Merge remote-tracking branch 'upstream/main'

This commit is contained in:
✨await
2026-09-17 10:53:42 +08:00
21 changed files with 450 additions and 169 deletions

View File

@@ -32,11 +32,11 @@ import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItem
import { useAgentPanelLayout } from "./use-agent-panel-layout";
import "./canvas-cloud-agent.css";
type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; prefillPrompt?: string; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
type ApprovalState = { approvalId: string; detail: Record<string, unknown>; reason: string };
type AgentPanelView = "chat" | "history" | "settings";
export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) {
export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) {
const theme = canvasThemes[useActiveTheme()];
const config = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
@@ -47,6 +47,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
const [connectionEpoch, setConnectionEpoch] = useState(0);
const [messages, setMessages] = useState<CloudAgentChatMessage[]>([]);
const [prompt, setPrompt] = useState("");
const lastPrefillPromptRef = useRef("");
const [reasoningMode, setReasoningMode] = useState<AgentReasoningMode>("off");
const [profileView, setProfileView] = useState<AgentProfileView | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
@@ -112,6 +113,14 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : status === "cancelled" ? "已停止" : status === "rejected" ? "已拒绝" : "待命";
const statusColor = status === "failed" ? "#e66b6b" : status === "rejected" || status === "cancelled" ? theme.node.muted : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted;
useEffect(() => {
const value = prefillPrompt?.trim();
if (!value || value === lastPrefillPromptRef.current) return;
lastPrefillPromptRef.current = value;
setPrompt(value);
setView("chat");
}, [prefillPrompt]);
useEffect(() => {
if (!open || view !== "chat") setSkillsOpen(false);
}, [open, view]);

View File

@@ -1,6 +1,6 @@
import { AnimatePresence } from "motion/react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, LayoutTemplate, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
import { ArrowLeft, AtSign, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, LayoutTemplate, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
@@ -55,6 +55,7 @@ type CanvasNodeContextMenuProps = {
onSpreadSelection?: () => void;
onCopySelection?: () => void;
onDeleteSelection?: () => void;
onSendToAgent?: () => void;
};
export function CanvasNodeContextMenu({
@@ -94,6 +95,7 @@ export function CanvasNodeContextMenu({
onSpreadSelection,
onCopySelection,
onDeleteSelection,
onSendToAgent,
}: CanvasNodeContextMenuProps) {
const theme = canvasThemes[useActiveTheme()];
const [addOpen, setAddOpen] = useState(false);
@@ -200,6 +202,7 @@ export function CanvasNodeContextMenu({
<MenuButton icon={<LayoutTemplate />} label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} />
<MenuDivider />
<MenuButton icon={<Copy />} label={`复制 ${selectedCount} 个节点`} shortcut="⌘C" disabled={!onCopySelection} onClick={() => onCopySelection && runAction(onCopySelection)} />
<MenuButton icon={<AtSign />} label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} />
<MenuButton icon={<Trash2 />} label={`删除 ${selectedCount} 个节点`} danger disabled={!onDeleteSelection} onClick={() => onDeleteSelection && runAction(onDeleteSelection)} />
</>
) : menu.type === "node" ? (
@@ -211,6 +214,7 @@ export function CanvasNodeContextMenu({
<MenuButton icon={<UserRound />} label="查看角色详情" onClick={() => runAction(onEditText)} />
<MenuDivider />
<MenuSection label="节点" />
<MenuButton icon={<AtSign />} label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} />
<MenuButton icon={<Copy />} label="复制角色引用" shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
<MenuButton icon={<Layers3 />} label="创建引用副本" shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
<MenuButton icon={<Trash2 />} label="删除节点" danger onClick={() => runAction(onDelete)} />
@@ -224,6 +228,7 @@ export function CanvasNodeContextMenu({
{isImage ? <MenuButton icon={<CloudUpload />} label="上传到方舟素材库" onClick={() => runAction(onUploadToArkPrivateAsset)} /> : null}
<MenuDivider />
<MenuSection label="节点" />
<MenuButton icon={<AtSign />} label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} />
<MenuButton icon={<Copy />} label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
{isImage ? <MenuButton icon={<Clipboard />} label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
<MenuButton icon={<Link2 />} label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} />
@@ -241,6 +246,7 @@ export function CanvasNodeContextMenu({
{isText ? <MenuButton icon={<ImageIcon />} label="用文本生图" disabled={!canGenerateFromText} onClick={() => runAction(onGenerateImage)} /> : null}
<MenuDivider />
<MenuSection label="副本与内容" />
<MenuButton icon={<AtSign />} label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} />
<MenuButton icon={<Copy />} label={isFrame ? `复制${isFolder ? "文件夹" : "背板"}及内容` : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
{isText ? <MenuButton icon={<Clipboard />} label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
<MenuButton icon={<Copy />} label={isFrame ? `创建${isFolder ? "文件夹" : "背板"}副本` : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} />

View File

@@ -159,7 +159,7 @@ export function CanvasToolbar({
onToggleSettingsPanel: () => { setAddOpen(false); setAppearanceOpen(false); setSettingsOpen((value) => !value); },
onDeleteSelected: onDelete,
// 以下为多选/节点悬停工具栏回调,主工具栏不使用,用 no-op 占位
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onBatchConnect: () => {}, onMergeVideos: () => {},
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onBatchConnect: () => {}, onMergeVideos: () => {}, onSendSelectionToAgent: () => {},
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},

View File

@@ -1,5 +1,6 @@
import { Popover } from "antd";
import { Bell, ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus } from "lucide-react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
@@ -8,6 +9,7 @@ import { Kbd } from "@/components/ui/base/kbd";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { cn } from "@/lib/utils";
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
@@ -139,6 +141,7 @@ function NavItem({
const isActive = activeId === item.id || (item.id === "settings" && activeId.startsWith("settings:"));
const hasChildren = Boolean(item.children?.length);
const [isOpen, setIsOpen] = useState(false);
const reducedMotion = useReducedMotion();
// 激活分支自动展开(如设置分区子项),保证当前位置可见。
useEffect(() => {
@@ -172,10 +175,18 @@ function NavItem({
);
const rowClassName = cn(
"app-workspace-nav-link group flex min-h-11 w-full items-center justify-between gap-2 rounded-[var(--r-md)] px-3 py-2 text-[var(--fs-body)] transition-[background-color,color,transform] duration-200 select-none",
"app-workspace-nav-link group relative isolate flex min-h-11 w-full items-center justify-between gap-2 rounded-[var(--r-md)] px-3 py-2 text-[var(--fs-body)] transition-[color,transform] duration-200 select-none",
collapsed && "is-collapsed",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
);
const activePill = isActive ? (
<motion.span
layoutId="workspace-nav-active-pill"
className="app-workspace-nav-active-pill"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock}
/>
) : null;
const handleClick = () => {
if (item.action === "search") {
@@ -211,10 +222,12 @@ function NavItem({
onPointerDown={() => preloadWorkspaceRoute(linkTo)}
onPointerEnter={() => preloadWorkspaceRoute(linkTo)}
>
{activePill}
{rowContent}
</Link>
) : (
<button type="button" className={rowClassName} data-nav-id={item.id} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
{activePill}
{rowContent}
</button>
)}
@@ -299,6 +312,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
<div className={cn("app-workspace-sidebar-nav flex h-full shrink-0 flex-col", collapsed && "is-collapsed")}>
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} onCollapse={onCollapse} />
<LayoutGroup id="workspace-sidebar-nav">
<div
ref={scrollRef}
onScroll={handleScroll}
@@ -308,6 +322,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
</LayoutGroup>
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
<WorkspaceSidebarProfile collapsed={collapsed} user={user} />

View File

@@ -0,0 +1,86 @@
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState, type ComponentPropsWithoutRef, type ElementType, type MouseEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";
type Direction = "TOP" | "LEFT" | "BOTTOM" | "RIGHT";
type HoverBorderGradientProps<T extends ElementType = "button"> = {
as?: T;
containerClassName?: string;
className?: string;
duration?: number;
clockwise?: boolean;
children?: ReactNode;
} & Omit<ComponentPropsWithoutRef<T>, "as" | "children">;
const movingMap: Record<Direction, string> = {
TOP: "radial-gradient(20.7% 50% at 50% 0%, color-mix(in srgb, var(--user-ink) 38%, transparent) 0%, transparent 100%)",
LEFT: "radial-gradient(16.6% 43.1% at 0% 50%, color-mix(in srgb, var(--user-ink) 38%, transparent) 0%, transparent 100%)",
BOTTOM: "radial-gradient(20.7% 50% at 50% 100%, color-mix(in srgb, var(--user-ink) 38%, transparent) 0%, transparent 100%)",
RIGHT: "radial-gradient(16.2% 41.2% at 100% 50%, color-mix(in srgb, var(--user-ink) 38%, transparent) 0%, transparent 100%)",
};
const highlight = "radial-gradient(75% 181% at 50% 50%, color-mix(in srgb, var(--user-ink) 22%, transparent) 0%, transparent 100%)";
function rotateDirection(current: Direction, clockwise: boolean): Direction {
const directions: Direction[] = clockwise
? ["TOP", "LEFT", "BOTTOM", "RIGHT"]
: ["TOP", "RIGHT", "BOTTOM", "LEFT"];
const index = directions.indexOf(current);
return directions[(index - 1 + directions.length) % directions.length];
}
// Aceternity Hover Border Gradient:中性墨色描边,不用彩色高光。
export function HoverBorderGradient<T extends ElementType = "button">({
children,
containerClassName,
className,
as,
duration = 1,
clockwise = true,
onMouseEnter,
onMouseLeave,
...props
}: HoverBorderGradientProps<T>) {
const Tag = (as || "button") as ElementType;
const reducedMotion = useReducedMotion();
const [hovered, setHovered] = useState(false);
const [direction, setDirection] = useState<Direction>("TOP");
useEffect(() => {
if (reducedMotion || hovered) return;
const interval = window.setInterval(() => {
setDirection((current) => rotateDirection(current, clockwise));
}, duration * 1000);
return () => window.clearInterval(interval);
}, [clockwise, duration, hovered, reducedMotion]);
return (
<Tag
{...props}
className={cn("relative flex h-min w-full content-center items-center overflow-hidden bg-[var(--user-surface)] p-px transition-colors duration-500", containerClassName)}
onMouseEnter={(event: MouseEvent<HTMLElement>) => {
setHovered(true);
onMouseEnter?.(event as never);
}}
onMouseLeave={(event: MouseEvent<HTMLElement>) => {
setHovered(false);
onMouseLeave?.(event as never);
}}
>
<div className={cn("relative z-10 w-full rounded-[inherit]", className)}>{children}</div>
{reducedMotion ? null : (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-[inherit]"
style={{ filter: "blur(1px)" }}
initial={{ background: movingMap[direction] }}
animate={{ background: hovered ? [movingMap[direction], highlight] : movingMap[direction] }}
transition={{ ease: "linear", duration }}
/>
)}
<div aria-hidden className="pointer-events-none absolute inset-px z-[1] rounded-[inherit] bg-[var(--user-surface)]" />
</Tag>
);
}

View File

@@ -7,11 +7,12 @@ type SpotlightSurfaceProps = Omit<HTMLMotionProps<"div">, "children"> & {
children?: ReactNode;
spotlightColor: string;
spotlightRadius?: number;
contentClassName?: string;
};
// 基于 Aceternity Card Spotlight 改造:只保留中性指针高光,避免高频工具面板持续动画。
export const SpotlightSurface = forwardRef<HTMLDivElement, SpotlightSurfaceProps>(function SpotlightSurface(
{ children, className, spotlightColor, spotlightRadius = 220, onPointerEnter, onPointerLeave, onPointerMove, ...props },
{ children, className, contentClassName, spotlightColor, spotlightRadius = 220, onPointerEnter, onPointerLeave, onPointerMove, ...props },
ref,
) {
const mouseX = useMotionValue(0);
@@ -45,7 +46,7 @@ export const SpotlightSurface = forwardRef<HTMLDivElement, SpotlightSurfaceProps
className="pointer-events-none absolute -inset-px z-0 rounded-[inherit] opacity-0 transition-opacity duration-150 group-hover/spotlight:opacity-100"
style={{ backgroundColor: spotlightColor, maskImage, WebkitMaskImage: maskImage, opacity: reducedMotion ? 0 : undefined }}
/>
<div className="relative z-[1] flex min-h-0 flex-1 flex-col">{children}</div>
<div className={cn(contentClassName ?? "relative z-[1] flex min-h-0 flex-1 flex-col")}>{children}</div>
</motion.div>
);
});