merge: official v1.5.6 with multimodal storyboard enhancements

This commit is contained in:
Codex
2026-09-20 13:05:42 +08:00
199 changed files with 11465 additions and 2971 deletions

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveImageUrl } from "@/services/image-storage";
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
@@ -13,11 +12,13 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
};
/**
* 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。
* 本地 image: 类型的 storageKey 也会自动从 LocalForage 恢复有效的 Object URL。
* 远程资源图片统一使用稳定的资源文件地址。
* Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把
* `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。
*/
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) {
const remoteResource = Boolean(resourceIdFromStorageKey(storageKey));
const resourceId = resourceIdFromStorageKey(storageKey);
const remoteResource = Boolean(resourceId);
const localImageResource = Boolean(storageKey && storageKey.startsWith("image:"));
const targetRef = useRef<HTMLSpanElement>(null);
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
@@ -51,29 +52,14 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
let cancelled = false;
setCacheFailed(false);
if (remoteResource && storageKey) {
if (remoteResource && resourceId) {
if (!nearViewport) {
setCachedSrc("");
return () => {
cancelled = true;
};
}
setCachedSrc("");
const resolve = cacheResourceObjectUrl(storageKey);
void resolve
.then((url) => {
if (!cancelled) {
setCachedSrc(url || src);
setCacheFailed(!url && !src);
}
})
.catch(() => {
if (!cancelled) {
// 缓存读取失败时仍允许原始地址加载,真正的解码失败再显示占位。
setCacheFailed(!src);
setCachedSrc(src);
}
});
setCachedSrc(resourceFileUrl(resourceId));
return () => {
cancelled = true;
};
@@ -96,7 +82,7 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
return () => {
cancelled = true;
};
}, [localImageResource, nearViewport, remoteResource, src, storageKey]);
}, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]);
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {

View File

@@ -0,0 +1,47 @@
import { ArrowUpRight, Clapperboard, Layers3, Sparkles } from "lucide-react";
type AgentWelcomeProps = {
brandName: string;
nodeCount: number;
onChooseSkill: () => void;
onDraftPrompt: (prompt: string) => void;
};
export function AgentWelcome({ brandName, nodeCount, onChooseSkill, onDraftPrompt }: AgentWelcomeProps) {
return (
<section className="agent-welcome" aria-label="开始 Agent 创作">
<div className="agent-welcome-intro">
<span className="agent-welcome-orb" aria-hidden="true" />
<h2>在这里,和{brandName}让灵感,慢慢成形</h2>
<p>从一个想法开始,和 Agent 一起创作。</p>
</div>
<div className="agent-welcome-actions">
<button type="button" onClick={onChooseSkill}>
<Sparkles aria-hidden="true" />
<span>
<strong>选择技能,开始创作</strong>
<small>为这次创作找到合适的帮手</small>
</span>
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
</button>
<button type="button" onClick={() => onDraftPrompt("我想创作一段短片,请先和我一起梳理故事方向。先询问我的想法,不要直接生成。")}>
<Clapperboard aria-hidden="true" />
<span>
<strong>从灵感构思故事</strong>
<small>聊聊主题、角色,或一个难忘的画面</small>
</span>
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
</button>
<button type="button" disabled={nodeCount === 0} onClick={() => onDraftPrompt("请先阅读当前画布,梳理素材与节点之间的关系,给出接下来的创作建议。先不要修改节点或提交生成任务。")}>
<Layers3 aria-hidden="true" />
<span>
<strong>一起梳理当前画布</strong>
<small>{nodeCount > 0 ? `${nodeCount} 个节点,看看下一步可以做什么` : "添加节点后,一起梳理创作思路"}</small>
</span>
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
</button>
</div>
<p className="agent-welcome-footnote">先聊想法,再决定下一步</p>
</section>
);
}

View File

@@ -166,7 +166,9 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
{
id: "asset-tray-toggle",
label: open ? "收起素材空间" : `打开素材空间,共 ${(showLibrary ? assetImages.length : 0) + canvasImages.length} 项`,
icon: <span className="relative"><Images /><span className="absolute -right-1.5 -top-1.5 min-w-3 rounded-full px-0.5 text-center text-[var(--fs-nano)] font-bold leading-3" style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}>{(showLibrary ? assetImages.length : 0) + canvasImages.length}</span></span>,
displayLabel: "素材",
badge: (showLibrary ? assetImages.length : 0) + canvasImages.length,
icon: <Images />,
active: open,
onClick: () => {
bringToFront();
@@ -248,7 +250,7 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
) : null}
</AnimatePresence>
<FloatingDock items={dockItems} className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="素材空间" />
<FloatingDock items={dockItems} showLabels className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="素材空间" />
</div>
);
}

View File

@@ -163,7 +163,7 @@ export function AgentChatMessage({
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentTimelineMarker theme={theme} tone="agent" /> : null}
<div className={`min-w-0 text-sm leading-6 ${isUser ? "max-w-[82%] rounded-2xl rounded-br-md px-3.5 py-2.5 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color, ...(isUser ? { background: theme.node.agentUserMessage } : {}) }}>
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color }}>
{item.interjection ? (
<span
className="mb-1 inline-flex items-center rounded-full px-1.5 py-[1px] text-[var(--fs-label)] leading-4"
@@ -370,7 +370,7 @@ export function AgentPlanBar({ items, theme, minimized, onToggle }: {
const doneCount = items.filter((entry) => entry.status === "done").length;
const allDone = doneCount === items.length;
return (
<div className="agent-plan-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ background: theme.node.fill, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}>
<div className="agent-plan-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
<button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left focus-visible:outline focus-visible:outline-2" aria-expanded={!minimized} onClick={onToggle}>
<ListChecks className="size-3.5 shrink-0" style={{ color: allDone ? "#429477" : theme.node.muted }} />
<span className="text-xs font-semibold">本轮待办</span>
@@ -405,7 +405,7 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
disabled?: boolean;
}) {
return (
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ background: theme.node.fill, border: `1px solid ${theme.accent.primary}`, color: theme.node.text }}>
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
<div className="flex items-start gap-2 px-3 pt-2.5">
<HelpCircle className="mt-[1px] size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
<span className="min-w-0 flex-1 text-xs font-semibold leading-5">{question.question}</span>
@@ -417,8 +417,8 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
type="button"
disabled={disabled}
title={option.detail || option.label}
className="max-w-full rounded-md border px-3 py-1.5 text-left text-xs transition focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderColor: theme.node.stroke, background: theme.toolbar.itemHover }}
className="max-w-full rounded-md border-0 px-3 py-1.5 text-left text-xs transition focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: theme.toolbar.itemHover }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
@@ -600,13 +600,11 @@ export function AgentChatComposer({
};
return (
<div className="min-w-0 shrink-0 px-3 pb-3 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
<div className="agent-composer-wrap min-w-0 shrink-0" onWheelCapture={(event) => event.stopPropagation()}>
<div
className="group/composer relative rounded-2xl px-3 pb-2.5 pt-3 transition-[background-color,box-shadow] duration-200"
className="agent-composer-surface group/composer relative transition-[background-color,box-shadow] duration-200"
style={{
background: theme.node.fill,
color: theme.accent.primary,
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
}}
>
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
@@ -757,8 +755,8 @@ export function AgentChatComposer({
whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{ background: theme.accent.danger, color: theme.accent.onPrimary, boxShadow: `0 8px 20px ${theme.accent.danger}45` }}
className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{ background: theme.accent.danger, color: theme.accent.onPrimary }}
>
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
</motion.button> : null}
@@ -772,11 +770,10 @@ export function AgentChatComposer({
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }}
transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }}
className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
className="agent-composer-send grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{
background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface,
color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted,
boxShadow: canSubmit || sending ? `0 8px 20px ${theme.accent.primary}45` : "none",
}}
>
<motion.span
@@ -786,7 +783,7 @@ export function AgentChatComposer({
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
className="grid place-items-center"
>
{sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
{sending ? <LoaderCircle className="size-3.5 animate-spin" /> : <ArrowUp className="size-3.5" />}
</motion.span>
</motion.button>
</div>

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react";
import { Button, Dropdown, Input } from "antd";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
import { ArrowLeft, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
import { saveAs } from "file-saver";
import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export";
import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan";
@@ -24,6 +24,7 @@ import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory }
import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations";
import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
@@ -31,6 +32,7 @@ import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, Ag
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
import { AgentWelcome } from "./canvas-agent-welcome";
import "./canvas-cloud-agent.css";
type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; prefillPrompt?: string; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
@@ -598,8 +600,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
transition={{ duration: reducedMotion ? 0 : 0.26, ease: [0.16, 1, 0.3, 1] }}
className="canvas-agent-panel fixed z-[var(--z-modal-overlay)] flex min-w-0 flex-col overflow-hidden rounded-2xl border max-sm:rounded-b-none"
style={{ ...panelLayout.style, background: theme.node.panel, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 90px ${theme.spatial.shadow}` }}
className="canvas-agent-panel fixed z-[var(--z-modal-overlay)] flex min-w-0 flex-col overflow-hidden"
style={{ ...panelLayout.style, "--agent-surface-base": theme.node.panel, "--agent-ink": theme.node.text, "--agent-accent": theme.accent.primary, "--agent-shadow-color": theme.spatial.shadow } as CSSProperties & Record<`--${string}`, string>}
aria-label="Agent 工作台"
data-canvas-no-zoom
data-canvas-wheel-scroll
@@ -613,10 +615,11 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
aria-label="调整 Agent 面板大小"
title="拖动调整宽高,也可用方向键调整"
data-agent-resize="northwest"
className="absolute left-0 top-0 z-10 hidden size-5 cursor-nw-resize touch-none opacity-50 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 sm:block"
className="agent-panel-resize-corner absolute left-2 top-2 z-10 hidden size-5 cursor-nw-resize touch-none place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 sm:grid"
onKeyDown={panelLayout.onResizeKeyDown}
style={{ color: theme.node.muted }}
>
<span className="absolute left-1.5 top-1.5 size-2 border-l-2 border-t-2 rounded-tl" style={{ borderColor: theme.node.muted }} />
<MoveDiagonal2 className="size-3" aria-hidden="true" />
</button>
<AnimatePresence mode="wait" initial={false}>
{view === "settings" ? (
@@ -676,6 +679,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
<motion.div key="chat" className="flex min-h-0 flex-1 flex-col" initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}>
<AgentHeader
theme={theme}
hasMessages={messages.length > 0}
statusLabel={statusLabel}
statusColor={statusColor}
nodeCount={nodeCount}
@@ -715,6 +719,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
approval={approval}
nodeCount={nodeCount}
approvalSubmitting={approvalSubmitting || connectionStatus !== "connected"}
onChooseSkill={() => setSkillsOpen(true)}
onDraftPrompt={(draft) => setPrompt((current) => current.trim() ? `${current}\n\n${draft}` : draft)}
onApprovalReasonChange={(reason) => setApproval((current) => (current ? { ...current, reason } : current))}
onApprove={(settings) => void submitApproval("approve", settings)}
onReject={() => void submitApproval("reject")}
@@ -812,23 +818,20 @@ function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onO
);
}
function AgentHeader({ theme, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
function AgentHeader({ theme, hasMessages, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; hasMessages: boolean; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
return (
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-3 px-4" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
<span className="grid size-9 shrink-0 place-items-center rounded-xl" style={{ color: theme.node.text, background: theme.node.fill }}>
<Bot className="size-4" />
</span>
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Agent</span>
<span className="flex items-center gap-1 text-[11px]" style={{ color: statusColor }}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="agent-panel-title">{hasMessages ? "画布助手" : "新对话"}</span>
<span className="agent-panel-status flex items-center gap-1" style={{ color: statusColor }}>
<CircleDot className="size-3" />
{statusLabel}
</span>
</div>
<div className="mt-0.5 text-[11px] opacity-40">当前画布 · {nodeCount} 个节点</div>
<div className="agent-panel-context">Agent · 当前画布 {nodeCount} 个节点</div>
</div>
<div className="flex items-center gap-0.5">
<div className="agent-header-actions flex items-center gap-0.5" style={{ color: theme.node.muted }}>
<Button type="text" shape="circle" icon={<Download className="size-4" />} loading={exporting} onClick={onExport} aria-label="导出 Agent 调试记录" title="导出对话、工具参数、审批和错误(分享前请检查隐私)" />
<Button type="text" shape="circle" icon={<MessageSquarePlus className="size-4" />} onClick={onNew} aria-label="新建对话" title="新建对话" />
<Button type="text" shape="circle" icon={<History className="size-4" />} onClick={onHistory} aria-label="历史对话" title="历史对话" />
@@ -842,7 +845,7 @@ function AgentHeader({ theme, statusLabel, statusColor, nodeCount, onNew, onHist
function AgentHistory({ conversations, activeConversationId, theme, onBack, onNew, onOpen, onDelete }: { conversations: CloudAgentConversation[]; activeConversationId: string; theme: CanvasTheme; onBack: () => void; onNew: () => void; onOpen: (conversation: CloudAgentConversation) => void; onDelete: (id: string) => void }) {
return (
<div className="canvas-agent-history-root flex min-h-0 min-w-0 flex-1 flex-col">
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-2 px-3" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-2">
<Button type="text" shape="circle" icon={<ArrowLeft className="size-4" />} onClick={onBack} aria-label="返回对话" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">历史对话</div>
@@ -891,6 +894,8 @@ function AgentConversation({
approval,
approvalSubmitting,
nodeCount,
onChooseSkill,
onDraftPrompt,
onFocusNode,
onApprovalReasonChange,
onApprove,
@@ -903,11 +908,14 @@ function AgentConversation({
approval: ApprovalState | null;
approvalSubmitting: boolean;
nodeCount: number;
onChooseSkill: () => void;
onDraftPrompt: (prompt: string) => void;
onFocusNode?: (nodeId: string) => void;
onApprovalReasonChange: (reason: string) => void;
onApprove: (settings?: AgentMediaSettings) => void;
onReject: () => void;
}) {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
@@ -934,12 +942,12 @@ function AgentConversation({
}, []);
return (
<div ref={scrollRef} data-agent-conversation className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-5" onScroll={(event) => {
<div ref={scrollRef} data-agent-conversation className="agent-conversation thin-scrollbar min-h-0 flex-1 overflow-y-auto" onScroll={(event) => {
const element = event.currentTarget;
followRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
}}>
{!messages.length ? <AgentEmptyState theme={theme} nodeCount={nodeCount} /> : null}
<div ref={contentRef} className="space-y-2.5">
{!messages.length ? <AgentWelcome brandName={brandName} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
<div ref={contentRef} className="agent-conversation-messages">
{messages.map((item) => (
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
))}
@@ -952,18 +960,6 @@ function AgentConversation({
);
}
function AgentEmptyState({ theme, nodeCount }: { theme: CanvasTheme; nodeCount: number }) {
return (
<div className="flex h-full min-h-72 flex-col items-center justify-center px-8 pb-8 text-center">
<div className="grid size-12 place-items-center rounded-2xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
<Bot className="size-6" />
</div>
<h2 className="mt-5 text-lg font-semibold">现在想做什么?</h2>
<p className="mt-2 max-w-64 text-xs leading-5" style={{ color: theme.node.muted }}>当前画布有 {nodeCount} 个节点。Agent 可以按权限读取画布、调用已启用 Skill、整理文本节点并提交受预算约束的生成任务。</p>
</div>
);
}
function ComposerControls({
reasoningMode,
reasoningSupported,
@@ -992,6 +988,7 @@ function ComposerControls({
selectedSkillCount: number;
}) {
const permissionVisual = agentPermissionVisual(permissionMode);
const PermissionIcon = permissionVisual.icon;
return (
<div className="flex min-w-0 flex-wrap items-center gap-0.5">
<ModelPicker
@@ -1000,7 +997,7 @@ function ComposerControls({
capability="text"
onChange={onModelChange}
variant="creation"
className="!h-8 !min-w-0 !w-36 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
className="!h-8 !min-w-0 !w-52 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
popoverClassName="agent-model-picker-popover"
showSelectedPrice={false}
showOptionPrices
@@ -1014,12 +1011,12 @@ function ComposerControls({
<Dropdown trigger={["click"]} placement="topLeft" menu={{ items: agentPermissionMenuItems(permissionMode, onPermissionChange) }}>
<button
type="button"
className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
className="grid size-8 shrink-0 place-items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
style={{ color: theme.node.muted, background: "transparent" }}
aria-label="选择 Agent 执行权限"
aria-label={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
title={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
>
<ShieldCheck className="size-3.5" style={{ color: permissionVisual.color }} />
<span className="max-w-20 truncate">{agentPermissionLabel(permissionMode)}</span>
<PermissionIcon className="size-3.5" style={{ color: permissionVisual.color }} aria-hidden="true" />
</button>
</Dropdown>
<button
@@ -1033,7 +1030,7 @@ function ComposerControls({
onClick={() => onSkillsOpenChange(true)}
>
<Sparkles className="size-3.5" />
<span className="max-w-28 truncate">Skills技能包({selectedSkillCount})</span>
<span className="max-w-28 truncate">Skills({selectedSkillCount})</span>
</button>
</div>
);

View File

@@ -76,7 +76,7 @@ export function CanvasCloudAgentSettings(props: AgentSettingsProps) {
return (
<div className="canvas-agent-settings flex min-h-0 min-w-0 flex-1 flex-col" style={{ background: theme.node.panel }}>
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-2 px-3" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-2">
<Button type="text" shape="circle" icon={<ArrowLeft className="size-4" />} onClick={section === "home" ? props.onBack : goHome} aria-label={section === "home" ? "返回对话" : "返回设置"} />
<div className="min-w-0 flex-1"><div className="text-sm font-semibold">{title}</div><div className="mt-0.5 text-[11px] opacity-40">{section === "home" ? "只影响下一次新运行" : sectionSubtitle(section)}</div></div>
{section !== "home" ? <span className="rounded-full px-2 py-1 text-[10px] opacity-50" style={{ background: theme.node.fill }}>{section === "skills" ? `${props.selectedSkillIds.length} 已启用` : "当前 Agent"}</span> : null}
@@ -317,5 +317,5 @@ function sectionTitle(section: SettingsSection) { return section === "profile" ?
function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "memories" ? "只属于你,批准后才会注入会话" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
export function agentPermissionLabel(mode: AgentPermissionMode) { return permissionOptions.find((option) => option.value === mode)?.label || "请求审批"; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f` }; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f`, icon: option.icon }; }
export function agentPermissionMenuItems(mode: AgentPermissionMode, onChange: (mode: AgentPermissionMode) => void) { return permissionOptions.map((option) => ({ key: option.value, label: <span>{option.label}</span>, icon: mode === option.value ? <Check className="size-3.5" style={{ color: option.color }} /> : <option.icon className="size-3.5" style={{ color: option.color }} />, onClick: () => onChange(option.value) })); }

View File

@@ -1,6 +1,242 @@
.canvas-agent-panel {
/* Canvas theme -> material tokens. Only the floating shell blurs the canvas. */
--agent-material-solid: color-mix(in srgb, var(--agent-ink) 3%, var(--agent-surface-base));
--agent-material-glass: color-mix(in srgb, var(--agent-material-solid) 68%, transparent);
--agent-material-sheen: color-mix(in srgb, var(--color-neutral-0) 8%, transparent);
--agent-surface-soft: color-mix(in srgb, var(--agent-ink) 4%, transparent);
--agent-surface-hover: color-mix(in srgb, var(--agent-ink) 7%, transparent);
--agent-surface-raised: color-mix(in srgb, var(--agent-ink) 6%, var(--agent-material-solid));
--agent-caption: color-mix(in srgb, var(--agent-ink) 62%, var(--agent-surface-base));
--agent-radius-shell: calc(var(--radius) * 3.5);
--agent-radius-surface: calc(var(--radius) * 2.5);
--agent-material-shadow: 0 24px 80px var(--agent-shadow-color);
--agent-focus-ring: color-mix(in srgb, var(--agent-accent) 65%, var(--agent-ink));
container-type: inline-size;
overscroll-behavior: contain;
isolation: isolate;
border: 0;
border-radius: var(--agent-radius-shell);
background: var(--agent-material-solid);
color: var(--agent-ink);
box-shadow: var(--agent-material-shadow);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.canvas-agent-panel {
background: linear-gradient(145deg, var(--agent-material-sheen), transparent 55%), var(--agent-material-glass);
-webkit-backdrop-filter: blur(32px) saturate(145%);
backdrop-filter: blur(32px) saturate(145%);
}
}
.agent-panel-header {
min-height: 80px;
padding: var(--space-4) var(--space-5) var(--space-3) var(--space-6);
}
.agent-panel-title {
font-size: var(--fs-heading);
font-weight: 600;
letter-spacing: -0.02em;
}
.agent-panel-context {
margin-top: var(--space-1);
color: var(--agent-caption);
font-size: var(--fs-label);
}
.agent-panel-status {
font-size: var(--fs-label);
}
.agent-header-actions .ant-btn {
color: inherit;
}
.agent-conversation {
padding: var(--space-4) var(--space-6) var(--space-6);
scroll-padding-block: var(--space-6);
}
.agent-conversation-messages > * + * {
margin-top: var(--space-4);
}
.agent-message-user {
border-radius: var(--agent-radius-surface) var(--agent-radius-surface) var(--space-2) var(--agent-radius-surface);
background: var(--agent-surface-hover, color-mix(in srgb, var(--foreground) 7%, transparent));
}
.agent-welcome {
display: flex;
min-height: 100%;
flex-direction: column;
justify-content: center;
align-items: center;
padding-block: var(--space-6);
}
.agent-welcome-intro {
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.agent-welcome-orb {
display: inline-block;
width: 52px;
height: 52px;
margin-bottom: var(--space-5);
border-radius: 50%;
background:
radial-gradient(circle at 32% 24%, var(--color-neutral-0), transparent 36%),
radial-gradient(circle at 70% 80%, var(--palette-node-audio), transparent 64%),
radial-gradient(circle at 35% 65%, color-mix(in srgb, var(--agent-accent) 65%, var(--color-neutral-0)), var(--agent-accent));
box-shadow: inset -4px -6px 14px color-mix(in srgb, var(--agent-accent) 28%, transparent), 0 8px 32px color-mix(in srgb, var(--agent-accent) 14%, transparent);
}
.agent-welcome-intro h2 {
margin: 0;
color: var(--agent-ink);
font-size: var(--fs-title);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1.5;
text-wrap: balance;
}
.agent-welcome-intro p {
margin: var(--space-2) 0 0;
color: var(--agent-caption);
font-size: var(--fs-caption);
line-height: 1.7;
}
.agent-welcome-actions {
display: grid;
width: 100%;
max-width: 360px;
gap: var(--space-1);
margin-top: var(--space-8);
}
.agent-welcome-actions button {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 0;
border-radius: var(--space-4);
background: transparent;
color: var(--agent-caption);
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.agent-welcome-actions button:hover:not(:disabled) {
background: var(--agent-surface-soft);
color: var(--agent-ink);
}
.agent-welcome-actions button:focus-visible {
outline: 2px solid var(--agent-focus-ring);
outline-offset: 2px;
}
.agent-welcome-actions button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.agent-welcome-actions svg {
width: var(--space-5);
height: var(--space-5);
flex: 0 0 auto;
stroke-width: 1.5;
}
.agent-welcome-actions button > span {
min-width: 0;
flex: 1;
}
.agent-welcome-actions strong,
.agent-welcome-actions small {
display: block;
font-weight: 400;
line-height: 1.6;
}
.agent-welcome-actions strong {
color: var(--agent-ink);
font-size: var(--fs-body);
}
.agent-welcome-actions small {
margin-top: var(--space-half);
font-size: var(--fs-label);
}
.agent-welcome-actions .agent-welcome-arrow {
width: var(--space-4);
height: var(--space-4);
opacity: 0.6;
}
.agent-welcome-footnote {
margin: var(--space-6) 0 0;
color: var(--agent-caption);
font-size: var(--fs-label);
}
.agent-composer-wrap {
padding: var(--space-2) var(--space-4) var(--space-4);
}
.agent-composer-surface {
padding: var(--space-4) var(--space-4) var(--space-3);
border-radius: var(--agent-radius-surface, var(--radius-xl));
background: var(--agent-surface-raised, var(--background));
}
.agent-composer-surface:focus-within {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent-accent, var(--foreground)) 35%, transparent);
}
.agent-plan-bar {
border: 0;
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
}
.agent-question-bar {
border: 0;
background: var(--agent-surface-raised, var(--background));
}
@container (max-width: 400px) {
.agent-panel-header {
gap: var(--space-2);
padding-inline: var(--space-4);
}
.agent-panel-status { white-space: nowrap; }
.agent-conversation { padding-inline: var(--space-4); }
.agent-welcome-actions button { padding-inline: var(--space-2); }
}
@media (max-width: 639px) {
.canvas-agent-panel { border-end-start-radius: 0; border-end-end-radius: 0; }
.agent-composer-wrap { padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
}
@media (prefers-reduced-transparency: reduce) {
.canvas-agent-panel { background: var(--agent-material-solid); -webkit-backdrop-filter: none; backdrop-filter: none; }
}
@media (prefers-reduced-motion: reduce) {
.agent-welcome-actions button { transition: none; }
}
/* Agent 浮窗高于普通画布层,模型菜单必须叠在对话窗之上;菜单本身与全站二级选择器同一套。 */
@@ -172,16 +408,16 @@
.agent-tool-action-link {
min-width: 0;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border: 0;
border-radius: 8px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 4px 7px;
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 3%, transparent));
padding: var(--space-2) var(--space-3);
color: color-mix(in srgb, currentColor 88%, transparent);
font-size: 11px;
line-height: 17px;
overflow-wrap: anywhere;
text-align: left;
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
transition: background-color 140ms ease, color 140ms ease;
}
.agent-tool-action-link:not(:disabled) {
@@ -189,7 +425,6 @@
}
.agent-tool-action-link:not(:disabled):hover {
border-color: color-mix(in srgb, var(--foreground) 26%, transparent);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
color: var(--foreground);
}
@@ -412,7 +647,7 @@
}
.agent-reasoning-card[open] {
background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background));
background: var(--agent-surface-soft, color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background)));
}
.agent-reasoning-summary {
@@ -590,11 +825,10 @@
.canvas-agent-approval-card {
--agent-approval-blue: #2563eb;
--agent-approval-blue-hover: #1d4ed8;
border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--foreground) 4%, var(--background));
padding: 16px;
box-shadow: 0 10px 28px color-mix(in srgb, var(--foreground) 5%, transparent);
border: 0;
border-radius: var(--agent-radius-surface, var(--radius-xl));
background: var(--agent-surface-raised, color-mix(in srgb, var(--foreground) 4%, var(--background)));
padding: var(--space-5);
}
.canvas-agent-approval-header {
@@ -654,7 +888,7 @@
display: grid;
gap: 6px;
min-width: 0;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border: 0;
border-radius: 10px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 9px 10px;

View File

@@ -11,6 +11,7 @@ import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-ge
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities";
import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract";
import type { CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows";
@@ -66,6 +67,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
const theme = canvasThemes[useActiveTheme()];
const mode = node.metadata?.generationMode === "video" || node.metadata?.generationMode === "audio" ? node.metadata.generationMode : "image";
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
const simpleMode = workspaceMode === "simple";
const resolvedProvider = resolveCanvasWorkflowProvider(node.metadata);
const workflowProvider = resolvedProvider;
@@ -74,16 +76,16 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
capability: mode,
input: inputSummary,
videoOperation: node.metadata?.videoEditOperation,
videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined,
videoSeconds: mode === "video" ? node.metadata?.seconds ?? globalConfig.videoSeconds : undefined,
options: modelRequestOptions({
...globalConfig,
size: node.metadata?.size || globalConfig.size,
quality: node.metadata?.quality || globalConfig.quality,
count: String(node.metadata?.count || globalConfig.count),
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
videoSeconds: node.metadata?.seconds ?? globalConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark,
videoGenerateAudio: node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark ?? globalConfig.videoWatermark,
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed,
@@ -450,6 +452,7 @@ function WorkflowSelectedLabel({ kind, label }: { kind: "app" | "workflow"; labe
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig {
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
const workflowProvider = mode === "text" ? "model" : resolveCanvasWorkflowProvider(node.metadata);
if (workflowProvider === "model") return buildModelNodeConfig(globalConfig, node, mode, requirements);
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
@@ -476,7 +479,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
const workflowParameter = (source: string) => workflowParameters[`source:${source}`] === undefined ? "" : String(workflowParameters[`source:${source}`]);
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || workflowParameter("quality") || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined;
const videoProfile = mode === "video" ? capabilityProfile.video! : undefined;
const rawVideoSettings = { seconds: node.metadata?.seconds || workflowParameter("videoSeconds") || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || workflowParameter("vquality") || globalConfig.vquality || defaultConfig.vquality };
const rawVideoSettings = { seconds: node.metadata?.seconds ?? workflowParameter("videoSeconds") ?? globalConfig.videoSeconds ?? defaultConfig.videoSeconds, ratio: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || workflowParameter("vquality") || globalConfig.vquality || defaultConfig.vquality };
const normalizedVideo = videoProfile
? { seconds: String(rawVideoSettings.seconds), ratio: rawVideoSettings.ratio, resolution: rawVideoSettings.resolution }
: undefined;
@@ -489,10 +492,10 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
quality: workflowParameter("quality") || normalizedImage?.quality || node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: normalizedImage?.size || normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
transparentBackground: normalizedImage?.transparentBackground || ((node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false"),
videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
videoSeconds: normalizedVideo?.seconds ?? normalizeVideoDuration(node.metadata?.seconds ?? globalConfig.videoSeconds ?? defaultConfig.videoSeconds),
vquality: String(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false",
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio ?? String(videoProfile.generateAudio.default) : "false",
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark ?? globalConfig.videoWatermark ?? String(videoProfile.watermark.default) : "false",
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
@@ -502,6 +505,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
}
function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig {
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node.metadata?.model;
@@ -544,10 +548,10 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode
quality: generationDefaults.quality || globalConfig.quality || defaultConfig.quality,
size: generationDefaults.size ?? globalConfig.size ?? defaultConfig.size,
transparentBackground: generationDefaults.transparentBackground || "false",
videoSeconds: generationDefaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
videoSeconds: generationDefaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: generationDefaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark || String(videoProfile.watermark.default) : "false",
videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio ?? String(videoProfile.generateAudio.default) : "false",
videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark ?? String(videoProfile.watermark.default) : "false",
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,

View File

@@ -4,6 +4,7 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { uploadVeilOpacity } from "@/lib/canvas/canvas-file-upload";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import "./canvas-file-upload.css";
export function CanvasFileUploadContent({ node, theme, reduceMotion = false }: { node: CanvasNodeData; theme: CanvasTheme; reduceMotion?: boolean }) {
const { upload } = useCanvasNodeActions();

View File

@@ -0,0 +1,71 @@
/* Canvas file upload placeholder: a local component owns its visual state. */
.canvas-file-upload {
--upload-glow-blue: color-mix(in srgb, var(--status-loading) 22%, transparent);
--upload-glow-mint: color-mix(in srgb, var(--status-success) 14%, transparent);
--upload-glow-warm: color-mix(in srgb, var(--status-warning) 12%, transparent);
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--upload-surface);
color: var(--upload-text);
}
.canvas-file-upload-glow {
transition: opacity 180ms ease-out;
position: absolute;
inset: -45%;
background: radial-gradient(ellipse at 28% 32%, var(--upload-glow-blue), transparent 48%), radial-gradient(ellipse at 75% 64%, var(--upload-glow-mint), transparent 46%), radial-gradient(ellipse at 55% 78%, var(--upload-glow-warm), transparent 42%);
animation: canvas-file-upload-drift 5s ease-in-out infinite alternate;
pointer-events: none;
}
.canvas-file-upload-frost {
transition: opacity 180ms ease-out;
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--upload-surface) 24%, transparent);
backdrop-filter: blur(20px) saturate(0.8);
pointer-events: none;
}
.canvas-file-upload-frost::after {
content: "";
position: absolute;
inset: -60%;
background: linear-gradient(110deg, transparent 35%, color-mix(in srgb, var(--upload-text) 4%, transparent) 47%, color-mix(in srgb, var(--upload-surface) 40%, transparent) 51%, transparent 65%);
animation: canvas-file-upload-sheen 4s ease-in-out infinite;
}
.canvas-file-upload-copy {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 100%;
padding: var(--space-5);
text-align: center;
}
.canvas-file-upload-copy > svg { color: var(--upload-muted); }
.canvas-file-upload-label { font-size: var(--fs-caption); font-weight: 500; }
.canvas-file-upload-detail { max-width: 100%; font-size: var(--fs-tiny); color: var(--upload-muted); overflow-wrap: anywhere; }
.canvas-file-upload-retry { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--r-md); background: color-mix(in srgb, var(--upload-text) 8%, transparent); font-size: var(--fs-caption); cursor: pointer; }
.canvas-file-upload-retry:hover { background: color-mix(in srgb, var(--upload-text) 12%, transparent); }
.canvas-file-upload-retry:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
@keyframes canvas-file-upload-drift { from { transform: translate(-8%, -4%) rotate(-8deg); } to { transform: translate(8%, 4%) rotate(12deg); } }
@keyframes canvas-file-upload-sheen { from { transform: translateX(-35%); } to { transform: translateX(35%); } }
.canvas-file-upload[data-static] .canvas-file-upload-glow,
.canvas-file-upload[data-static] .canvas-file-upload-frost::after { animation: none; }
.canvas-file-upload[data-static] .canvas-file-upload-glow,
.canvas-file-upload[data-static] .canvas-file-upload-frost { transition: none; }
@media (prefers-reduced-motion: reduce) {
.canvas-file-upload-glow, .canvas-file-upload-frost::after { animation: none; }
.canvas-file-upload-glow, .canvas-file-upload-frost { transition: none; }
}

View File

@@ -6,6 +6,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
@@ -15,7 +16,7 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl, peekCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache";
import { scheduleResourceBlobCache } from "@/services/resource-blob-cache";
import { resolveMediaUrl } from "@/services/file-storage";
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -306,7 +307,7 @@ function UnknownNodeContent({ theme }: Pick<CanvasNodeContentProps, "theme">) {
}
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) {
const fontSize = node.metadata?.fontSize || 14;
const fontSize = canvasTextFontSize(node.width, node.height, node.metadata?.fontSize);
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as CSSProperties;
const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]);
@@ -650,49 +651,27 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
const resourceId = resourceIdFromStorageKey(storageKey);
const isRemoteResource = Boolean(resourceId);
// 图片内容随资源 ID 不可变且后端允许磁盘强缓存:视口内的远程图片首帧直接上直链,
// 走浏览器原生解码与磁盘缓存;Blob 缓存就绪后再平滑替换,避免刷新后满屏转圈。
const synchronousUrl = eager && isRemoteResource && node.type === CanvasNodeType.Image ? peekCachedResourceObjectUrl(storageKey) || resourceFileUrl(resourceId) : "";
// 远程资源展示统一走稳定的云端资源文件地址。Blob 缓存只服务于导出、抽帧和
// 供应商输入等字节处理,不再把缓存生成的 blob: URL 写入媒体展示链路。
const remoteUrl = isRemoteResource ? resourceFileUrl(resourceId) : "";
// Inline data URLs are already local, but decoding thousands of them is
// still expensive. Images must wait for the same viewport gate as remote
// resources; otherwise DOM virtualization does not reduce image work.
const isLazyVisual = node.type === CanvasNodeType.Image;
const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:"));
const initialUrl = synchronousUrl || (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
const initialUrl = eager && isRemoteResource ? remoteUrl : (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
const [url, setUrl] = useState(() => initialUrl);
const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager);
useEffect(() => {
let cancelled = false;
if (!isRemoteResource) {
setUrl(isLazyVisual && !eager ? "" : fallback);
setLoading(false);
return;
}
const cachedSync = peekCachedResourceObjectUrl(storageKey);
if (cachedSync) {
setUrl(cachedSync);
setLoading(false);
return;
}
if (!url && eager && isHttpUrl) {
setUrl(fallback);
setLoading(false);
} else if (!url) {
setLoading(eager);
}
// 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。
const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey);
void resolve.then((cached) => {
if (!cancelled && cached) setUrl(cached);
else if (!cancelled && eager && fallback) setUrl(fallback);
}).catch(() => {
if (!cancelled && eager) setUrl(synchronousUrl || fallback);
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [eager, fallback, isHttpUrl, isLazyVisual, isRemoteResource, storageKey]);
setUrl(eager ? remoteUrl : "");
setLoading(false);
}, [eager, fallback, isLazyVisual, isRemoteResource, remoteUrl]);
return { url, loading };
}

View File

@@ -301,14 +301,14 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text, opacity: options.smartMode ? 1 : 0.55 }}
/>
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>预设</span>
<div className="grid grid-cols-4 gap-1.5">
<div className="grid shrink-0 auto-rows-[60px] grid-cols-4 gap-1.5">
{STYLE_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
aria-pressed={options.stylePreset === preset.id}
onClick={() => updateOption("stylePreset", options.stylePreset === preset.id ? "" : preset.id)}
className={`relative h-[60px] overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
className={`relative h-full min-h-0 overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
style={{
backgroundImage: `linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0.08)), url(${preset.image})`,
backgroundSize: "cover",

View File

@@ -6,6 +6,7 @@ import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText,
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract";
import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -79,6 +80,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
const simpleMode = workspaceMode === "simple";
const mode = defaultMode(node.type);
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
@@ -107,17 +109,17 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
characterCount: activeReferences.filter((item) => item.kind === "character").length,
},
videoOperation: node.metadata?.videoEditOperation,
videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined,
videoSeconds: mode === "video" ? node.metadata?.seconds ?? globalConfig.videoSeconds : undefined,
options: modelRequestOptions({
...globalConfig,
size: node.metadata?.size || globalConfig.size,
quality: node.metadata?.quality || globalConfig.quality,
count: String(node.metadata?.count || globalConfig.count),
transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
videoSeconds: node.metadata?.seconds ?? globalConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark,
videoGenerateAudio: node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark ?? globalConfig.videoWatermark,
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed,
@@ -1054,6 +1056,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
}
export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig {
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
@@ -1094,7 +1097,7 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality,
size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
transparentBackground: defaults.transparentBackground ?? "false",
videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
videoSeconds: defaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds ?? defaultConfig.videoSeconds),
vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: defaults.videoGenerateAudio ?? globalConfig.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
videoWatermark: defaults.videoWatermark ?? globalConfig.videoWatermark ?? defaultConfig.videoWatermark,

View File

@@ -9,8 +9,6 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { resolveMediaUrl } from "@/services/file-storage";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser";
import { DEFAULT_MAX_CHARS_PER_ENTRY, MAX_CHARS_PER_ENTRY_LIMIT, MIN_CHARS_PER_ENTRY, resegmentSrtEntries, splitLongEntry } from "@/lib/timeline/srt-resegment";
import { buildFallbackHighlights, remapHighlightsAfterResegment } from "@/lib/timeline/subtitle-highlights";
@@ -62,8 +60,7 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
return () => abortRef.current?.abort();
}, []);
// 打开弹窗时解析视频地址,用于字幕叠加预览。
// 远端资源优先走节点同款缓存下载(对象 URL),失败再退回资源代理地址。
// 打开弹窗时解析视频地址,用于字幕叠加预览;远端资源统一走稳定的云端地址。
useEffect(() => {
if (!open) return;
let cancelled = false;
@@ -72,25 +69,9 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
setVideoError(false);
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const applyUrl = (url: string) => {
void resolveMediaUrl(storageKey, fallback).then((url) => {
if (!cancelled) setVideoUrl(url);
};
if (resourceIdFromStorageKey(storageKey)) {
void cacheResourceObjectUrl(storageKey)
.then((cached) => {
if (cancelled) return;
if (cached) {
setVideoUrl(cached);
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
})
.catch(() => {
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
});
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
});
return () => {
cancelled = true;
};

View File

@@ -3,8 +3,6 @@ import { Pause, Play } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveMediaUrl } from "@/services/file-storage";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import type { TimelineClip } from "@/types/timeline";
@@ -57,28 +55,12 @@ export function CanvasTimelinePreview({ clips, nodes, playheadMs, playing, theme
setVideoSize(null);
if (!node && !media) return;
let cancelled = false;
// 直连媒体片段(directMedia,不落画布)与画布节点走同一套缓存/回退解析策略
// 直连媒体片段(directMedia,不落画布)与画布节点走同一套稳定资源地址解析策略。
const storageKey = node?.metadata?.storageKey || media?.storageKey || "";
const fallback = node?.metadata?.content || media?.url || "";
const applyUrl = (url: string) => {
void resolveMediaUrl(storageKey, fallback).then((url) => {
if (!cancelled) setVideoUrl(url);
};
if (resourceIdFromStorageKey(storageKey)) {
void cacheResourceObjectUrl(storageKey)
.then((cached) => {
if (cancelled) return;
if (cached) {
setVideoUrl(cached);
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
})
.catch(() => {
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
});
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
});
return () => {
cancelled = true;
};

View File

@@ -5,9 +5,7 @@ import { nanoid } from "nanoid";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatVideoFrameTime, normalizeVideoFrameTimes } from "@/lib/canvas/canvas-video-frame";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
@@ -49,22 +47,9 @@ export function CanvasVideoFrameDialog({ node, open, onClose, onConfirm }: Canva
setFrames([]);
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const applyUrl = (url: string) => {
void resolveMediaUrl(storageKey, fallback).then((url) => {
if (!cancelled) setVideoUrl(url);
};
if (resourceIdFromStorageKey(storageKey)) {
void cacheResourceObjectUrl(storageKey)
.then((cached) => {
if (cancelled) return;
if (cached) setVideoUrl(cached);
else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
})
.catch(() => {
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
});
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
});
return () => {
cancelled = true;
};

View File

@@ -12,8 +12,6 @@ import { modelRequestOptions, resolveCompatibleModel, type ModelRequirements } f
import { navigateToSettings } from "@/lib/settings-navigation";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { resolveMediaUrl } from "@/services/file-storage";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
import { type CanvasConnection, type CanvasNodeData, type CanvasVideoEditOperation } from "@/types/canvas";
import type { TimelineProject } from "@/types/timeline";
@@ -82,22 +80,9 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode,
setOperation(profile?.operations.includes("extend") ? "extend" : (profile?.operations[0] as CanvasVideoEditOperation | undefined) || "extend");
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const applyUrl = (url: string) => {
void resolveMediaUrl(storageKey, fallback).then((url) => {
if (!cancelled) setVideoUrl(url);
};
if (resourceIdFromStorageKey(storageKey)) {
void cacheResourceObjectUrl(storageKey)
.then((cached) => {
if (cancelled) return;
if (cached) setVideoUrl(cached);
else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
})
.catch(() => {
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
});
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
});
return () => {
cancelled = true;
};

View File

@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type RefObject } from "react";
import { Compass, Focus, HelpCircle, LayoutTemplate, Minus, Plus } from "lucide-react";
import { Compass, Eye, EyeOff, Focus, HelpCircle, LayoutTemplate, Minus, Plus } from "lucide-react";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -14,6 +14,8 @@ type CanvasZoomControlsProps = {
onScaleChange: (scale: number) => void;
onFitContent: () => void;
onAutoArrange?: () => void;
hideNodeConnections?: boolean;
onHideNodeConnectionsChange?: (value: boolean) => void;
isMiniMapOpen: boolean;
onToggleMiniMap: () => void;
onOpenShortcuts: () => void;
@@ -22,7 +24,7 @@ type CanvasZoomControlsProps = {
const QUICK_ZOOM_LEVELS = [0.25, 0.5, 1, 2] as const;
export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoArrange, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) {
export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoArrange, hideNodeConnections = false, onHideNodeConnectionsChange, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) {
const theme = canvasThemes[useActiveTheme()];
const rootRef = useRef<HTMLDivElement>(null);
const liveScaleRef = useRef(scale);
@@ -73,6 +75,7 @@ export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoA
{ id: "zoom-minimap", label: isMiniMapOpen ? "关闭小地图" : "打开小地图", icon: <Compass />, active: isMiniMapOpen, onClick: onToggleMiniMap },
{ id: "zoom-fit", label: "适应画布", icon: <Focus />, onClick: onFitContent },
...(onAutoArrange ? [{ id: "zoom-auto-arrange", label: "自动整理节点", icon: <LayoutTemplate />, onClick: onAutoArrange }] : []),
...(onHideNodeConnectionsChange ? [{ id: "zoom-hide-node-connections", label: hideNodeConnections ? "显示全部连线" : "设置隐藏节点连线", icon: hideNodeConnections ? <Eye /> : <EyeOff />, active: hideNodeConnections, onClick: () => onHideNodeConnectionsChange(!hideNodeConnections) }] : []),
{ kind: "separator", id: "zoom-separator" },
{ id: "zoom-out", label: "缩小画布", icon: <Minus />, onClick: () => commitScale(liveScaleRef.current - 0.1) },
{

View File

@@ -29,7 +29,6 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
footer={null}
centered
onCancel={onClose}
styles={{ body: { padding: 0 } }}
modalRender={(node) => (
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
{node}

View File

@@ -1,6 +1,6 @@
import { Button } from "antd";
import { Button, ConfigProvider } from "antd";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
/** 内容库的浏览控件,不复用后台表格的筛选外框。 */
export function CollectionToolbar({ children, trailing, active, onReset, label = "搜索与筛选" }: {
@@ -10,8 +10,23 @@ export function CollectionToolbar({ children, trailing, active, onReset, label =
onReset?: () => void;
label?: string;
}) {
return <section className="collection-toolbar" aria-label={label}>
<div className="collection-toolbar-controls">{children}</div>
const [inputModality, setInputModality] = useState<"keyboard" | "pointer">("keyboard");
return <section
className="collection-toolbar"
aria-label={label}
data-input-modality={inputModality}
onPointerDownCapture={() => setInputModality("pointer")}
onKeyDownCapture={(event) => {
if (!event.altKey && !event.ctrlKey && !event.metaKey) setInputModality("keyboard");
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setInputModality("keyboard");
}}
>
<ConfigProvider select={{ variant: "filled", classNames: { popup: { root: "workspace-quiet-popup" } } }}>
<div className="collection-toolbar-controls">{children}</div>
</ConfigProvider>
<div className="collection-toolbar-actions">
{active && onReset ? <Button type="text" icon={<RotateCcw />} onClick={onReset}>重置</Button> : null}
{trailing}

View File

@@ -1,4 +1,4 @@
.workspace-account-popover .ant-popover-container { padding: 6px; border: 0 !important; border-radius: 20px; background: var(--user-surface-raised) !important; box-shadow: none !important; }
.workspace-account-popover .ant-popover-container { padding: var(--space-2); }
.workspace-account-popover .workspace-topbar-account-menu { width: auto; min-width: 0; padding: 0; }
.workspace-account-card { width: min(304px, calc(100vw - 48px)); color: var(--user-ink); padding: 12px; }
.workspace-account-card-identity { display: flex; align-items: center; gap: 12px; padding: 4px 0 16px; }
@@ -8,7 +8,7 @@
.workspace-account-card-identity strong { font-size: 15px; line-height: 24px; }
.workspace-account-card-identity span { color: var(--user-ink-muted); font-size: 12px; }
.workspace-account-card-identity em { font-style: normal; font-size: 10px; padding: 4px 8px; border-radius: 999px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
.workspace-account-card-wallet { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; padding: 16px; margin-bottom: 12px; border-radius: 16px; background: linear-gradient(120deg, var(--user-accent-soft), color-mix(in srgb, var(--product-accent-blue) 10%, var(--user-surface))); }
.workspace-account-card-wallet { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; padding: 16px; margin-bottom: 12px; border-radius: var(--user-radius-card); background: var(--user-surface-muted); }
.workspace-account-card-balance > span { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--user-ink-muted); }
.workspace-account-card-balance strong { display: block; margin-top: 4px; font-size: 26px; font-weight: 650; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.workspace-account-card-wallet > button { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--user-accent); white-space: nowrap; padding: 6px 0; }

View File

@@ -27,6 +27,7 @@ export function WorkspaceAccountMenu() {
<><Popover
trigger="click"
placement="bottomRight"
arrow={false}
rootClassName="workspace-account-popover"
open={menuOpen}
onOpenChange={setMenuOpen}

View File

@@ -81,7 +81,7 @@ function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user
<WorkspaceSidebarCheckin collapsed={collapsed} />
<WorkspaceSidebarStorageMeter collapsed={collapsed} />
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" rootClassName="workspace-account-popover" content={content}>
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" arrow={false} rootClassName="workspace-account-popover" content={content}>
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}

View File

@@ -350,7 +350,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
}
function TextCapabilityEditor({ value, onChange, protocol, disabled, section }: Pick<Props, "value" | "onChange" | "protocol" | "disabled" | "section">) {
const profile = value?.text || defaultModelCapabilityConfig(protocol).text!;
const profile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol)).text!;
const update = (patch: Partial<TextCapabilityConfig>) => {
onChange?.({ version: 1, text: { ...profile, ...patch } });
};
@@ -369,8 +369,17 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
}
if (section === "references") {
const contextWindowFields = (
<CapabilityGroup title="上下文能力" description="这是上游文本模型的能力合同,决定 Agent 本轮可保留的输入预算;不会改变运行时 checkpoint 的持久化上限。">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label="上下文窗口 Token" value={profile.contextWindowTokens} min={4_096} max={10_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ contextWindowTokens: next || 4_096 })} />
<NumberField label="最大输出 Token" value={profile.maxOutputTokens} min={256} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ maxOutputTokens: next || 256 })} />
</div>
</CapabilityGroup>
);
return (
<div className="admin-capability-reference-editor">
{contextWindowFields}
<div className="admin-capability-reference-grid is-three">
<ReferenceCard title="图片引用" description="文本模型可接收的图片范围">
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />
@@ -394,6 +403,12 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
<div className="text-sm font-medium">文本理解能力</div>
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">默认不假设支持图片或视频,只有明确配置后相关请求才会进入该模型。</div>
</div>
<CapabilityGroup title="上下文能力" description="这是上游文本模型的能力合同,决定 Agent 本轮可保留的输入预算;不会改变运行时 checkpoint 的持久化上限。">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label="上下文窗口 Token" value={profile.contextWindowTokens} min={4_096} max={10_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ contextWindowTokens: next || 4_096 })} />
<NumberField label="最大输出 Token" value={profile.maxOutputTokens} min={256} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ maxOutputTokens: next || 256 })} />
</div>
</CapabilityGroup>
<CapabilityGroup title="图片" description="文本模型可接收的图片参考范围">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />

View File

@@ -9,6 +9,7 @@ export type FloatingDockCommand = {
id: string;
label: string;
displayLabel?: string;
badge?: ReactNode;
icon: ReactNode;
wide?: boolean;
quiet?: boolean;
@@ -25,6 +26,7 @@ export type FloatingDockSwitchOption = {
label: string;
icon: ReactNode;
value: string;
displayLabel?: string;
};
export type FloatingDockSwitch = {
@@ -237,6 +239,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
>
<span className="grid size-3.5 shrink-0 place-items-center">{command.icon}</span>
<span className="inline-flex h-4 items-center text-[var(--fs-label)] font-medium leading-none">{command.displayLabel || command.label}</span>
{command.badge !== undefined ? <span className="aceternity-dock-command-badge inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[var(--fs-micro)] font-bold leading-4">{command.badge}</span> : null}
</motion.button>
</motion.span>
);
@@ -292,15 +295,17 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
const [focusedId, setFocusedId] = useState<string | null>(null);
const selectedIndex = Math.max(0, entry.options.findIndex((option) => option.value === entry.value));
const touch = metrics.base >= 40;
const slot = touch ? 32 : compact ? 24 : 26;
const gap = touch ? 10 : compact ? 8 : 10;
const padX = touch ? 7 : compact ? 6 : 7;
const labeled = showLabel || entry.options.some((option) => option.displayLabel);
const slot = labeled ? (touch ? 68 : compact ? 58 : 64) : touch ? 32 : compact ? 24 : 26;
const slotHeight = labeled ? metrics.base : slot;
const gap = labeled ? (touch ? 4 : 3) : touch ? 10 : compact ? 8 : 10;
const padX = labeled ? (touch ? 5 : 4) : touch ? 7 : compact ? 6 : 7;
return (
<span
role="radiogroup"
aria-label={entry.label}
className="aceternity-dock-switch relative flex shrink-0 self-end items-center"
className={cn("aceternity-dock-switch relative flex shrink-0 self-end items-center", labeled && "is-labeled")}
onKeyDown={(event) => {
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
event.preventDefault();
@@ -315,25 +320,25 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
>
<motion.span
aria-hidden
className="aceternity-dock-switch-thumb pointer-events-none absolute top-1/2 rounded-full"
className={cn("aceternity-dock-switch-thumb pointer-events-none absolute top-1/2", labeled ? "rounded-[var(--dock-item-radius)]" : "rounded-full")}
initial={false}
animate={{ x: selectedIndex * (slot + gap), y: "-50%" }}
transition={reducedMotion || !motionEnabled ? { duration: 0 } : aceternityMotion.spring.dock}
style={{ width: slot, height: slot, left: padX }}
style={{ width: slot, height: slotHeight, left: padX }}
/>
{entry.options.map((option) => {
const checked = option.value === entry.value;
const showTooltip = !showLabel && (hoveredId === option.id || focusedId === option.id);
const showTooltip = !labeled && (hoveredId === option.id || focusedId === option.id);
return (
<span key={option.id} className="relative">
<span key={option.id} className="relative inline-flex shrink-0">
<button
type="button"
role="radio"
aria-checked={checked}
aria-label={option.label}
title={!motionEnabled ? option.label : undefined}
className="aceternity-dock-switch-option relative z-[1] grid place-items-center rounded-full border-0 outline-none"
style={{ width: slot, height: slot }}
className={cn("aceternity-dock-switch-option relative z-[1] inline-flex items-center justify-center border-0 outline-none", labeled ? "gap-1 rounded-[var(--dock-item-radius)] px-2" : "rounded-full")}
style={{ width: slot, height: slotHeight }}
onMouseEnter={() => setHoveredId(option.id)}
onMouseLeave={() => setHoveredId((current) => current === option.id ? null : current)}
onFocus={(event) => {
@@ -345,7 +350,8 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
if (!checked) entry.onChange(option.value);
}}
>
<span className={cn("grid place-items-center", touch ? "[&_svg]:size-4" : "[&_svg]:size-[15px]")}>{option.icon}</span>
<span className="grid size-4 shrink-0 place-items-center">{option.icon}</span>
{labeled ? <span className="shrink-0 whitespace-nowrap text-[length:var(--fs-micro)] font-semibold leading-none">{option.displayLabel || option.label}</span> : null}
</button>
<AnimatePresence>
{showTooltip ? (