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

2
.gitignore vendored
View File

@@ -61,6 +61,8 @@ assets/prototypes/.*.drawio.bkp
camera_language_guide.md
.superdesign
.playwright-cli/
# Local Playwright / browser verification artifacts.
/output/
/project-memory/
.workbuddy/

View File

@@ -2,6 +2,8 @@
## Unreleased
- 新增画布多选工具栏和节点右键菜单的「发送到 Agent」快捷入口,点击后自动带入节点引用。
## v1.3.0
- 发布正式版本 `v1.3.0`,提供 `ghcr.io/ddcat-ai/open-ai-canvas-web:1.3.0` 与 `ghcr.io/ddcat-ai/open-ai-canvas-backend:1.3.0` 的 Linux amd64/arm64 镜像,并更新 `latest` 镜像。

View File

@@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 画布节点快速引用 Agent
- 多选节点后,选区浮动工具栏应显示 `@` 图标;点击「发送到 Agent」后,Agent 输入框应自动带入所有选中节点引用。
- 单节点右键菜单(图片、视频、文本及其他节点)应显示「发送到 Agent」;点击后仅带入当前节点引用。
- 引用应以节点 mention token 保存,发送前可以继续补充操作描述。
## Agent 对话模型选择器复用创造页样式
- Agent 底部 Composer 与设置页「对话模型」改为复用画布节点 / 创造页的 `ModelPicker` `variant="creation"`:先选品牌、再选模型,不再把品牌挤成一行。

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>
);
});

View File

@@ -1,4 +1,4 @@
import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, Link2, LoaderCircle, Workflow } from "lucide-react";
import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, AtSign, Film, FolderTree, Grid3X3, LayoutTemplate, Link2, LoaderCircle, Workflow } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
@@ -18,6 +18,7 @@ export const selectionToolbarTools: ToolDefinition[] = [
{ id: "selection-create-storyboard", toolbar: "selection", category: "selection", label: "创建分镜组", icon: <LayoutTemplate />, defaultVisible: true, defaultOrder: 130, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateStoryboard() },
{ id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: <FolderTree />, defaultVisible: false, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
{ id: "selection-batch-connect", toolbar: "selection", category: "selection", label: "批量连接", icon: <Link2 />, defaultVisible: true, defaultOrder: 145, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onBatchConnect() },
{ id: "selection-send-to-agent", toolbar: "selection", category: "selection", label: "发送到 Agent", icon: <AtSign />, defaultVisible: true, defaultOrder: 148, disabled: (ctx) => ctx.selectedCount < 1, run: (ctx) => ctx.handlers.onSendSelectionToAgent() },
{
id: "selection-merge-videos",
toolbar: "selection",

View File

@@ -68,6 +68,7 @@ export type ToolbarHandlers = {
onCreateReferenceGroup: () => void;
onBatchConnect: () => void;
onMergeVideos: () => void;
onSendSelectionToAgent: () => void;
// 节点悬停工具栏——节点操作(均接收当前节点)
onNodeInfo: (node: CanvasNodeData) => void;
onNodeDelete: (node: CanvasNodeData) => void;

View File

@@ -42,6 +42,7 @@ type CanvasProjectContextMenuProps = {
onSpreadSelection: () => void;
onCopySelection: () => void;
onDeleteSelection: () => void;
onSendToAgent: () => void;
};
export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props }: CanvasProjectContextMenuProps) {
@@ -114,6 +115,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onSpreadSelection={props.onSpreadSelection}
onCopySelection={props.onCopySelection}
onDeleteSelection={props.onDeleteSelection}
onSendToAgent={props.onSendToAgent}
/>
);
}

View File

@@ -20,13 +20,14 @@ type CanvasProjectSelectionToolbarProps = {
onCreateReferenceGroup: () => void;
onBatchConnect: () => void;
onMergeVideos: () => void;
onSendSelectionToAgent: () => void;
};
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos }: CanvasProjectSelectionToolbarProps) {
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, onSendSelectionToAgent }: CanvasProjectSelectionToolbarProps) {
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
const handlers = {
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos,
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, onSendSelectionToAgent,
} as Partial<ToolbarHandlers> as ToolbarHandlers;
const ctx: ToolContext = {

View File

@@ -77,6 +77,7 @@ import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
import {
applyCanvasConnectionPromptSync,
buildCanvasAgentMentionReferences,
canvasResourceMentionToken,
buildCanvasNodeMentionReferenceMap,
buildCanvasResourceReferences,
getContextResourceNodes,
@@ -292,6 +293,7 @@ function InfiniteCanvasPage() {
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [agentPrefillPrompt, setAgentPrefillPrompt] = useState("");
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>(DEFAULT_CANVAS_BACKGROUND_MODE);
@@ -333,6 +335,15 @@ function InfiniteCanvasPage() {
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
const { assistantOpen, closeAgent, openAgent } = useCanvasAssistantVisibility();
const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
const sendSelectionToAgent = useCallback((nodeId?: string) => {
const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
if (!references.length) return;
setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
openAgent();
setContextMenu(null);
}, [agentMentionReferences, openAgent]);
const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
@@ -2582,7 +2593,7 @@ function InfiniteCanvasPage() {
) : null}
</div>
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} references={agentMentionReferences} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
focusCanvasNode(nodeId);
}} />
@@ -2697,6 +2708,7 @@ function InfiniteCanvasPage() {
onCreateReferenceGroup={createReferenceGroup}
onBatchConnect={() => beginBatchConnectionMode(Array.from(selectedNodeIds))}
onMergeVideos={() => void mergeSelectedVideos()}
onSendSelectionToAgent={() => sendSelectionToAgent()}
/>
) : null}
@@ -2843,6 +2855,7 @@ function InfiniteCanvasPage() {
onSpreadSelection={spreadSelectedNodes}
onCopySelection={copySelectedNodes}
onDeleteSelection={() => deleteNodes(selectedNodeIds)}
onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)}
/>
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />

View File

@@ -1,21 +1,13 @@
/* Creation-specific geometry. Shared controls still use the workspace tokens. */
.creation-mode-tabs { position: relative; isolation: isolate; display: grid; width: min(456px, 100%); margin: 0 auto 20px; padding: 4px; border-radius: 18px; background: var(--user-surface-muted); }
.creation-mode-indicator { position: absolute; z-index: -1; top: 4px; left: 4px; bottom: 4px; border-radius: 14px; background: var(--user-surface); box-shadow: 0 2px 8px color-mix(in srgb, var(--user-ink) 5%, transparent); }
.creation-mode-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 0; height: 44px; padding: 0 12px; border: 0; border-radius: 14px; background: transparent; color: var(--user-ink-muted); font-size: 14px; font-weight: 600; cursor: pointer; transition: color var(--user-motion), transform var(--user-motion); }
.creation-mode-button svg { width: 18px; height: 18px; stroke-width: 1.8; }
.creation-mode-button[data-mode="video"] svg { color: var(--user-accent); }
.creation-mode-button[data-mode="image"] svg { color: var(--product-accent-green); }
.creation-mode-button[data-mode="text"] svg { color: var(--product-accent-orange); }
.creation-mode-button[data-mode="agent"] svg { color: var(--product-accent-blue); }
.creation-mode-indicator { position: absolute; z-index: -1; inset: 0; border-radius: 14px; background: var(--user-surface); box-shadow: 0 2px 8px color-mix(in srgb, var(--user-ink) 5%, transparent); }
.creation-mode-button { position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 0; height: 44px; padding: 0 12px; border: 0; border-radius: 14px; background: transparent; color: var(--user-ink-muted); font-size: 14px; font-weight: 600; cursor: pointer; transition: color var(--user-motion), transform var(--user-motion); }
.creation-mode-button svg { width: 18px; height: 18px; stroke-width: 1.8; color: currentColor; }
.creation-mode-button[aria-pressed="true"], .creation-mode-button:hover { color: var(--user-ink); }
.creation-mode-button:active { transform: scale(.97); }
.creation-mode-button:focus-visible { outline: 2px solid var(--user-accent); outline-offset: 2px; }
.creation-mode-spark { width: 5px; height: 5px; border-radius: 50%; background: var(--user-accent); box-shadow: 0 0 0 3px var(--user-accent-soft); }
.creation-mode-tabs[data-active-mode="agent"] .creation-mode-indicator { background: linear-gradient(115deg, var(--user-accent-soft), color-mix(in srgb, var(--product-accent-blue) 12%, var(--user-surface))); }
.creation-mode-tabs[data-active-mode="video"] .creation-mode-indicator { background: linear-gradient(115deg, var(--user-surface), var(--user-accent-soft)); }
.creation-mode-tabs[data-active-mode="image"] .creation-mode-indicator { background: color-mix(in srgb, var(--product-accent-green) 9%, var(--user-surface)); }
.creation-mode-tabs[data-active-mode="text"] .creation-mode-indicator { background: color-mix(in srgb, var(--product-accent-orange) 9%, var(--user-surface)); }
.creation-agent-entry { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 16px 24px; padding: 28px; margin-bottom: 20px; border-radius: var(--user-radius-card); background: radial-gradient(ellipse at top right, var(--user-accent-soft), transparent 60%), var(--user-surface); animation: product-card-enter var(--product-enter) var(--product-ease) backwards; }
.creation-mode-spark { width: 4px; height: 4px; border-radius: 50%; background: currentColor; opacity: .42; box-shadow: none; }
.creation-agent-entry { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 16px 24px; padding: 28px; margin-bottom: 20px; border-radius: var(--user-radius-card); background: var(--user-surface); animation: product-card-enter var(--product-enter) var(--product-ease) backwards; }
.creation-agent-orb { grid-row: span 2; }
.creation-agent-eyebrow { font-size: 10px; font-weight: 650; letter-spacing: .14em; color: var(--user-accent); }
.creation-agent-copy h2 { margin: 4px 0 8px; font-size: 21px; font-weight: 650; color: var(--user-ink); }
@@ -69,34 +61,27 @@
min-width: 36px !important;
height: 36px !important;
border-radius: 999px !important;
background: color-mix(in srgb, var(--product-accent-blue) 12%, var(--user-surface-muted)) !important;
color: var(--product-accent-blue) !important;
background: var(--user-surface-muted) !important;
color: var(--user-ink-muted) !important;
transition: background var(--user-motion), color var(--user-motion), transform var(--user-motion), box-shadow var(--user-motion) !important;
}
.app-user-workspace .creation-voice-trigger.ant-btn:hover,
.app-user-workspace .creation-voice-trigger.ant-btn:focus-visible {
background: color-mix(in srgb, var(--product-accent-blue) 20%, var(--user-surface)) !important;
color: var(--product-accent-blue) !important;
background: var(--user-surface-hover) !important;
color: var(--user-ink) !important;
transform: translateY(-1px);
box-shadow: 0 8px 18px color-mix(in srgb, var(--product-accent-blue) 18%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--user-ink) 8%, transparent);
}
.app-user-workspace .creation-chat-control > svg:first-child,
.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { transition: transform var(--user-motion), color var(--user-motion); }
.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { transition: transform var(--user-motion), color var(--user-motion); color: currentColor; }
.app-user-workspace .creation-chat-control:hover > svg:first-child,
.app-user-workspace .creation-model-picker:hover .canvas-model-picker-trigger-icon { transform: scale(1.08); }
.app-user-workspace .creation-chat-control:has(.lucide-wand-sparkles) > svg:first-child { color: var(--product-accent-violet); }
.app-user-workspace .creation-chat-control:has(.lucide-clock-3) > svg:first-child { color: var(--product-accent-orange); }
.app-user-workspace .creation-chat-control:has(.lucide-sliders-horizontal) > svg:first-child { color: var(--product-accent-green); }
.app-user-workspace .creation-chat-control:has(.lucide-waves) > svg:first-child { color: var(--product-accent-cyan); }
.app-user-workspace .creation-chat-control:has(.lucide-brain) > svg:first-child { color: var(--product-accent-blue); }
.app-user-workspace .creation-chat-control.is-clear > svg:first-child { color: #c45b5b; }
.app-user-workspace .creation-chat-control.is-clear:hover { background: color-mix(in srgb, #c45b5b 12%, var(--user-surface)) !important; color: #c45b5b !important; }
.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { color: var(--product-accent-orange); }
.app-user-workspace .creation-model-picker:hover .canvas-model-picker-trigger-icon { transform: scale(1.06); }
.app-user-workspace .creation-chat-control.is-clear:hover { background: color-mix(in srgb, var(--status-error, #c45b5b) 12%, var(--user-surface)) !important; color: var(--status-error, #c45b5b) !important; }
.app-user-workspace .creation-launchpad .creation-reference-add-button:hover:not(:disabled) {
border-style: solid;
border-color: var(--user-accent);
background: color-mix(in srgb, var(--user-accent) 12%, var(--user-surface));
color: var(--user-accent);
border-color: var(--user-border-strong);
background: var(--user-surface-hover);
color: var(--user-ink);
transform: translateY(-1px);
}
.app-user-workspace .creation-launchpad .creation-reference-add-button:active:not(:disabled) { transform: scale(.97); }

View File

@@ -5,7 +5,7 @@ import { App, Button, Dropdown, Popover } from "antd";
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { AppModal } from "@/components/ui/product/app-modal";
import { Tooltip } from "@/components/ui/base/tooltip";
import { Reorder, motion, useReducedMotion } from "motion/react";
import { Reorder, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
@@ -20,7 +20,10 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { HoverBorderGradient } from "@/components/ui/aceternity/hover-border-gradient";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { ModelPicker } from "@/components/model-picker";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { formatShotOrdinal } from "@/lib/shot-label";
@@ -482,7 +485,13 @@ export function CreationComposer(props: ComposerProps) {
}
return undefined;
};
const composer = <section className={`creation-chat-composer is-${props.variant}`}>
const composer = <HoverBorderGradient as="div" duration={2.2} containerClassName="creation-composer-shell" className="creation-composer-shell-inner">
<SpotlightSurface
className={`creation-chat-composer is-${props.variant}`}
contentClassName="contents"
spotlightColor="color-mix(in srgb, var(--user-ink) 12%, transparent)"
spotlightRadius={280}
>
{props.variant === "thread" ? <div className="creation-composer-mode-row"><ModePicker mode={props.mode} onModeChange={props.onModeChange} /></div> : null}
<div className="creation-chat-writing-surface">
<div className="creation-chat-editor">
@@ -600,7 +609,8 @@ export function CreationComposer(props: ComposerProps) {
</Button>
</footer>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</section>;
</SpotlightSurface>
</HoverBorderGradient>;
if (!promptOptimizerOpen) return composer;
@@ -630,18 +640,26 @@ export function CreationModeTabs({ mode, onModeChange, agentActive = false, onAg
{ mode: "image", icon: <ImageIcon />, label: "图片" },
{ mode: "text", icon: <MessageSquareText />, label: "文本" },
];
const activeIndex = agentActive ? 3 : items.findIndex((item) => item.mode === mode);
const vertical = orientation === "vertical";
return <div className="creation-mode-tabs" role="group" aria-label="创作模式" data-active-mode={agentActive ? "agent" : mode} data-orientation={orientation} style={{ gridTemplateColumns: vertical ? "minmax(0, 1fr)" : `repeat(${onAgentSelect ? 4 : 3}, minmax(0, 1fr))` }}>
<motion.span aria-hidden className="creation-mode-indicator" initial={false} animate={vertical ? { y: `${activeIndex * 100}%` } : { x: `${activeIndex * 100}%` }} transition={{ duration: reducedMotion ? 0 : .3, ease: [.16, 1, .3, 1] }} style={vertical ? { width: "calc(100% - 8px)", height: `calc((100% - 8px) / ${onAgentSelect ? 4 : 3})`, bottom: "auto" } : { width: onAgentSelect ? "calc((100% - 8px) / 4)" : "calc((100% - 8px) / 3)" }} />
const indicator = (pressed: boolean) => pressed ? (
<motion.span
layoutId={`creation-mode-indicator-${orientation}`}
className="creation-mode-indicator"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock}
/>
) : null;
return <LayoutGroup id={`creation-mode-tabs-${orientation}`}>
<div className="creation-mode-tabs" role="group" aria-label="创作模式" data-active-mode={agentActive ? "agent" : mode} data-orientation={orientation} style={{ gridTemplateColumns: orientation === "vertical" ? "minmax(0, 1fr)" : `repeat(${onAgentSelect ? 4 : 3}, minmax(0, 1fr))` }}>
{items.map((item) => (
<button key={item.mode} type="button" className="creation-mode-button" data-mode={item.mode} aria-pressed={!agentActive && item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
{indicator(!agentActive && item.mode === mode)}
{item.icon}
<span>{item.label}</span>
</button>
))}
{onAgentSelect ? <button type="button" className="creation-mode-button" data-mode="agent" aria-pressed={agentActive} onClick={onAgentSelect}><Brain /><span>Agent</span><i className="creation-mode-spark" aria-hidden /></button> : null}
</div>;
{onAgentSelect ? <button type="button" className="creation-mode-button" data-mode="agent" aria-pressed={agentActive} onClick={onAgentSelect}>{indicator(agentActive)}<Brain /><span>Agent</span><i className="creation-mode-spark" aria-hidden /></button> : null}
</div>
</LayoutGroup>;
}
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
@@ -735,22 +753,43 @@ const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapper
];
export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
return <div className="creation-empty-suggest" aria-label="快捷创作入口">
const reducedMotion = useReducedMotion();
const [hovered, setHovered] = useState<string | null>(null);
return <LayoutGroup id="creation-empty-suggest">
<div className="creation-empty-suggest" aria-label="快捷创作入口">
{creationEmptySuggestions.map((item) => {
const Icon = item.icon;
const start = () => {
if (item.openLibrary) onOpenLibrary();
else onStartPrompt(item.mode, item.prompt);
};
return <button key={item.title} type="button" className="suggest-card" onClick={start}>
<span className={`library-icon-tile suggest-icon is-${item.mode}`}><Icon size={18} strokeWidth={2} /></span>
return <motion.button
key={item.title}
type="button"
className="suggest-card"
onClick={start}
onHoverStart={() => setHovered(item.title)}
onHoverEnd={() => setHovered(null)}
whileHover={reducedMotion ? undefined : { y: -2 }}
transition={aceternityMotion.spring.surface}
>
{hovered === item.title ? (
<motion.span
layoutId="creation-suggest-hover"
className="suggest-card-hover"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.surface}
/>
) : null}
<span className="library-icon-tile suggest-icon"><Icon size={18} strokeWidth={2} /></span>
<span className="suggest-copy">
<strong>{item.title}</strong>
<span>{item.hint}</span>
</span>
</button>;
</motion.button>;
})}
</div>;
</div>
</LayoutGroup>;
}

View File

@@ -0,0 +1,90 @@
export type WelcomeContributor = {
name: string;
avatar: string;
signature: string;
email?: string;
wechat?: string;
isFounder?: boolean;
};
function plainText(value: string) {
return value
.replace(/<br\s*\/?>/gi, " · ")
.replace(/<[^>]+>/g, "")
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function wechatFrom(details: string) {
return details.match(/微信\s*[::]\s*([^·]+)/)?.[1]?.trim();
}
function parsePipeContributor(line: string, resolveAvatar: (path: string) => string | undefined): WelcomeContributor[] {
const columns = line
.slice(1, line.lastIndexOf("|"))
.split("|")
.map((column) => column.trim());
const image = columns[0]?.match(/src="([^"]+)"[^>]*alt="([^"]*)"/i);
const nameColumn = columns[1] ?? "";
const name = plainText(nameColumn.split(/<br\s*\/?>/i)[0] ?? image?.[2] ?? "");
const avatarPath = image?.[1];
if (!avatarPath || !name) return [];
const avatar = resolveAvatar(avatarPath);
if (!avatar) return [];
const nameDetails = plainText(nameColumn);
const email = plainText(columns[2] ?? "");
return [
{
name,
avatar,
signature: plainText(columns[3] ?? ""),
email: email || undefined,
wechat: wechatFrom(nameDetails),
isFounder: nameDetails.includes("项目发起者"),
},
];
}
function parseHtmlContributor(cell: string, resolveAvatar: (path: string) => string | undefined): WelcomeContributor[] {
const avatarPath = cell.match(/<img\s[^>]*\bsrc="([^"]+)"/i)?.[1];
const alt = cell.match(/<img\s[^>]*\balt="([^"]*)"/i)?.[1] ?? "";
const strong = cell.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? "";
const name = plainText(strong.split(/<br\s*\/?>/i)[0] ?? "") || plainText(alt);
if (!avatarPath || !name) return [];
const avatar = resolveAvatar(avatarPath);
if (!avatar) return [];
const nameDetails = plainText(strong) || name;
const email = decodeURIComponent(cell.match(/mailto:([^"'>\s]+)/i)?.[1] ?? "") || plainText(cell.match(/<a\b[^>]*>([\s\S]*?)<\/a>/i)?.[1] ?? "");
return [
{
name,
avatar,
signature: plainText(cell.match(/<em>([\s\S]*?)<\/em>/i)?.[1] ?? ""),
email: email || undefined,
wechat: wechatFrom(nameDetails),
isFounder: nameDetails.includes("项目发起者"),
},
];
}
export function parseContributors(markdown: string, resolveAvatar: (path: string) => string | undefined): WelcomeContributor[] {
const section = markdown.match(/## 贡献者与团队([\s\S]*?)(?=\n##\s|$)/)?.[1];
if (!section) return [];
const htmlCells = section.match(/<td\b[\s\S]*?<\/td>/gi) ?? [];
if (htmlCells.length) {
return htmlCells.flatMap((cell) => parseHtmlContributor(cell, resolveAvatar));
}
return section
.split("\n")
.filter((line) => /^\|\s*<img\s/i.test(line))
.flatMap((line) => parsePipeContributor(line, resolveAvatar));
}

View File

@@ -1,65 +1,13 @@
import readmeMarkdown from "../../../../README.md?raw";
import { parseContributors } from "./contributors-parse";
const contributorAvatarModules = import.meta.glob("../../../../assets/user-*", {
eager: true,
import: "default",
query: "?url",
}) as Record<string, string>;
export type WelcomeContributor = {
name: string;
avatar: string;
signature: string;
email?: string;
wechat?: string;
isFounder?: boolean;
};
export type { WelcomeContributor } from "./contributors-parse";
function plainText(value: string) {
return value
.replace(/<br\s*\/?>/gi, " · ")
.replace(/<[^>]+>/g, "")
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function parseContributors(markdown: string): WelcomeContributor[] {
const section = markdown.match(/## 贡献者与团队([\s\S]*?)(?=\n##\s)/)?.[1];
if (!section) return [];
return section
.split("\n")
.filter((line) => /^\|\s*<img\s/i.test(line))
.flatMap((line) => {
const columns = line
.slice(1, line.lastIndexOf("|"))
.split("|")
.map((column) => column.trim());
const image = columns[0]?.match(/src="([^"]+)"[^>]*alt="([^"]*)"/i);
const nameColumn = columns[1] ?? "";
const name = plainText(nameColumn.split(/<br\s*\/?>/i)[0] ?? image?.[2] ?? "");
const avatarPath = image?.[1];
if (!avatarPath || !name) return [];
const avatar = contributorAvatarModules[`../../../../${avatarPath}`];
if (!avatar) return [];
const nameDetails = plainText(nameColumn);
const email = plainText(columns[2] ?? "");
const wechat = nameDetails.match(/微信\s*[::]\s*([^·]+)/)?.[1]?.trim();
return [
{
name,
avatar,
signature: plainText(columns[3] ?? ""),
email: email || undefined,
wechat,
isFounder: nameDetails.includes("项目发起者"),
},
];
});
}
export const welcomeContributors = parseContributors(readmeMarkdown);
export const welcomeContributors = parseContributors(readmeMarkdown, (avatarPath) => contributorAvatarModules[`../../../../${avatarPath}`]);

View File

@@ -17707,8 +17707,8 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
height: 28px;
flex: 0 0 auto;
border-radius: 8px;
background: #f3f2ff;
color: #6657d9;
background: var(--user-surface-muted);
color: var(--user-ink-muted);
}
.app-user-workspace .creation-empty-suggest .suggest-copy,
@@ -19379,7 +19379,7 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
.app-user-workspace .app-workspace-nav-link .app-workspace-nav-main { gap: 10px !important; }
.app-user-workspace .app-workspace-nav-link .app-workspace-nav-icon svg,
.app-user-workspace .app-workspace-nav-link .app-workspace-nav-main > svg { width: 16px !important; height: 16px !important; }
.app-user-workspace .app-workspace-nav-link.is-active { background: var(--user-surface-muted) !important; color: var(--user-ink) !important; font-weight: 680 !important; }
.app-user-workspace .app-workspace-nav-link.is-active { background: transparent !important; color: var(--user-ink) !important; font-weight: 680 !important; }
.app-user-workspace .app-workspace-nav-link:hover:not(.is-active),
.app-user-workspace .app-workspace-nav-link:focus-visible:not(.is-active) { background: var(--user-surface-hover) !important; color: var(--user-ink) !important; }
.app-user-workspace .app-workspace-nav-group-toggle { color: var(--user-ink-soft) !important; }
@@ -19491,27 +19491,40 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
.app-user-workspace .creation-launchpad > .creation-mode-capsule .creation-chat-control svg { width: 16px !important; height: 16px !important; }
.app-user-workspace .creation-empty-composer { width: 100% !important; margin: 0 0 20px !important; }
.app-user-workspace .creation-composer-shell {
width: 100%;
border-radius: 22px;
background: transparent;
box-shadow: 0 6px 16px color-mix(in srgb, var(--user-ink) 8%, transparent);
}
.dark .app-user-workspace .creation-composer-shell {
box-shadow: 0 6px 16px rgba(0, 0, 0, .28);
}
.app-user-workspace .creation-chat-composer {
position: relative;
isolation: isolate;
overflow: hidden;
border: 0 !important;
border-radius: 22px !important;
background:
radial-gradient(920px 180px at 8% -30%, color-mix(in srgb, var(--user-accent) 11%, transparent), transparent 58%),
var(--user-surface) !important;
box-shadow: 0 6px 16px color-mix(in srgb, var(--user-ink) 8%, transparent) !important;
border-radius: 21px !important;
background: var(--user-surface) !important;
box-shadow: none !important;
transition: box-shadow var(--user-motion), transform var(--user-motion) !important;
}
.app-user-workspace .creation-chat-composer .creation-composer-mode-row,
.app-user-workspace .creation-chat-composer .creation-chat-writing-surface,
.app-user-workspace .creation-chat-composer .creation-chat-dock {
position: relative;
z-index: 1;
}
.app-user-workspace .creation-chat-composer:focus-within {
box-shadow: 0 8px 18px color-mix(in srgb, var(--user-ink) 10%, transparent) !important;
box-shadow: none !important;
transform: none;
}
.dark .app-user-workspace .creation-chat-composer {
box-shadow: 0 6px 16px rgba(0, 0, 0, .28) !important;
box-shadow: none !important;
}
.dark .app-user-workspace .creation-chat-composer:focus-within {
box-shadow: 0 8px 18px rgba(0, 0, 0, .34) !important;
box-shadow: none !important;
}
.app-user-workspace .creation-chat-composer.is-empty .creation-chat-writing-surface {
min-height: 216px !important;
@@ -19579,9 +19592,9 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
}
.app-user-workspace .creation-launchpad .creation-reference-add-button {
border-style: dashed;
border-color: color-mix(in srgb, var(--user-accent) 28%, var(--user-border));
background: color-mix(in srgb, var(--user-accent) 7%, var(--user-surface-muted));
color: var(--user-accent);
border-color: var(--user-border);
background: var(--user-surface-muted);
color: var(--user-ink-muted);
font-size: 12px;
transition: background var(--user-motion), border-color var(--user-motion), color var(--user-motion), transform var(--user-motion);
}
@@ -19598,9 +19611,9 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading small { color: var(--user-ink-soft); font-size: 10px; font-weight: 500; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--user-ink); text-align: left; transition: background-color 160ms ease, border-color 160ms ease, transform 160ms cubic-bezier(.16,1,.3,1); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand:hover { border-color: var(--user-border); background: var(--user-surface-hover); transform: translateX(2px); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: linear-gradient(135deg, color-mix(in srgb, var(--user-accent) 20%, var(--user-surface)), color-mix(in srgb, var(--user-accent) 6%, var(--user-surface))); color: var(--user-accent); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand:nth-child(3n) .canvas-model-picker-brand-icon { background: linear-gradient(135deg, color-mix(in srgb, #e99a3d 24%, var(--user-surface)), color-mix(in srgb, #e99a3d 7%, var(--user-surface))); color: #d57b16; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand:nth-child(4n) .canvas-model-picker-brand-icon { background: linear-gradient(135deg, color-mix(in srgb, #41a87b 22%, var(--user-surface)), color-mix(in srgb, #41a87b 7%, var(--user-surface))); color: #25845d; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand:nth-child(3n) .canvas-model-picker-brand-icon,
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand:nth-child(4n) .canvas-model-picker-brand-icon { background: var(--user-surface-muted); color: var(--user-ink-muted); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy small { color: var(--user-ink-muted); font-size: 10px; }
@@ -19613,9 +19626,9 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-secondary-head strong { color: var(--user-ink); font-size: 13px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-secondary-head small { color: var(--user-ink-soft); font-size: 10px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option { min-height: 55px; border-radius: 12px; padding: 8px 9px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:first-child { width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--user-accent) 10%, var(--user-surface)) !important; color: var(--user-accent); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option:nth-child(3n) > span:first-child > span:first-child { color: #d57b16; background: color-mix(in srgb, #e99a3d 12%, var(--user-surface)) !important; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option:nth-child(4n) > span:first-child > span:first-child { color: #25845d; background: color-mix(in srgb, #41a87b 12%, var(--user-surface)) !important; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:first-child { width: 32px; height: 32px; border-radius: 10px; background: var(--user-surface-muted) !important; color: var(--user-ink-muted); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option:nth-child(3n) > span:first-child > span:first-child,
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option:nth-child(4n) > span:first-child > span:first-child { color: var(--user-ink-muted); background: var(--user-surface-muted) !important; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option .text-amber-600,
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option .dark\:text-amber-300 { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; background: color-mix(in srgb, #e28a17 12%, var(--user-surface)); color: #c66d00 !important; padding: 5px 8px; font-size: 11px; font-weight: 700; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-option .text-amber-600 svg { width: 13px; height: 13px; }
@@ -19636,6 +19649,8 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
}
.app-user-workspace .creation-empty-suggest .creation-hero-prompt,
.app-user-workspace .creation-empty-suggest .suggest-card {
position: relative;
isolation: isolate;
min-height: 80px !important;
margin: 0 !important;
padding: 12px !important;
@@ -19646,12 +19661,22 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
color: var(--user-ink) !important;
transition: transform var(--user-motion), background var(--user-motion), box-shadow var(--user-motion), border-color var(--user-motion) !important;
}
.app-user-workspace .creation-empty-suggest .suggest-card-hover {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: var(--user-surface-hover);
pointer-events: none;
}
.app-user-workspace .creation-empty-suggest .suggest-card > .suggest-icon,
.app-user-workspace .creation-empty-suggest .suggest-card > .suggest-copy { position: relative; z-index: 1; }
.app-user-workspace .creation-empty-suggest .creation-hero-prompt { grid-column: auto !important; }
.app-user-workspace .creation-empty-suggest .creation-hero-prompt:hover,
.app-user-workspace .creation-empty-suggest .suggest-card:hover,
.app-user-workspace .creation-empty-suggest .creation-hero-prompt:focus-visible,
.app-user-workspace .creation-empty-suggest .suggest-card:focus-visible { transform: translateY(-2px) !important; border-color: var(--user-border-strong) !important; background: var(--user-surface-hover) !important; box-shadow: var(--user-shadow-hover) !important; }
.app-user-workspace .creation-empty-suggest .suggest-icon { width: 32px !important; height: 32px !important; margin-bottom: 0 !important; flex-shrink: 0; border: 0 !important; border-radius: 12px !important; background: var(--user-accent-soft) !important; color: var(--user-accent) !important; }
.app-user-workspace .creation-empty-suggest .suggest-card:focus-visible { transform: none !important; border-color: transparent !important; background: transparent !important; box-shadow: none !important; }
.app-user-workspace .creation-empty-suggest .suggest-icon { width: 32px !important; height: 32px !important; margin-bottom: 0 !important; flex-shrink: 0; border: 0 !important; border-radius: 12px !important; background: var(--user-surface-muted) !important; color: var(--user-ink-muted) !important; }
.app-user-workspace .creation-empty-suggest .suggest-copy,
.app-user-workspace .creation-empty-suggest .creation-hero-prompt-copy { gap: 4px !important; }
.app-user-workspace .creation-empty-suggest .suggest-copy strong,

View File

@@ -471,49 +471,51 @@
.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-profile { width: 48px; min-width: 48px; flex: 0 0 48px; margin-inline: auto; }
.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-notification-spacer { display: none; }
.app-user-workspace .app-workspace-nav-link { --nav-accent: var(--user-ink-muted); }
.app-user-workspace .app-workspace-nav-link[data-nav-id="home"] { --nav-accent: var(--product-accent-orange); }
.app-user-workspace .app-workspace-nav-link[data-nav-id="projects"] { --nav-accent: var(--product-accent-blue); }
.app-user-workspace .app-workspace-nav-link[data-nav-id="canvas"] { --nav-accent: var(--product-accent-green); }
.app-user-workspace .app-workspace-nav-link[data-nav-id="assets"] { --nav-accent: #c4891a; }
.app-user-workspace .app-workspace-nav-link[data-nav-id="skills"] { --nav-accent: var(--product-accent-pink); }
.app-user-workspace .app-workspace-nav-link[data-nav-id="plugins"] { --nav-accent: var(--product-accent-violet); }
.app-user-workspace .app-workspace-nav-link[data-nav-id="tasks"] { --nav-accent: var(--product-accent-cyan); }
.dark .app-user-workspace .app-workspace-nav-link[data-nav-id="assets"] { --nav-accent: #e8c37a; }
.app-user-workspace .app-workspace-nav-link {
position: relative;
isolation: isolate;
}
.app-user-workspace .app-workspace-nav-active-pill {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: var(--user-surface-muted);
pointer-events: none;
}
.app-user-workspace .app-workspace-nav-link > :not(.app-workspace-nav-active-pill) {
position: relative;
z-index: 1;
}
.app-user-workspace .app-workspace-nav-icon {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
width: 20px;
height: 20px;
flex: 0 0 20px;
place-items: center;
border-radius: 9px;
background: color-mix(in srgb, var(--nav-accent) 14%, transparent);
color: var(--nav-accent);
transition: background var(--product-state) var(--product-ease), transform var(--product-state) var(--product-ease), box-shadow var(--product-state) var(--product-ease);
border-radius: 0;
background: transparent;
color: currentColor;
transition: transform var(--product-state) var(--product-ease);
}
.app-user-workspace .app-workspace-nav-icon svg {
color: var(--nav-accent) !important;
color: currentColor !important;
opacity: 1 !important;
transform-origin: center;
transition: transform var(--product-state) var(--product-ease);
}
.app-user-workspace .app-workspace-nav-link:hover .app-workspace-nav-icon,
.app-user-workspace .app-workspace-nav-link:focus-visible .app-workspace-nav-icon {
background: color-mix(in srgb, var(--nav-accent) 22%, transparent);
transform: translateY(-1px);
}
.app-user-workspace .app-workspace-nav-link:hover .app-workspace-nav-icon svg,
.app-user-workspace .app-workspace-nav-link:focus-visible .app-workspace-nav-icon svg {
animation: workspace-nav-icon-nudge 420ms var(--product-ease);
}
.app-user-workspace .app-workspace-nav-link.is-active {
background: color-mix(in srgb, var(--nav-accent) 12%, var(--user-surface-muted)) !important;
background: transparent !important;
}
.app-user-workspace .app-workspace-nav-link.is-active .app-workspace-nav-icon {
background: color-mix(in srgb, var(--nav-accent) 24%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--nav-accent) 10%, transparent);
background: transparent;
box-shadow: none;
}
.app-user-workspace .app-workspace-nav-link:active .app-workspace-nav-icon { transform: scale(.94); }
.app-user-workspace .app-workspace-nav-link:active .app-workspace-nav-icon { transform: scale(.96); }
.app-user-workspace .app-workspace-nav-group > div > div .app-workspace-nav-link {
animation: workspace-nav-item-enter var(--product-enter) var(--product-ease) both;
}
@@ -523,11 +525,6 @@
.app-user-workspace .app-workspace-nav-group > div > div:nth-child(4) .app-workspace-nav-link { animation-delay: 120ms; }
.app-user-workspace .app-workspace-nav-group > div > div:nth-child(n + 5) .app-workspace-nav-link { animation-delay: 160ms; }
@keyframes workspace-nav-icon-nudge {
0% { transform: rotate(0deg) scale(1); }
42% { transform: rotate(-10deg) scale(1.08); }
100% { transform: rotate(0deg) scale(1); }
}
@keyframes workspace-nav-item-enter {
from { opacity: 0; transform: translateX(-6px); }
to { opacity: 1; transform: translateX(0); }
@@ -545,16 +542,14 @@
.app-user-workspace .creation-chat-composer.is-thread .creation-composer-mode-row { grid-column: 1; grid-row: 1 / 3; min-width: 0; padding: 8px 0 8px 8px; }
.app-user-workspace .creation-chat-composer.is-thread .creation-composer-mode-row .creation-mode-tabs { display: grid; width: 100%; height: 100%; grid-template-rows: repeat(3, minmax(52px, 1fr)); align-self: stretch; margin: 0; padding: 4px; border-radius: 16px; transform: none; }
.app-user-workspace .creation-chat-composer.is-thread .creation-composer-mode-row .creation-mode-button { height: auto; min-height: 52px; flex-direction: column; gap: 3px; padding: 5px 2px; font-size: 11px; }
.app-user-workspace .creation-chat-composer.is-thread .creation-composer-mode-row .creation-mode-indicator { top: 4px; left: 4px; }
.app-user-workspace .creation-chat-composer.is-thread .creation-composer-mode-row .creation-mode-indicator { inset: 0; width: auto; height: auto; }
.app-user-workspace .creation-chat-composer.is-thread .creation-chat-writing-surface,
.app-user-workspace .creation-chat-composer.is-thread .creation-chat-dock { grid-column: 2; min-width: 0; }
.app-user-workspace .creation-composer-stage { display: flex; min-width: 0; align-items: stretch; gap: 14px; }
.app-user-workspace .creation-composer-stage.is-thread-mode { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 14px; }
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs { display: grid; width: 76px; grid-template-columns: 1fr; grid-template-rows: repeat(3, minmax(54px, 1fr)); align-self: stretch; margin: 0; padding: 4px; border-radius: 18px; }
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs .creation-mode-button { min-width: 0; min-height: 0; flex-direction: column; gap: 3px; padding: 7px 3px; font-size: 11px; }
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs .creation-mode-indicator { inset: 4px 4px auto; width: calc(100% - 8px); height: calc((100% - 8px) / 3); transition: transform 360ms var(--product-ease); }
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs[data-active-mode="image"] .creation-mode-indicator { transform: translateY(100%); }
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs[data-active-mode="text"] .creation-mode-indicator { transform: translateY(200%); }
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs .creation-mode-indicator { inset: 0; width: auto; height: auto; transform: none; }
.app-user-workspace .creation-composer-stage.is-home-mode { display: block; }
.app-user-workspace .creation-composer-stage.is-home-mode > .creation-mode-tabs { display: grid; width: min(456px, 100%); grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: 1fr; margin: 0 auto 20px; padding: 4px; }
.app-user-workspace .creation-composer-stage.is-home-mode > .creation-mode-tabs .creation-mode-button { min-height: 46px; flex-direction: row; gap: 6px; padding: 0 8px; font-size: 13px; }

View File

@@ -0,0 +1,54 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parseContributors } from "../src/pages/welcome/contributors-parse";
const identityAvatar = (path: string) => path;
test("welcome contributors parse the README HTML table", () => {
const readme = readFileSync(resolve(import.meta.dir, "../../README.md"), "utf8");
const people = parseContributors(readme, identityAvatar);
expect(people.length).toBeGreaterThanOrEqual(20);
expect(people[0]).toMatchObject({
name: "ddCat",
avatar: "assets/user-ddcat.jpg",
email: "ddcat666@126.com",
wechat: "ddcat0829",
isFounder: true,
});
expect(people[0]?.signature).toContain("没有任何黑魔法");
expect(people.some((person) => person.name === "爱笑的毛毛虫")).toBe(true);
expect(people.filter((person) => person.isFounder)).toHaveLength(1);
});
test("welcome contributors still parse markdown pipe tables", () => {
const markdown = `## 贡献者与团队
| 头像 | 昵称 | 邮箱 | 签名 |
| --- | --- | --- | --- |
| <img src="assets/user-ddcat.jpg" alt="ddCat"> | ddCat<br>项目发起者 · 微信:ddcat0829 | founder@example.com | 把故事搬上银幕 |
| <img src="assets/user-ken.jpg" alt="ken"> | ken | ken@example.com | 走自己的路 |
## 下一章
`;
expect(parseContributors(markdown, identityAvatar)).toEqual([
{
name: "ddCat",
avatar: "assets/user-ddcat.jpg",
signature: "把故事搬上银幕",
email: "founder@example.com",
wechat: "ddcat0829",
isFounder: true,
},
{
name: "ken",
avatar: "assets/user-ken.jpg",
signature: "走自己的路",
email: "ken@example.com",
wechat: undefined,
isFounder: false,
},
]);
});