Initial commit: Pixel AI comic/video creation platform

- FastAPI backend with SQLModel, Alembic migrations, AgentScope agents
- Next.js 15 frontend with React 19, Tailwind, Zustand, React Flow
- Multi-provider AI system (DashScope, Kling, MiniMax, Volcengine, OpenAI, etc.)
- All HTTP clients migrated from sync requests to async httpx
- Admin-managed API keys via environment variables
- SSRF vulnerability fixed in ensure_url()
This commit is contained in:
张鹏
2026-04-29 01:20:12 +08:00
commit f9f4560459
808 changed files with 151724 additions and 0 deletions

View File

@@ -0,0 +1,222 @@
'use client';
import React, { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Layers,
Play,
X,
Loader2,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { cn } from '@/utils';
import { useCanvasStore } from '@/lib/store/canvasStore';
import { BatchService } from '@/lib/api';
import { AppNodeType, NodeType } from '../types/node';
interface BatchGenerationToolbarProps {
onClose: () => void;
}
export function BatchGenerationToolbar({ onClose }: BatchGenerationToolbarProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [result, setResult] = useState<{
success: boolean;
message: string;
taskCount?: number;
} | null>(null);
const nodes = useCanvasStore((state) => state.nodes);
const selectedNodes = nodes.filter(n => n.selected);
// 获取可生成的节点(有生成类型的节点)
const generatableNodes = selectedNodes.filter(node => {
if (node.type !== 'appNode') return false;
const nodeData = node.data;
return nodeData.type === NodeType.IMAGE_GENERATOR ||
nodeData.type === NodeType.VIDEO_GENERATOR ||
nodeData.type === NodeType.AUDIO_GENERATOR;
});
const handleBatchGenerate = useCallback(async () => {
if (generatableNodes.length === 0) return;
setIsGenerating(true);
setResult(null);
try {
// 构建批量生成请求
const items = generatableNodes.map(node => {
const data = node.data;
// 将 NodeType 映射为 API 类型
const typeMap: Record<string, string> = {
'IMAGE_GENERATOR': 'image',
'VIDEO_GENERATOR': 'video',
'AUDIO_GENERATOR': 'audio'
};
const type = data.outputType || typeMap[data.type] || 'image';
return {
type: type,
prompt: data.prompt || data.title || '',
model: data.model,
aspect_ratio: data.aspectRatio || '16:9',
resolution: data.resolution || '1K',
duration: data.duration,
voice: data.voice,
image_inputs: data.imageInputs || [],
extra_params: data.extraParams || {},
source: 'canvas',
source_id: node.id
};
});
const job = await BatchService.createJob(items);
setResult({
success: job.status !== 'failed',
message: `成功创建批量任务 (ID: ${job.id})`,
taskCount: job.total
});
// 3秒后自动关闭
if (job.status !== 'failed') {
setTimeout(() => {
onClose();
}, 3000);
}
} catch (error) {
console.error('Batch generation failed:', error);
setResult({
success: false,
message: '批量生成失败,请重试'
});
} finally {
setIsGenerating(false);
}
}, [generatableNodes, onClose]);
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="absolute top-4 left-1/2 -translate-x-1/2 z-[100] bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl p-4 min-w-[400px]"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
<Layers className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-white font-semibold"></h3>
<p className="text-xs text-muted-foreground">
{generatableNodes.length}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* 节点列表预览 */}
{generatableNodes.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto space-y-2 mb-4">
{generatableNodes.map((node, idx) => (
<div
key={node.id}
className="flex items-center gap-3 p-2 bg-white/5 rounded-lg"
>
<span className="text-xs text-muted-foreground w-6">{idx + 1}</span>
<span className="text-sm text-white truncate flex-1">
{node.data.title || node.data.prompt || '未命名节点'}
</span>
<span className={cn(
"text-xs px-2 py-0.5 rounded",
node.data.outputType === 'image' || node.data.type === NodeType.IMAGE_GENERATOR
? "bg-blue-500/20 text-blue-400"
: node.data.outputType === 'video' || node.data.type === NodeType.VIDEO_GENERATOR
? "bg-purple-500/20 text-purple-400"
: node.data.outputType === 'audio' || node.data.type === NodeType.AUDIO_GENERATOR
? "bg-green-500/20 text-green-400"
: "bg-yellow-500/20 text-yellow-400"
)}>
{node.data.outputType ? String(node.data.outputType) : String(node.data.type)}
</span>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground">
<Layers className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-sm"></p>
<p className="text-xs opacity-60"></p>
</div>
)}
{/* 结果提示 */}
{result && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={cn(
"p-3 rounded-lg mb-4 flex items-center gap-2",
result.success
? "bg-green-500/10 border border-green-500/30"
: "bg-red-500/10 border border-red-500/30"
)}
>
{result.success ? (
<CheckCircle2 className="w-5 h-5 text-green-400" />
) : (
<AlertCircle className="w-5 h-5 text-red-400" />
)}
<span className={cn(
"text-sm",
result.success ? "text-green-400" : "text-red-400"
)}>
{result.message}
</span>
</motion.div>
)}
{/* 操作按钮 */}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm text-muted-foreground hover:text-white hover:bg-white/10 rounded-xl transition-colors"
>
</button>
<button
onClick={handleBatchGenerate}
disabled={generatableNodes.length === 0 || isGenerating}
className={cn(
"flex-[2] flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-xl transition-all",
generatableNodes.length === 0 || isGenerating
? "bg-white/5 text-muted-foreground cursor-not-allowed"
: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90"
)}
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
({generatableNodes.length})
</>
)}
</button>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,195 @@
import React from 'react';
import {
Trash2, Copy, RefreshCw, Clipboard, Unplug, Music2, Folder, Ungroup
} from 'lucide-react';
import { NodeType, AppNodeType, AppNodeData, ContextMenuState, ContextMenuTarget } from '../types/node';
import { getNodeIcon, getNodeNameCN } from '../utils';
interface CanvasContextMenuProps {
contextMenu: ContextMenuState;
target: ContextMenuTarget;
nodes: AppNodeType[];
clipboard: AppNodeType | null;
onClose: () => void;
// Actions
onCreateNode: (type: NodeType, data?: Partial<AppNodeData>) => void;
onCreateAndConnect: (type: NodeType, data?: Partial<AppNodeData>) => void;
// Node Actions
onCopyNode: (nodeId: string) => void;
onReplaceMedia: (nodeId: string, mediaType: 'image' | 'video') => void;
onDeleteNode: (nodeId: string) => void;
// Global Actions
onPaste: () => void;
// Connection Actions
onDeleteEdge: (edgeId: string) => void;
// Group Actions
onCreateGroup?: () => void;
onUngroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void;
}
export const CanvasContextMenu: React.FC<CanvasContextMenuProps> = ({
contextMenu,
target,
nodes,
clipboard,
onClose,
onCreateNode,
onCreateAndConnect,
onCopyNode,
onReplaceMedia,
onDeleteNode,
onPaste,
onDeleteEdge,
onCreateGroup,
onUngroup,
onDeleteGroup
}) => {
const createNodeOptions: Array<{
key: string;
type: NodeType;
label: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
data?: Partial<AppNodeData>;
}> = [
{ key: NodeType.PROMPT_INPUT, type: NodeType.PROMPT_INPUT, label: getNodeNameCN(NodeType.PROMPT_INPUT), icon: getNodeIcon(NodeType.PROMPT_INPUT) },
{ key: NodeType.LYRICS_GENERATOR, type: NodeType.LYRICS_GENERATOR, label: getNodeNameCN(NodeType.LYRICS_GENERATOR), icon: getNodeIcon(NodeType.LYRICS_GENERATOR), data: { title: '歌词生成' } },
{ key: NodeType.PROMPT_GENERATOR, type: NodeType.PROMPT_GENERATOR, label: getNodeNameCN(NodeType.PROMPT_GENERATOR), icon: getNodeIcon(NodeType.PROMPT_GENERATOR) },
{ key: NodeType.IMAGE_GENERATOR, type: NodeType.IMAGE_GENERATOR, label: getNodeNameCN(NodeType.IMAGE_GENERATOR), icon: getNodeIcon(NodeType.IMAGE_GENERATOR) },
{ key: NodeType.VIDEO_GENERATOR, type: NodeType.VIDEO_GENERATOR, label: getNodeNameCN(NodeType.VIDEO_GENERATOR), icon: getNodeIcon(NodeType.VIDEO_GENERATOR) },
{ key: NodeType.AUDIO_GENERATOR, type: NodeType.AUDIO_GENERATOR, label: getNodeNameCN(NodeType.AUDIO_GENERATOR), icon: getNodeIcon(NodeType.AUDIO_GENERATOR), data: { generationType: 'audio' } },
{ key: `${NodeType.AUDIO_GENERATOR}-music`, type: NodeType.AUDIO_GENERATOR, label: '音乐生成', icon: Music2, data: { generationType: 'music', title: '音乐生成' } },
{ key: NodeType.VIDEO_ANALYZER, type: NodeType.VIDEO_ANALYZER, label: getNodeNameCN(NodeType.VIDEO_ANALYZER), icon: getNodeIcon(NodeType.VIDEO_ANALYZER) },
{ key: NodeType.IMAGE_EDITOR, type: NodeType.IMAGE_EDITOR, label: getNodeNameCN(NodeType.IMAGE_EDITOR), icon: getNodeIcon(NodeType.IMAGE_EDITOR) },
];
return (
<div
className="fixed z-[100] bg-card/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl p-1.5 min-w-[160px] animate-in fade-in zoom-in-95 duration-200 origin-top-left"
style={{ top: contextMenu.y, left: contextMenu.x }}
onMouseDown={(e) => e.stopPropagation()}
>
{target.type === 'handle-create' && (
<>
<div className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"></div>
{createNodeOptions.map((option) => {
const ItemIcon = option.icon;
return (
<button key={option.key} className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => { onCreateAndConnect(option.type, option.data); onClose(); }}>
<ItemIcon size={12} className="text-cyan-400" /> {option.label}
</button>
);
})}
</>
)}
{target.type === 'node' && target.id && (
<>
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
onCopyNode(target.id!);
onClose();
}}>
<Copy size={12} />
</button>
{(() => {
const targetNode = nodes.find(n => n.id === target.id);
if (targetNode && targetNode.type === 'appNode') {
const data = targetNode.data as AppNodeData;
const isVideo = data.type === NodeType.VIDEO_GENERATOR || data.type === NodeType.VIDEO_ANALYZER;
const isImage = data.type === NodeType.IMAGE_GENERATOR || data.type === NodeType.IMAGE_EDITOR;
if (isVideo || isImage) {
return (
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-purple-500/20 hover:text-purple-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
onReplaceMedia(target.id!, isVideo ? 'video' : 'image');
onClose();
}}>
<RefreshCw size={12} />
</button>
);
}
}
return null;
})()}
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors mt-1" onClick={() => {
onDeleteNode(target.id!);
onClose();
}}>
<Trash2 size={12} />
</button>
</>
)}
{(target.type === 'create' || target.type === 'pane') && (
<>
{clipboard && (
<>
<button className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => {
onPaste();
onClose();
}}>
<Clipboard size={12} className="text-cyan-400" />
</button>
<div className="w-full h-px bg-white/10 my-1"></div>
</>
)}
<div className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"></div>
{createNodeOptions.map((option) => {
const ItemIcon = option.icon;
return (
<button key={option.key} className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => {
onCreateNode(option.type, option.data);
onClose();
}}>
<ItemIcon size={12} className="text-cyan-400" /> {option.label}
</button>
);
})}
</>
)}
{target.type === 'connection' && target.id && (
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
onDeleteEdge(target.id!);
onClose();
}}>
<Unplug size={12} /> 线
</button>
)}
{target.type === 'group' && target.id && (
<>
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
onUngroup?.(target.id!);
onClose();
}}>
<Ungroup size={12} />
</button>
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors mt-1" onClick={() => {
onDeleteGroup?.(target.id!);
onClose();
}}>
<Trash2 size={12} />
</button>
</>
)}
{(target.type === 'create' || target.type === 'pane') && onCreateGroup && (
<>
<div className="w-full h-px bg-white/10 my-1"></div>
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
onCreateGroup();
onClose();
}}>
<Folder size={12} />
</button>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { Keyboard } from 'lucide-react';
import { cn } from '@/utils';
export function CanvasShortcuts() {
const [isOpen, setIsOpen] = useState(false);
const shortcuts = [
{ key: '⌘/Ctrl + S', desc: '保存' },
{ key: '⌘/Ctrl + Z', desc: '撤销' },
{ key: '⌘/Ctrl + ⇧ + Z', desc: '重做' },
{ key: '⌘/Ctrl + A', desc: '全选' },
{ key: '⌘/Ctrl + D', desc: '复制选中' },
{ key: '⌘/Ctrl + G', desc: '创建分组' },
{ key: '⌘/Ctrl + Q', desc: '生成队列' },
{ key: '⌘/Ctrl + B', desc: '批量生成' },
{ key: '⌘/Ctrl + T', desc: '提示词模板库' },
{ key: '⌘/Ctrl + C', desc: '复制' },
{ key: '⌘/Ctrl + V', desc: '粘贴' },
{ key: 'Del / Backspace', desc: '删除' },
{ key: 'Esc', desc: '取消选择' },
{ key: 'Alt + ← / →', desc: '切换画布' },
{ key: '⌘/Ctrl + 0', desc: '适应视图' },
{ key: '⌘/Ctrl + +/-', desc: '缩放' },
{ key: 'F11', desc: '全屏' },
{ key: 'Space + 拖拽', desc: '平移画布' },
{ key: 'Scroll', desc: '缩放画布' },
];
return (
<div className="absolute bottom-6 right-6 z-50 flex flex-col items-end gap-2">
<div
className={cn(
"origin-bottom-right transition-all duration-300 ease-spring",
isOpen
? "opacity-100 scale-100 translate-y-0 mb-2"
: "opacity-0 scale-90 translate-y-2 pointer-events-none absolute bottom-full right-0 mb-0"
)}
>
<div className="w-64 p-4 bg-black/40 backdrop-blur-md border border-white/10 rounded-2xl shadow-2xl">
<div className="flex items-center justify-between mb-3 pb-2 border-b border-white/10">
<span className="text-xs font-medium text-white/90"></span>
<span className="text-[10px] text-muted-foreground">Keyboard Shortcuts</span>
</div>
<div className="space-y-2">
{shortcuts.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs group">
<span className="text-muted-foreground group-hover:text-white/80 transition-colors">{item.desc}</span>
<kbd className="px-1.5 py-0.5 bg-white/5 group-hover:bg-white/10 rounded border border-white/5 text-white/90 font-mono text-[10px] min-w-[20px] text-center transition-colors">
{item.key}
</kbd>
</div>
))}
</div>
</div>
</div>
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"group relative p-2 rounded-full shadow-xl transition-all duration-300 hover:scale-105 active:scale-95",
isOpen
? "bg-white text-black border-transparent"
: "bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 text-muted-foreground hover:text-white"
)}
>
<Keyboard size={18} strokeWidth={2} />
{/* Tooltip */}
{!isOpen && (
<div className="absolute right-full mr-3 top-1/2 -translate-y-1/2 px-2 py-1 bg-black/90 backdrop-blur-md rounded-md border border-white/10 text-[10px] text-white font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
</div>
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import React from 'react';
import { useReactFlow, useViewport } from '@xyflow/react';
import { Plus, Minus, Scan, Map, List } from 'lucide-react';
interface CanvasViewportControlsProps {
isMiniMapOpen?: boolean;
onToggleMiniMap?: () => void;
onToggleQueuePanel?: () => void;
isQueuePanelOpen?: boolean;
}
export function CanvasViewportControls({
isMiniMapOpen = false,
onToggleMiniMap,
onToggleQueuePanel,
isQueuePanelOpen = false
}: CanvasViewportControlsProps) {
const { zoomIn, zoomOut, zoomTo, fitView } = useReactFlow();
const { zoom } = useViewport();
return (
<div className="absolute bottom-6 left-6 flex items-center justify-between w-[240px] px-3 py-1.5 bg-black/40 backdrop-blur-md border border-white/10 rounded-full shadow-xl z-50 animate-in fade-in slide-in-from-bottom-4 duration-700">
<button
onClick={() => zoomOut({ duration: 300 })}
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
>
<Minus size={12} strokeWidth={3} />
</button>
<div className="flex items-center gap-2 min-w-0 flex-1 justify-center px-1">
<input
type="range"
min="0.1"
max="4"
step="0.1"
value={zoom}
onChange={(e) => zoomTo(parseFloat(e.target.value), { duration: 0 })}
className="w-16 h-1 bg-white/20 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-lg hover:[&::-webkit-slider-thumb]:scale-125 transition-all shrink-0"
/>
<span
className="text-[10px] font-bold text-muted-foreground w-8 text-center tabular-nums cursor-pointer hover:text-foreground shrink-0"
onClick={() => zoomTo(1, { duration: 300 })}
title="Reset Zoom"
>
{Math.round(zoom * 100)}%
</span>
</div>
<button
onClick={() => zoomIn({ duration: 300 })}
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
>
<Plus size={12} strokeWidth={3} />
</button>
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
<button
onClick={() => fitView({
duration: 300,
padding: {
top: 0.1,
right: 0.15,
bottom: 0.15,
left: 0.12
},
minZoom: 0.5,
maxZoom: 1.2
})}
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
title="适配视图"
>
<Scan size={12} strokeWidth={3} />
</button>
{onToggleMiniMap && (
<>
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
<button
onClick={onToggleMiniMap}
className={`p-1 transition-colors rounded-full hover:bg-white/10 shrink-0 ${
isMiniMapOpen ? 'text-white' : 'text-muted-foreground hover:text-white'
}`}
title={isMiniMapOpen ? "隐藏小地图" : "显示小地图"}
>
<Map size={12} strokeWidth={3} />
</button>
</>
)}
{onToggleQueuePanel && (
<>
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
<button
onClick={onToggleQueuePanel}
className={`p-1 transition-colors rounded-full hover:bg-white/10 shrink-0 ${
isQueuePanelOpen ? 'text-white' : 'text-muted-foreground hover:text-white'
}`}
title={isQueuePanelOpen ? "隐藏生成队列" : "显示生成队列"}
>
<List size={12} strokeWidth={3} />
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import React, { useEffect, useRef } from 'react';
import {
Video, Type, Image as ImageIcon,
Edit, Mic2, Info, FileSearch, Sparkles, ScrollText
} from 'lucide-react';
import { NodeType } from '../types/node';
import { cn } from '@/utils';
import { VALID_CONNECTIONS } from '../hooks/useSocketInteraction';
interface ConnectionMenuProps {
x: number;
y: number;
sourceType?: string;
onSelect: (type: NodeType) => void;
onClose: () => void;
}
const NODE_OPTIONS = [
{ type: NodeType.PROMPT_INPUT, label: 'Prompt Input', icon: Type, color: 'text-amber-400' },
{ type: NodeType.LYRICS_GENERATOR, label: 'Lyrics Generator', icon: ScrollText, color: 'text-indigo-400' },
{ type: NodeType.PROMPT_GENERATOR, label: 'Prompt Optimizer', icon: Sparkles, color: 'text-fuchsia-400' },
{ type: NodeType.IMAGE_GENERATOR, label: 'Image Generator', icon: ImageIcon, color: 'text-cyan-400' },
{ type: NodeType.VIDEO_GENERATOR, label: 'Video Generator', icon: Video, color: 'text-purple-400' },
{ type: NodeType.AUDIO_GENERATOR, label: 'Audio Generator', icon: Mic2, color: 'text-pink-400' },
{ type: NodeType.VIDEO_ANALYZER, label: 'Video Analyzer', icon: FileSearch, color: 'text-emerald-400' },
{ type: NodeType.IMAGE_EDITOR, label: 'Image Editor', icon: Edit, color: 'text-rose-400' },
{ type: NodeType.INFO_DISPLAY, label: 'Info Display', icon: Info, color: 'text-blue-400' },
];
export const ConnectionMenu = ({ x, y, sourceType, onSelect, onClose }: ConnectionMenuProps) => {
const menuRef = useRef<HTMLDivElement>(null);
// Filter options based on source type validity
const options = React.useMemo(() => {
if (!sourceType) return NODE_OPTIONS;
const validTargets = VALID_CONNECTIONS[sourceType] || [];
return NODE_OPTIONS.filter(opt => validTargets.includes(opt.type));
}, [sourceType]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
if (options.length === 0) return null;
return (
<div
ref={menuRef}
className="fixed z-50 bg-black/80 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl p-2 min-w-[200px] animate-in fade-in zoom-in-95 duration-200"
style={{
left: x,
top: y,
}}
>
<div className="text-xs font-bold text-muted-foreground px-2 py-1 mb-1 uppercase tracking-wider">
Add Node
</div>
<div className="flex flex-col gap-1">
{options.map((option) => (
<button
key={option.type}
onClick={() => onSelect(option.type)}
className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors text-left group"
>
<option.icon size={16} className={cn(option.color, "group-hover:scale-110 transition-transform")} />
<span className="text-gray-200 font-medium">{option.label}</span>
</button>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,434 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { X, Check, Crop, Move } from 'lucide-react';
import { SecureImage } from '@/components/common/SecureImage';
import { useReactFlow } from '@xyflow/react';
import { useCanvasEvents } from '../hooks/useCanvasEvents';
import { cropImage, type CropRect } from '@/utils/mediaUtils';
const RATIOS = [
{ label: '自由', value: null },
{ label: '16:9', value: 16/9 },
{ label: '9:16', value: 9/16 },
{ label: '4:3', value: 4/3 },
{ label: '3:4', value: 3/4 },
{ label: '1:1', value: 1 },
];
type InteractionType = 'create' | 'move' | 'resize';
type ResizeHandle = 'nw' | 'ne' | 'sw' | 'se';
export const ImageCropper: React.FC = () => {
const { cropRequest, setCropRequest } = useCanvasEvents();
const { setNodes } = useReactFlow();
const imageSrc = cropRequest?.src || '';
const onConfirm = (croppedBase64: string) => {
if (!cropRequest) return;
setNodes((nds) => nds.map(n => n.id === cropRequest.nodeId && n.type === 'appNode' ? { ...n, data: { ...n.data, croppedFrame: croppedBase64 } } : n));
setCropRequest(null);
};
const onCancel = () => setCropRequest(null);
const [crop, setCrop] = useState<CropRect | null>(null);
const [aspectRatio, setAspectRatio] = useState<number | null>(null); // null means free
const imgRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Interaction State
const [interaction, setInteraction] = useState<{
type: InteractionType;
handle?: ResizeHandle;
startPos: { x: number; y: number };
startCrop: CropRect | null;
}>({ type: 'create', startPos: { x: 0, y: 0 }, startCrop: null });
const getRelativePos = (e: React.MouseEvent | MouseEvent) => {
if (!imgRef.current) return { x: 0, y: 0 };
const rect = imgRef.current.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
rawX: e.clientX,
rawY: e.clientY
};
};
// Helper: Constrain a rectangle within image bounds (maxW, maxH)
// Ensures x, y >= 0 and x+w <= maxW, y+h <= maxH
const clampRect = (rect: CropRect, maxW: number, maxH: number): CropRect => {
let { x, y, width, height } = rect;
// Basic clamping
if (x < 0) x = 0;
if (y < 0) y = 0;
if (width > maxW) width = maxW;
if (height > maxH) height = maxH;
if (x + width > maxW) x = maxW - width;
if (y + height > maxH) y = maxH - height;
return { x, y, width, height };
};
const handleMouseDown = (e: React.MouseEvent, type: InteractionType, handle?: ResizeHandle) => {
e.preventDefault();
e.stopPropagation();
const pos = getRelativePos(e);
// If starting a NEW creation, clear the old crop unless we clicked on handles or existing crop
let startCrop = crop;
if (type === 'create') {
startCrop = { x: pos.x, y: pos.y, width: 0, height: 0 };
setCrop(startCrop);
}
setInteraction({
type,
handle,
startPos: { x: pos.x, y: pos.y },
startCrop: startCrop ? { ...startCrop } : null
});
};
const handleGlobalMouseMove = useCallback((e: MouseEvent) => {
if (!imgRef.current || !interaction.startCrop) return;
// Only process if mouse button is down (safety check)
if (e.buttons === 0) {
setInteraction(prev => ({ ...prev, type: 'create' })); // Reset to default
return;
}
// Helper to get relative pos inside callback
const getPos = (evt: MouseEvent) => {
if (!imgRef.current) return { x: 0, y: 0 };
const rect = imgRef.current.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
};
const pos = getPos(e);
const maxW = imgRef.current.width;
const maxH = imgRef.current.height;
const { startPos, startCrop } = interaction;
if (interaction.type === 'move') {
const dx = pos.x - startPos.x;
const dy = pos.y - startPos.y;
const newRect = {
...startCrop,
x: startCrop.x + dx,
y: startCrop.y + dy
};
setCrop(clampRect(newRect, maxW, maxH));
}
else if (interaction.type === 'create') {
let currentX = Math.max(0, Math.min(pos.x, maxW));
let currentY = Math.max(0, Math.min(pos.y, maxH));
// Use startCrop.x/y as anchor (which was set to mouseDown pos)
const anchorX = startCrop.x;
const anchorY = startCrop.y;
let width = Math.abs(currentX - anchorX);
let height = Math.abs(currentY - anchorY);
// Apply Aspect Ratio
if (aspectRatio) {
if (width / height > aspectRatio) {
height = width / aspectRatio;
} else {
width = height * aspectRatio;
}
}
const dirX = currentX >= anchorX ? 1 : -1;
const dirY = currentY >= anchorY ? 1 : -1;
let x = anchorX + (dirX === -1 ? -width : 0);
let y = anchorY + (dirY === -1 ? -height : 0);
// Boundary Check for Create
if (x < 0) { x = 0; if (aspectRatio) height = width/aspectRatio; }
if (y < 0) { y = 0; if (aspectRatio) width = height*aspectRatio; }
if (x + width > maxW) {
// Simple clamp by shifting x if possible, or reducing size
if (dirX === 1) width = maxW - x;
else x = maxW - width;
if (aspectRatio) height = width / aspectRatio;
}
if (y + height > maxH) {
if (dirY === 1) height = maxH - y;
else y = maxH - height;
if (aspectRatio) width = height * aspectRatio;
}
setCrop({ x, y, width, height });
}
else if (interaction.type === 'resize' && interaction.handle) {
// Resizing logic
// 1. Determine Anchor Point (Opposite to handle)
let anchorX = 0, anchorY = 0;
switch (interaction.handle) {
case 'nw': anchorX = startCrop.x + startCrop.width; anchorY = startCrop.y + startCrop.height; break;
case 'ne': anchorX = startCrop.x; anchorY = startCrop.y + startCrop.height; break;
case 'sw': anchorX = startCrop.x + startCrop.width; anchorY = startCrop.y; break;
case 'se': anchorX = startCrop.x; anchorY = startCrop.y; break;
}
// 2. Calculate raw new dimensions based on mouse pos relative to anchor
// We do NOT clamp mouse pos here strictly yet, we calculate desired rect then fit.
const currentX = Math.max(0, Math.min(pos.x, maxW));
const currentY = Math.max(0, Math.min(pos.y, maxH));
let newW = Math.abs(currentX - anchorX);
let newH = Math.abs(currentY - anchorY);
// 3. Apply Aspect Ratio
if (aspectRatio) {
// Standard projection: take the larger dimension change or just prefer width?
// Let's rely on the handle direction.
// For corners, usually we pick the dimension that results in a larger box?
// Or typically width drives height for stability.
// Let's use width to drive height for consistent feel.
newH = newW / aspectRatio;
// Check if this height causes Y to go out of bounds?
// If dragging SE, Y must be <= maxH.
// If dragging NE, Y must be >= 0.
const isNorth = interaction.handle.includes('n');
const projectedY = isNorth ? anchorY - newH : anchorY + newH;
if (projectedY < 0 || projectedY > maxH) {
// Width-based height failed bounds, try Height-based width
newH = Math.abs(currentY - anchorY); // Revert to raw Y
newW = newH * aspectRatio;
}
}
// 4. Reconstruct Rect
let newX = interaction.handle.includes('w') ? anchorX - newW : anchorX;
let newY = interaction.handle.includes('n') ? anchorY - newH : anchorY;
// 5. Final Clamp (Double safety)
if (newX < 0) newX = 0;
if (newY < 0) newY = 0;
if (newX + newW > maxW) newW = maxW - newX;
if (newY + newH > maxH) newH = maxH - newY;
// If clamp broke aspect ratio, strict re-calc?
// For cropping tool, slight drift is annoying, but hard clamp is better than broken UI.
// We'll leave it as is, usually user corrects mouse.
setCrop({ x: newX, y: newY, width: newW, height: newH });
}
}, [interaction, aspectRatio]);
const handleGlobalMouseUp = () => {
// Just reset to create/none state
setInteraction(prev => ({ ...prev, type: 'create', startCrop: null }));
};
useEffect(() => {
window.addEventListener('mousemove', handleGlobalMouseMove);
window.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
window.removeEventListener('mousemove', handleGlobalMouseMove);
window.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, [interaction, handleGlobalMouseMove]);
// Adjust existing crop when ratio changes
useEffect(() => {
if (crop && aspectRatio && crop.width > 0 && crop.height > 0) {
// Keep center, adjust size
const centerX = crop.x + crop.width / 2;
const centerY = crop.y + crop.height / 2;
let newW = crop.width;
let newH = newW / aspectRatio;
if (newH > (imgRef.current?.height || 0)) {
newH = imgRef.current?.height || 0;
newW = newH * aspectRatio;
}
let newX = centerX - newW / 2;
let newY = centerY - newH / 2;
if (imgRef.current) {
const rect = clampRect({ x: newX, y: newY, width: newW, height: newH }, imgRef.current.width, imgRef.current.height);
setCrop(rect);
}
}
}, [aspectRatio, crop]);
const handleConfirm = () => {
if (!imgRef.current || !crop || crop.width === 0) { onConfirm(imageSrc); return; }
const canvas = document.createElement('canvas');
const sx = imgRef.current.naturalWidth / imgRef.current.width;
const sy = imgRef.current.naturalHeight / imgRef.current.height;
canvas.width = crop.width * sx;
canvas.height = crop.height * sy;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(imgRef.current, crop.x * sx, crop.y * sy, crop.width * sx, crop.height * sy, 0, 0, crop.width * sx, crop.height * sy);
onConfirm(canvas.toDataURL('image/png'));
}
};
if (!cropRequest) return null;
return (
<div className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center animate-in fade-in duration-300">
{/* Top Bar: Title */}
<div className="absolute top-6 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2">
<div className="bg-muted/90 backdrop-blur-md px-6 py-2.5 rounded-full border border-white/10 text-muted-foreground text-xs font-medium flex items-center gap-2 shadow-2xl">
<Crop size={14} className="text-cyan-400" />
<span></span>
</div>
<span className="text-[10px] text-muted-foreground font-medium"> </span>
</div>
{/* Main Canvas Area */}
<div
ref={containerRef}
className="relative max-w-[85vw] max-h-[65vh] border border-white/10 shadow-2xl rounded-lg overflow-hidden select-none bg-black/50 group"
style={{ cursor: 'crosshair' }}
onMouseDown={(e) => handleMouseDown(e, 'create')}
>
<SecureImage ref={imgRef} src={imageSrc} className="max-w-full max-h-[65vh] object-contain block opacity-50" draggable={false} crossOrigin="anonymous" />
{/* Active Crop Area */}
{crop && crop.width > 0 && (
<div className="absolute" style={{ left: crop.x, top: crop.y, width: crop.width, height: crop.height }}>
{/* 1. Clear Image View Inside */}
<div className="absolute inset-0 overflow-hidden">
<SecureImage
src={imageSrc}
className="absolute max-w-none"
style={{
width: imgRef.current?.width,
height: imgRef.current?.height,
left: -crop.x,
top: -crop.y,
opacity: 1
}}
crossOrigin="anonymous"
/>
</div>
{/* 2. Dark Overlay Outline (Outside shadow trick) */}
<div className="absolute inset-0 shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] pointer-events-none" />
{/* 3. Grid & Border */}
<div className="absolute inset-0 border-2 border-cyan-400 z-10 pointer-events-none">
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 opacity-40">
<div className="border-r border-white/50"/><div className="border-r border-white/50"/><div className="col-span-3 border-b border-white/50 -mt-[33%]"/><div className="col-span-3 border-b border-white/50 mt-[33%]"/>
</div>
</div>
{/* 4. Move Handler (Invisible Center) */}
<div
className="absolute inset-0 z-20 cursor-move group/move"
onMouseDown={(e) => handleMouseDown(e, 'move')}
>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/move:opacity-100 transition-opacity duration-200">
<div className="bg-black/50 p-2 rounded-full backdrop-blur-sm">
<Move size={16} className="text-foreground" />
</div>
</div>
</div>
{/* 5. Resize Handles (Corners) */}
{['nw', 'ne', 'sw', 'se'].map((h) => (
<div
key={h}
className={`
absolute w-4 h-4 bg-white border-2 border-cyan-500 rounded-full z-30 shadow-sm
hover:scale-125 transition-transform
`}
style={{
cursor: `${h}-resize`,
left: h.includes('w') ? -8 : 'auto',
right: h.includes('e') ? -8 : 'auto',
top: h.includes('n') ? -8 : 'auto',
bottom: h.includes('s') ? -8 : 'auto',
}}
onMouseDown={(e) => handleMouseDown(e, 'resize', h as ResizeHandle)}
/>
))}
{/* Size Label */}
<div className="absolute -top-7 left-0 flex gap-2 z-20 pointer-events-none">
<div className="bg-cyan-500 text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm shadow-md">
{Math.round(crop.width)} × {Math.round(crop.height)}
</div>
{aspectRatio && (
<div className="bg-black/60 text-cyan-400 border border-cyan-500/30 text-[9px] font-bold px-1.5 py-0.5 rounded-sm shadow-md">
{RATIOS.find(r => r.value === aspectRatio)?.label}
</div>
)}
</div>
</div>
)}
</div>
{/* Bottom Bar: Aspect Ratios & Actions */}
<div className="flex flex-col items-center gap-6 mt-8 w-full max-w-2xl px-4">
{/* Aspect Ratio Selector */}
<div className="flex items-center gap-2 p-1 bg-card border border-white/10 rounded-xl shadow-lg overflow-x-auto custom-scrollbar max-w-full">
{RATIOS.map(ratio => (
<button
key={ratio.label}
onClick={() => setAspectRatio(ratio.value)}
className={`
relative px-4 py-2 rounded-lg text-xs font-bold transition-all whitespace-nowrap
${aspectRatio === ratio.value
? 'bg-cyan-500 text-black shadow-md scale-105 z-10'
: 'text-muted-foreground hover:text-foreground hover:bg-white/5'
}
`}
>
{ratio.label}
</button>
))}
</div>
{/* Action Buttons */}
<div className="flex gap-4">
<button onClick={onCancel} className="px-6 py-2.5 rounded-full bg-white/5 hover:bg-white/10 text-white text-xs font-medium transition-colors border border-white/5">
</button>
<button
onClick={handleConfirm}
disabled={!crop || crop.width === 0}
className={`
px-8 py-2.5 rounded-full text-xs font-bold shadow-lg transition-all flex items-center gap-2
${(!crop || crop.width === 0)
? 'bg-white/5 text-muted-foreground cursor-not-allowed'
: 'bg-cyan-500 hover:bg-cyan-400 text-black hover:scale-105 shadow-cyan-500/20'
}
`}
>
<Check size={14}/>
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,321 @@
/**
* Loading Progress Bar Component
*
* Displays a progress bar during canvas loading with stage indicators.
* Shows the current loading stage and progress percentage.
*/
import React from 'react';
import { LoadingStage } from '../managers/useProgressiveLoading';
import { Loader2 } from 'lucide-react';
export interface LoadingProgressBarProps {
/** Current loading stage */
stage: LoadingStage;
/** Loading progress (0-100) */
progress: number;
/** Whether loading is in progress */
isLoading: boolean;
/** Error message if loading failed */
error?: string;
/** Optional className for styling */
className?: string;
}
/**
* Get user-friendly message for each loading stage
*/
function getStageMessage(stage: LoadingStage): string {
switch (stage) {
case 'idle':
return '初始化中...';
case 'structure':
return '加载画布结构...';
case 'content':
return '加载图片和视频...';
case 'complete':
return '加载完成!';
case 'error':
return '加载失败';
default:
return '加载中...';
}
}
/**
* Get stage index for progress indicator
*/
function getStageIndex(stage: LoadingStage): number {
switch (stage) {
case 'idle':
return 0;
case 'structure':
return 1;
case 'content':
return 2;
case 'complete':
return 3;
default:
return 0;
}
}
/**
* Loading Progress Bar Component
*/
export function LoadingProgressBar({
stage,
progress,
isLoading,
error,
className = '',
}: LoadingProgressBarProps) {
// Don't show if not loading and no error
if (!isLoading && !error && stage === 'complete') {
return null;
}
const currentStageIndex = getStageIndex(stage);
const stageMessage = getStageMessage(stage);
return (
<div
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm ${className}`}
>
<div className="w-full max-w-md mx-4 p-6 bg-card/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl">
{/* Error State */}
{error && (
<div className="text-center">
<div className="mb-4 text-red-500">
<svg
className="w-16 h-16 mx-auto"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">
</h3>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
)}
{/* Loading State */}
{!error && (
<>
{/* Stage Indicators */}
<div className="flex items-center justify-between mb-6">
{/* Stage 1: Structure */}
<div className="flex flex-col items-center flex-1">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
currentStageIndex >= 1
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
: 'bg-white/10 text-muted-foreground'
}`}
>
{currentStageIndex > 1 ? (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<span className="text-sm font-bold">1</span>
)}
</div>
<span
className={`text-xs font-medium transition-colors ${
currentStageIndex >= 1
? 'text-white'
: 'text-muted-foreground'
}`}
>
</span>
</div>
{/* Divider */}
<div
className={`flex-1 h-0.5 mx-2 transition-all duration-300 ${
currentStageIndex >= 2
? 'bg-cyan-500'
: 'bg-white/10'
}`}
/>
{/* Stage 2: Content */}
<div className="flex flex-col items-center flex-1">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
currentStageIndex >= 2
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
: 'bg-white/10 text-muted-foreground'
}`}
>
{currentStageIndex > 2 ? (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<span className="text-sm font-bold">2</span>
)}
</div>
<span
className={`text-xs font-medium transition-colors ${
currentStageIndex >= 2
? 'text-white'
: 'text-muted-foreground'
}`}
>
</span>
</div>
{/* Divider */}
<div
className={`flex-1 h-0.5 mx-2 transition-all duration-300 ${
currentStageIndex >= 3
? 'bg-cyan-500'
: 'bg-white/10'
}`}
/>
{/* Stage 3: Complete */}
<div className="flex flex-col items-center flex-1">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
currentStageIndex >= 3
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
: 'bg-white/10 text-muted-foreground'
}`}
>
{currentStageIndex >= 3 ? (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<span className="text-sm font-bold">3</span>
)}
</div>
<span
className={`text-xs font-medium transition-colors ${
currentStageIndex >= 3
? 'text-white'
: 'text-muted-foreground'
}`}
>
</span>
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500 transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Status Message */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isLoading && (
<Loader2 className="w-4 h-4 text-cyan-500 animate-spin" />
)}
<span className="text-sm text-muted-foreground">
{stageMessage}
</span>
</div>
<span className="text-sm font-bold text-white tabular-nums">
{Math.round(progress)}%
</span>
</div>
</>
)}
</div>
</div>
);
}
/**
* Compact Loading Progress Bar (for in-canvas display)
*/
export function CompactLoadingProgressBar({
stage,
progress,
isLoading,
className = '',
}: Omit<LoadingProgressBarProps, 'error'>) {
// Don't show if not loading
if (!isLoading || stage === 'complete') {
return null;
}
const stageMessage = getStageMessage(stage);
return (
<div
className={`fixed top-4 left-1/2 -translate-x-1/2 z-50 ${className}`}
>
<div className="px-4 py-2 bg-card/95 backdrop-blur-xl border border-white/10 rounded-full shadow-lg">
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 text-cyan-500 animate-spin" />
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{stageMessage}
</span>
<span className="text-xs font-bold text-white tabular-nums">
{Math.round(progress)}%
</span>
</div>
<div className="w-24 h-1 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,270 @@
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronRight, ChevronLeft, Sparkles, MousePointer, Keyboard, Zap, Check } from 'lucide-react';
import { cn } from '@/utils';
interface OnboardingStep {
id: string;
title: string;
description: string;
icon: React.ReactNode;
target?: string;
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
}
const onboardingSteps: OnboardingStep[] = [
{
id: 'welcome',
title: '欢迎使用 Pixel',
description: 'Pixel 是一个 AI 驱动的漫画和视频创作平台。让我们花几分钟了解一下核心功能。',
icon: <Sparkles className="w-6 h-6 text-cyan-400" />,
position: 'center'
},
{
id: 'canvas',
title: '画布工作区',
description: '这是你的创作画布。你可以在这里添加节点、连接它们来构建创作流程。拖拽左侧的节点到画布上开始创作。',
icon: <MousePointer className="w-6 h-6 text-purple-400" />,
target: '.react-flow__pane',
position: 'center'
},
{
id: 'sidebar',
title: '左侧工具栏',
description: '这里提供了各种 AI 生成节点:图像生成、视频生成、剧本处理等。点击或拖拽到画布上使用。',
icon: <Zap className="w-6 h-6 text-yellow-400" />,
target: '[data-panel="left-dock"]',
position: 'right'
},
{
id: 'shortcuts',
title: '快捷键',
description: '使用快捷键可以提高创作效率。Ctrl+S 保存Ctrl+Z 撤销Ctrl+A 全选。点击右下角的键盘图标查看全部快捷键。',
icon: <Keyboard className="w-6 h-6 text-green-400" />,
target: '[data-shortcut-button]',
position: 'top'
},
{
id: 'complete',
title: '准备开始创作',
description: '你已经了解了基本操作。开始你的第一个 AI 创作项目吧!如有问题,随时查看帮助文档。',
icon: <Check className="w-6 h-6 text-emerald-400" />,
position: 'center'
}
];
const STORAGE_KEY = 'pixel-onboarding-completed';
export function OnboardingTour() {
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [hasCompleted, setHasCompleted] = useState(false);
useEffect(() => {
// Check if user has completed onboarding
const completed = localStorage.getItem(STORAGE_KEY);
if (!completed) {
// Show onboarding after a short delay
const timer = setTimeout(() => {
setIsOpen(true);
}, 1000);
return () => clearTimeout(timer);
} else {
setHasCompleted(true);
}
}, []);
const handleComplete = useCallback(() => {
localStorage.setItem(STORAGE_KEY, 'true');
setHasCompleted(true);
setIsOpen(false);
}, []);
const handleNext = useCallback(() => {
if (currentStep < onboardingSteps.length - 1) {
setCurrentStep(prev => prev + 1);
} else {
handleComplete();
}
}, [currentStep, handleComplete]);
const handlePrev = useCallback(() => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
}, [currentStep]);
const handleSkip = useCallback(() => {
localStorage.setItem(STORAGE_KEY, 'true');
setHasCompleted(true);
setIsOpen(false);
}, []);
const handleRestart = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setHasCompleted(false);
setCurrentStep(0);
setIsOpen(true);
}, []);
const step = onboardingSteps[currentStep];
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === onboardingSteps.length - 1;
const progress = ((currentStep + 1) / onboardingSteps.length) * 100;
if (!isOpen) {
// Show restart button if user has completed but wants to see it again
if (hasCompleted) {
return (
<button
onClick={handleRestart}
className="fixed bottom-6 left-6 z-50 p-2 bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 rounded-full text-muted-foreground hover:text-white transition-all"
title="重新查看新手引导"
>
<Sparkles size={18} />
</button>
);
}
return null;
}
return (
<>
{/* Backdrop */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9998] bg-black/60 backdrop-blur-sm"
onClick={handleSkip}
/>
)}
</AnimatePresence>
{/* Onboarding Card */}
<AnimatePresence mode="wait">
<motion.div
key={step.id}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className={cn(
"fixed z-[9999] w-[400px] bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden",
step.position === 'center' && "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
step.position === 'top' && "bottom-24 right-6",
step.position === 'bottom' && "top-24 left-1/2 -translate-x-1/2",
step.position === 'left' && "top-1/2 right-6 -translate-y-1/2",
step.position === 'right' && "top-1/2 left-6 -translate-y-1/2"
)}
>
{/* Progress bar */}
<div className="absolute top-0 left-0 right-0 h-1 bg-white/5">
<motion.div
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Close button */}
<button
onClick={handleSkip}
className="absolute top-3 right-3 p-1.5 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<X size={16} />
</button>
{/* Content */}
<div className="p-6">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
{step.icon}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-white mb-2">
{step.title}
</h3>
{/* Description */}
<p className="text-sm text-muted-foreground leading-relaxed mb-6">
{step.description}
</p>
{/* Step indicator */}
<div className="flex items-center justify-center gap-1.5 mb-6">
{onboardingSteps.map((_, index) => (
<div
key={index}
className={cn(
"w-2 h-2 rounded-full transition-colors",
index === currentStep
? "bg-cyan-400"
: index < currentStep
? "bg-white/40"
: "bg-white/10"
)}
/>
))}
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={handlePrev}
disabled={isFirstStep}
className={cn(
"flex items-center gap-1 px-3 py-2 text-sm rounded-lg transition-colors",
isFirstStep
? "text-muted-foreground/50 cursor-not-allowed"
: "text-muted-foreground hover:text-white hover:bg-white/10"
)}
>
<ChevronLeft size={16} />
</button>
<div className="flex items-center gap-2">
{!isLastStep && (
<button
onClick={handleSkip}
className="px-3 py-2 text-sm text-muted-foreground hover:text-white transition-colors"
>
</button>
)}
<button
onClick={handleNext}
className="flex items-center gap-1 px-4 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 transition-colors"
>
{isLastStep ? '完成' : '下一步'}
{!isLastStep && <ChevronRight size={16} />}
</button>
</div>
</div>
</div>
</motion.div>
</AnimatePresence>
</>
);
}
export function ResetOnboardingButton() {
const handleReset = () => {
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
};
return (
<button
onClick={handleReset}
className="text-xs text-muted-foreground hover:text-white underline"
>
</button>
);
}

View File

@@ -0,0 +1,519 @@
'use client';
import React, { useRef, useState, useEffect } from 'react';
import {
X, Brush, Eraser, Palette, Undo, Trash2,
Download, Play, Image as ImageIcon,
Activity, Wand2, Loader2, ChevronDown, Layers
} from 'lucide-react';
import { generateImage, generateVideo } from '../services/canvasService';
import { useModelStore } from '@lib/store/modelStore';
import { SecureImage } from '@/components/common/SecureImage';
import { handleError } from '@/lib/services/errorHandler';
import { logger } from '@/lib/utils/logger';
const log = logger.namespace('SketchEditor');
interface SketchEditorProps {
onClose: () => void;
onGenerate: (type: 'image' | 'video', result: string, prompt: string) => void;
}
type Tool = 'brush' | 'eraser';
type Mode = 'video' | 'image' | 'pose';
// Colors for the palette
export const PRESET_COLORS = [
'#000000', '#ffffff', '#ff3b30', '#ff9500',
'#ffcc00', '#4cd964', '#5ac8fa', '#007aff',
'#5856d6', '#ff2d55', '#8e8e93'
];
export const SketchEditor: React.FC<SketchEditorProps> = ({ onClose, onGenerate }) => {
// Models
const { imageModels, videoModels } = useModelStore();
const [selectedImageModel, setSelectedImageModel] = useState<string>('');
const [selectedVideoModel, setSelectedVideoModel] = useState<string>('');
const [showModelDropdown, setShowModelDropdown] = useState(false);
useEffect(() => {
if (!selectedImageModel && Object.keys(imageModels).length > 0) {
setSelectedImageModel(Object.keys(imageModels)[0]);
}
}, [imageModels, selectedImageModel]);
useEffect(() => {
if (!selectedVideoModel && Object.keys(videoModels).length > 0) {
setSelectedVideoModel(Object.keys(videoModels)[0]);
}
}, [videoModels, selectedVideoModel]);
// Canvas & Drawing State
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [tool, setTool] = useState<Tool>('brush');
const [brushColor, setBrushColor] = useState('#000000');
const [brushSize] = useState(5);
const [eraserSize] = useState(30);
const [canvasHistory, setCanvasHistory] = useState<ImageData[]>([]);
// Background Image State
const [backgroundImage, setBackgroundImage] = useState<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// UI State
const [activeMode, setActiveMode] = useState<Mode>('video');
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [showPalette, setShowPalette] = useState(false);
// Init Canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Handle High DPI
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.scale(dpr, dpr);
// Initialize transparent
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
saveHistory(); // Save initial blank state
}
}, []);
const saveHistory = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
setCanvasHistory(prev => [...prev.slice(-10), data]);
}
};
const handleUndo = () => {
if (canvasHistory.length <= 1) return;
const newHistory = [...canvasHistory];
newHistory.pop(); // Remove current state
const prevState = newHistory[newHistory.length - 1];
setCanvasHistory(newHistory);
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx && prevState) {
ctx.putImageData(prevState, 0, 0);
}
};
const handleClear = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
saveHistory();
}
};
const handleImportBackground = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => setBackgroundImage(img);
img.src = ev.target?.result as string;
};
reader.readAsDataURL(file);
}
};
// Drawing Handlers
const getPos = (e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
return {
x: clientX - rect.left,
y: clientY - rect.top
};
};
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
setIsDrawing(true);
const { x, y } = getPos(e);
const ctx = canvasRef.current?.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.moveTo(x, y);
if (tool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
ctx.lineWidth = eraserSize;
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = brushColor;
ctx.lineWidth = brushSize;
}
}
};
const draw = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing) return;
const { x, y } = getPos(e);
const ctx = canvasRef.current?.getContext('2d');
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
};
const stopDrawing = () => {
if (isDrawing) {
setIsDrawing(false);
const ctx = canvasRef.current?.getContext('2d');
ctx?.closePath();
// Reset composite operation to default just in case
if (ctx) ctx.globalCompositeOperation = 'source-over';
saveHistory();
}
};
// --- Composite Logic (Merge Background + Sketch) ---
const getCompositeDataURL = (): string => {
const canvas = canvasRef.current;
if (!canvas) return '';
// Create an off-screen canvas for composition
const osc = document.createElement('canvas');
osc.width = canvas.width;
osc.height = canvas.height;
const ctx = osc.getContext('2d');
if (!ctx) return '';
// 1. Fill White Background (Base)
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, osc.width, osc.height);
// 2. Draw Background Image (Scaled to Fit/Cover logic matching UI)
if (backgroundImage) {
// Calculate "contain" aspect ratio to match UI
const scale = Math.min(osc.width / backgroundImage.width, osc.height / backgroundImage.height);
const w = backgroundImage.width * scale;
const h = backgroundImage.height * scale;
const x = (osc.width - w) / 2;
const y = (osc.height - h) / 2;
ctx.drawImage(backgroundImage, x, y, w, h);
}
// 3. Draw User Sketch
ctx.drawImage(canvas, 0, 0);
return osc.toDataURL('image/png');
};
// Generation Logic
const handleGenerate = async () => {
if (!prompt.trim() || isGenerating) return;
setIsGenerating(true);
try {
if (activeMode === 'pose') {
// --- Pose Generator Mode: Draw TO Canvas ---
// 1. Generate Line Art from Gemini 2.5
const posePrompt = `
Generate a simple, high-contrast black line art sketch on a white background.
Subject: ${prompt}.
Style: Minimalist stick figure or outline drawing, clear lines, no shading.
`;
const res = await generateImage(posePrompt, selectedImageModel, [], { aspectRatio: '16:9', count: 1 });
const imgUrl = res[0];
// 2. Draw Result onto Canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
// We want to draw this opaque, but keep it editable.
// Since current canvas history logic is pixel-based, drawing it is destructive but fine.
// We draw it 'source-over'.
ctx.globalCompositeOperation = 'source-over';
// Scale to fit
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (canvas.width - w) / 2;
const y = (canvas.height - h) / 2;
ctx.drawImage(img, x, y, w, h);
saveHistory();
setIsGenerating(false);
}
};
img.onerror = () => {
throw new Error("Failed to load generated pose image");
};
img.src = imgUrl;
} else {
// --- Video/Image Mode: Generate FROM Canvas ---
const compositeBase64 = getCompositeDataURL();
if (activeMode === 'video') {
const res = await generateVideo(
prompt,
selectedVideoModel,
{ aspectRatio: '16:9' },
compositeBase64
);
onGenerate('video', res.uri, prompt);
} else {
// Image (Sketch-to-Image)
const res = await generateImage(
prompt,
selectedImageModel,
[compositeBase64],
{ aspectRatio: '16:9', count: 1 }
);
onGenerate('image', res[0], prompt);
}
onClose();
}
} catch (e) {
handleError(e, 'SketchGenerate', {
customMessage: '生成失败,请重试'
});
setIsGenerating(false);
}
};
const activeModel = activeMode === 'video'
? videoModels[selectedVideoModel]
: imageModels[selectedImageModel];
return (
<div className="fixed inset-0 z-[100] bg-background flex flex-col animate-in fade-in duration-300">
{/* 1. Top Navigation Bar */}
<div className="h-14 border-b border-white/10 flex items-center justify-between px-6 bg-card">
<button
onClick={onClose}
className="absolute left-6 p-2 rounded-full bg-white/5 hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} />
</button>
<div className="flex-1 flex justify-center">
<div className="flex bg-black/30 p-1 rounded-lg">
{[
{ id: 'video', label: '涂鸦生视频', icon: Play },
{ id: 'image', label: '涂鸦生图', icon: ImageIcon },
{ id: 'pose', label: '姿势生成器 (Pose)', icon: Activity }
].map(mode => (
<button
key={mode.id}
onClick={() => setActiveMode(mode.id as Mode)}
className={`
flex items-center gap-2 px-6 py-1.5 rounded-md text-xs font-bold transition-all
${activeMode === mode.id
? 'bg-white/10 text-foreground shadow-sm'
: 'text-muted-foreground hover:text-muted-foreground'}
`}
>
<mode.icon size={12} />
{mode.label}
</button>
))}
</div>
</div>
</div>
{/* 2. Main Canvas Area */}
<div className="flex-1 relative bg-background flex items-center justify-center p-8 overflow-hidden">
{/* Floating Toolbar */}
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 p-1.5 bg-muted/90 backdrop-blur-xl border border-white/10 rounded-full shadow-2xl">
<button
onClick={() => setTool('brush')}
className={`p-2.5 rounded-full transition-colors ${tool === 'brush' ? 'bg-cyan-500 text-black' : 'text-muted-foreground hover:text-white hover:bg-white/5'}`}
title="画笔"
>
<Brush size={16} />
</button>
<button
onClick={() => setTool('eraser')}
className={`p-2.5 rounded-full transition-colors ${tool === 'eraser' ? 'bg-cyan-500 text-black' : 'text-muted-foreground hover:text-white hover:bg-white/5'}`}
title="橡皮擦"
>
<Eraser size={16} />
</button>
<div className="w-px h-6 bg-white/10 mx-1" />
<div className="relative">
<button
onClick={() => setShowPalette(!showPalette)}
className="p-2.5 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-white/5 relative"
title="调色板"
>
<Palette size={16} style={{ color: tool === 'brush' ? brushColor : undefined }} />
<div className="absolute bottom-1 right-1 w-2 h-2 rounded-full border border-border" style={{ backgroundColor: brushColor }} />
</button>
{showPalette && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-3 p-3 bg-card border border-white/10 rounded-xl shadow-xl grid grid-cols-4 gap-2 w-48 z-30">
{PRESET_COLORS.map(c => (
<button
key={c}
onClick={() => { setBrushColor(c); setTool('brush'); setShowPalette(false); }}
className={`w-8 h-8 rounded-full border-2 ${brushColor === c ? 'border-white' : 'border-transparent hover:scale-110'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
)}
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
<button onClick={handleUndo} className="p-2.5 rounded-full text-muted-foreground hover:text-white hover:bg-white/5">
<Undo size={16} />
</button>
<button onClick={handleClear} className="p-2.5 rounded-full text-red-400 hover:bg-red-500/10">
<Trash2 size={16} />
</button>
</div>
{/* The Canvas Wrapper */}
<div className="relative shadow-2xl rounded-lg overflow-hidden border border-white/5 bg-white select-none" style={{ aspectRatio: '16/9', height: '100%', maxHeight: '800px' }}>
{/* Background Image Layer */}
{backgroundImage && (
<SecureImage
src={backgroundImage.src}
className="absolute inset-0 w-full h-full object-contain pointer-events-none opacity-50"
alt="Reference"
crossOrigin="anonymous"
/>
)}
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full cursor-crosshair touch-none"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
/>
</div>
</div>
{/* 3. Bottom Control Bar */}
<div className="h-20 bg-card border-t border-white/10 flex items-center px-8 gap-4">
{/* Tools (Left) */}
<div className="flex items-center gap-2 mr-4">
{/* Import Background Button */}
<div
className="relative p-2 rounded-lg bg-white/5 text-muted-foreground hover:text-foreground border border-white/5 cursor-pointer hover:bg-white/10 transition-colors"
onClick={() => fileInputRef.current?.click()}
title="导入底图"
>
<Layers size={16} />
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImportBackground} />
</div>
<button onClick={() => { if(canvasRef.current){ const a = document.createElement('a'); a.href = getCompositeDataURL(); a.download='sketch.png'; a.click(); } }} className="p-2 rounded-lg bg-white/5 text-muted-foreground hover:text-white border border-white/5" title="下载当前画布">
<Download size={16} />
</button>
</div>
{/* Input Area */}
<div className="flex-1 relative">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={activeMode === 'pose' ? "描述姿势 (e.g. A stick figure running fast)..." : "描述画面内容 (e.g. Milk splash around the bottle)..."}
className="w-full h-11 bg-black/30 border border-white/10 rounded-xl px-4 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500/50 transition-colors"
onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
/>
</div>
{/* Settings & Generate */}
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowModelDropdown(!showModelDropdown)}
className="h-11 px-4 flex items-center gap-2 bg-black/30 border border-white/10 rounded-xl text-xs text-muted-foreground font-medium hover:bg-white/5 transition-colors"
>
{activeModel?.icon && <activeModel.icon size={14} />}
<span>{activeModel?.name || 'Select Model'}</span>
<ChevronDown size={12} className="text-muted-foreground" />
</button>
{showModelDropdown && (
<div className="absolute bottom-full right-0 mb-2 w-48 bg-card border border-white/10 rounded-xl shadow-xl overflow-hidden z-50">
{activeMode === 'video' ? (
Object.values(videoModels).map(m => (
<button
key={m.id}
onClick={() => { setSelectedVideoModel(m.id); setShowModelDropdown(false); }}
className={`w-full text-left px-4 py-2 text-xs font-bold hover:bg-white/10 flex items-center gap-2 ${selectedVideoModel === m.id ? 'text-cyan-400 bg-white/5' : 'text-muted-foreground'}`}
>
{m.icon && <m.icon size={14} />}
{m.name}
</button>
))
) : (
Object.values(imageModels).map(m => (
<button
key={m.id}
onClick={() => { setSelectedImageModel(m.id); setShowModelDropdown(false); }}
className={`w-full text-left px-4 py-2 text-xs font-bold hover:bg-white/10 flex items-center gap-2 ${selectedImageModel === m.id ? 'text-cyan-400 bg-white/5' : 'text-muted-foreground'}`}
>
{m.icon && <m.icon size={14} />}
{m.name}
</button>
))
)}
</div>
)}
</div>
<div className="w-px h-6 bg-white/10 mx-2" />
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className={`
h-11 px-6 rounded-xl flex items-center gap-2 font-bold text-sm transition-all
${isGenerating || !prompt.trim()
? 'bg-white/5 text-muted-foreground cursor-not-allowed'
: activeMode === 'pose'
? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-foreground hover:scale-105'
: 'bg-gradient-to-r from-cyan-600 to-blue-600 text-foreground hover:scale-105'}
`}
>
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
<span>{activeMode === 'pose' ? '生成姿势' : '生成作品'}</span>
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,92 @@
import React, { memo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Library, Music, Plus, UserCircle } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { cn } from '@/utils';
import { InputAsset } from '../../types/node';
import { SecureImage } from '@/components/common/SecureImage';
import { SecureVideo } from '../../nodes/components/SecureVideo';
export const AssetSelector = memo(({ isExpanded, selectedAssets, onToggleAsset, inputAssets = [] }: {
isExpanded: boolean,
selectedAssets: string[],
onToggleAsset: (id: string) => void,
inputAssets?: InputAsset[]
}) => {
return (
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="mb-3 flex items-center gap-2 pointer-events-auto"
>
<div className="bg-black/40 backdrop-blur-md rounded-full p-1.5 flex items-center gap-1 border border-white/10 shadow-lg">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
<Library className="w-4 h-4 text-foreground/70" />
</div>
<span className="text-[10px] font-medium text-foreground/70 px-2"></span>
</div>
<div className="flex items-center -space-x-2 overflow-visible hover:space-x-1 transition-all duration-300">
{/* Input Assets */}
{inputAssets.map((asset) => (
<div
key={asset.id}
className={cn(
"w-10 h-10 rounded-full border-2 border-cyan-500/50 relative overflow-hidden group bg-black/50 transition-all duration-200 z-20",
"hover:scale-110 hover:border-cyan-400 hover:z-30 shadow-[0_0_10px_rgba(6,182,212,0.3)]"
)}
title={asset.label || undefined}
>
{asset.type === 'video' ? (
<SecureVideo
src={asset.src}
className="w-full h-full object-cover"
muted
loop
autoPlay
playsInline
/>
) : asset.type === 'audio' ? (
<div className={cn(
"w-full h-full flex items-center justify-center",
asset.gender ? "bg-black/40" : "bg-cyan-500/20"
)}>
{asset.gender ? (
<UserCircle
size={20}
className={cn(
asset.gender === '男' || asset.gender === 'male' ? 'text-blue-400' : 'text-pink-400'
)}
/>
) : (
<Music className="w-5 h-5 text-cyan-400" />
)}
</div>
) : (
<SecureImage
src={asset.src}
alt="Input Asset"
className="w-full h-full object-cover"
/>
)}
</div>
))}
<Button
variant="ghost"
className="w-10 h-10 rounded-full border-2 border-white/10 border-dashed bg-black/20 hover:bg-white/10 flex items-center justify-center transition-colors p-0"
>
<Plus className="w-4 h-4 text-foreground/50" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
);
});
AssetSelector.displayName = 'AssetSelector';

View File

@@ -0,0 +1,556 @@
'use client';
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import type { InputAsset } from '../../types/node';
import { cn } from '@/utils';
import { Music, UserCircle } from 'lucide-react';
import { SecureImage } from '@/components/common/SecureImage';
import { SecureVideo } from '../../nodes/components/SecureVideo';
import {
createMentionChipElement,
filterMentionCandidates,
parseMentionTokens,
} from './mentionUtils';
interface AutoResizeTextareaProps {
value: string;
onChange: (v: string) => void;
onEnter: () => void;
isExpanded?: boolean;
placeholder?: string;
/** 可 @ 的素材列表,用于在输入时弹出候选 */
inputAssets?: InputAsset[];
}
export const AutoResizeTextarea = ({
value,
onChange,
onEnter,
isExpanded,
placeholder,
inputAssets
}: AutoResizeTextareaProps) => {
const editorRef = useRef<HTMLDivElement>(null);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStart, setMentionStart] = useState<number | null>(null);
const [mentionPosition, setMentionPosition] = useState<{ top: number; left: number; width: number } | null>(null);
const isComposingRef = useRef(false);
const closeMention = useCallback(() => {
setMentionOpen(false);
setMentionQuery('');
setMentionStart(null);
setMentionPosition(null);
}, []);
// Focus when expanded
useEffect(() => {
if (isExpanded && editorRef.current) {
requestAnimationFrame(() => {
editorRef.current?.focus();
// Move cursor to end
const selection = window.getSelection();
if (selection && editorRef.current) {
const range = document.createRange();
range.selectNodeContents(editorRef.current);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
});
}
}, [isExpanded]);
// 从 DOM 读取纯文本内容chip span 的 data-mention-text 就是 @图片1
const getTextFromDOM = useCallback((): string => {
if (!editorRef.current) return '';
let text = '';
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || '';
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.classList.contains('mention-chip')) {
// chip span 的 data-mention-text 就是 @图片1
text += el.getAttribute('data-mention-text') || '';
} else if (el.tagName === 'BR') {
text += '\n';
} else {
node.childNodes.forEach(walk);
}
}
};
editorRef.current.childNodes.forEach(walk);
return text;
}, []);
// 光标偏移与恢复(纯 DOM 工具,稳定引用)
const getCursorOffset = useCallback((editor: HTMLElement, range: Range): number => {
let offset = 0;
const walk = (node: Node): boolean => {
if (node === range.startContainer) {
offset += range.startOffset;
return true;
}
if (node.nodeType === Node.TEXT_NODE) {
offset += (node.textContent || '').length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.classList.contains('mention-chip')) {
offset += (el.getAttribute('data-mention-text') || el.innerText || '').length;
} else {
for (let i = 0; i < node.childNodes.length; i++) {
if (walk(node.childNodes[i])) return true;
}
}
}
return false;
};
for (let i = 0; i < editor.childNodes.length; i++) {
if (walk(editor.childNodes[i])) break;
}
return offset;
}, []);
const restoreCursor = useCallback((editor: HTMLElement, offset: number) => {
let currentOffset = 0;
let found = false;
const walk = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const len = (node.textContent || '').length;
if (currentOffset + len >= offset) {
const range = document.createRange();
range.setStart(node, Math.min(offset - currentOffset, len));
range.collapse(true);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
return true;
}
currentOffset += len;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.classList.contains('mention-chip')) {
const len = (el.getAttribute('data-mention-text') || el.innerText || '').length;
if (currentOffset + len >= offset) {
const range = document.createRange();
range.setStartAfter(el);
range.collapse(true);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
return true;
}
currentOffset += len;
} else {
for (let i = 0; i < node.childNodes.length; i++) {
if (walk(node.childNodes[i])) return true;
}
}
}
return false;
};
for (let i = 0; i < editor.childNodes.length; i++) {
if (walk(editor.childNodes[i])) {
found = true;
break;
}
}
if (!found) {
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
}, []);
// 根据纯文本 value 渲染 DOM文本节点 + chip span
// 只在外部 value 变化时调用,用户输入时不调用
const renderContent = useCallback((newValue: string, preserveCursor: boolean = true) => {
if (!editorRef.current) return;
const editor = editorRef.current;
// 保存当前光标位置
const selection = window.getSelection();
let cursorOffset = 0;
if (preserveCursor && selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
cursorOffset = getCursorOffset(editor, range);
}
// 清空并重新渲染
editor.innerHTML = '';
if (!newValue) {
if (preserveCursor) {
// 确保光标在编辑器内
editor.focus();
}
return;
}
// 解析 value切分为文本段和 @别名段
const tokens = parseMentionTokens(newValue, inputAssets);
tokens.forEach(token => {
if (token.type === 'text') {
editor.appendChild(document.createTextNode(token.text));
} else {
const chip = createMentionChipElement(token.asset, isExpanded);
editor.appendChild(chip);
}
});
// 恢复光标位置
if (preserveCursor) {
restoreCursor(editor, cursorOffset);
}
}, [inputAssets, isExpanded, getCursorOffset, restoreCursor]);
// 初始渲染和外部 value 变化时渲染
const prevValueRef = useRef<string>(value);
const isUserInputRef = useRef<boolean>(false);
useEffect(() => {
// 只在外部 value 变化时重新渲染(不是用户输入导致的变化)
if (isUserInputRef.current) {
// 用户输入导致的变化,不重新渲染
isUserInputRef.current = false;
prevValueRef.current = value;
return;
}
const currentText = getTextFromDOM();
if (value !== prevValueRef.current && value !== currentText) {
renderContent(value, false);
prevValueRef.current = value;
}
}, [value, getTextFromDOM, renderContent]);
// 更新候选浮层位置
const updateMentionPosition = useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
setMentionPosition({
left: rect.left,
top: rect.top,
width: 200,
});
}, []);
// 处理输入
const handleInput = useCallback(() => {
if (isComposingRef.current) return;
const text = getTextFromDOM();
// 标记这是用户输入
isUserInputRef.current = true;
onChange(text);
// 检查是否输入了 @
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const editor = editorRef.current;
if (!editor) return;
const cursorOffset = getCursorOffset(editor, range);
const charBefore = text[cursorOffset - 1];
if (charBefore === '@') {
setMentionOpen(true);
setMentionQuery('');
setMentionStart(cursorOffset - 1);
updateMentionPosition();
return;
}
if (mentionOpen && mentionStart !== null) {
const query = text.slice(mentionStart + 1, cursorOffset);
setMentionQuery(query);
if (!query || /\s/.test(query)) {
closeMention();
} else {
updateMentionPosition();
}
}
}, [getTextFromDOM, onChange, mentionOpen, mentionStart, updateMentionPosition, closeMention, getCursorOffset]);
// 处理键盘事件
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const editor = editorRef.current;
if (!editor) return;
// 阻止 Backspace 和 Delete 键冒泡到画布层(避免删除画布节点)
if (e.key === 'Backspace' || e.key === 'Delete') {
e.stopPropagation();
}
// Enter 提交(折叠模式下不支持换行)
if (e.key === 'Enter') {
if (!isExpanded || !e.shiftKey) {
e.preventDefault();
onEnter();
return;
}
// 展开模式下Shift+Enter 允许换行(浏览器默认行为)
}
// Backspace 删除 chip
if (e.key === 'Backspace' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// 如果有选区,让浏览器默认处理
if (!range.collapsed) return;
// 检查光标前面是否是 chip
const container = range.startContainer;
const offset = range.startOffset;
// 如果光标在文本节点开头,检查前一个兄弟节点
if (container.nodeType === Node.TEXT_NODE && offset === 0) {
const prevSibling = container.previousSibling;
if (prevSibling && prevSibling.nodeType === Node.ELEMENT_NODE) {
const el = prevSibling as HTMLElement;
if (el.classList.contains('mention-chip')) {
e.preventDefault();
el.remove();
isUserInputRef.current = true;
const text = getTextFromDOM();
onChange(text);
return;
}
}
}
// 如果光标在编辑器开头,检查第一个子节点
if (container === editor && offset === 0) {
const firstChild = editor.firstChild;
if (firstChild && firstChild.nodeType === Node.ELEMENT_NODE) {
const el = firstChild as HTMLElement;
if (el.classList.contains('mention-chip')) {
e.preventDefault();
el.remove();
isUserInputRef.current = true;
const text = getTextFromDOM();
onChange(text);
return;
}
}
}
// 如果光标在元素节点内,检查前一个子节点
if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
const prevChild = container.childNodes[offset - 1];
if (prevChild && prevChild.nodeType === Node.ELEMENT_NODE) {
const el = prevChild as HTMLElement;
if (el.classList.contains('mention-chip')) {
e.preventDefault();
el.remove();
isUserInputRef.current = true;
const text = getTextFromDOM();
onChange(text);
return;
}
}
}
}
}, [getTextFromDOM, onChange, onEnter, isExpanded]);
// 候选列表
const candidates = useMemo(() => {
return filterMentionCandidates(inputAssets, mentionQuery);
}, [inputAssets, mentionQuery]);
// 插入 mention
const applyMention = useCallback((asset: InputAsset) => {
if (mentionStart === null || !editorRef.current) return;
const editor = editorRef.current;
const text = getTextFromDOM();
const alias = asset.mentionAlias || asset.label || asset.id;
// 获取当前光标位置
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const cursorOffset = getCursorOffset(editor, range);
// 构建新文本:删除 @ 和查询,插入 @别名 + 空格
const before = text.slice(0, mentionStart);
const after = text.slice(cursorOffset);
const inserted = `@${alias} `;
const newText = before + inserted + after;
// 标记为用户输入,但需要重新渲染(因为要插入 chip
isUserInputRef.current = false; // 这次需要重新渲染
onChange(newText);
closeMention();
// 光标移到插入内容后面
const newCursorOffset = before.length + inserted.length;
requestAnimationFrame(() => {
renderContent(newText, false);
requestAnimationFrame(() => {
restoreCursor(editor, newCursorOffset);
editor.focus();
});
});
}, [mentionStart, getTextFromDOM, onChange, closeMention, getCursorOffset, restoreCursor, renderContent]);
return (
<div className="relative w-full h-full flex items-center">
{/* contentEditable 编辑器 */}
<div
ref={editorRef}
contentEditable
onInput={handleInput}
onKeyDown={handleKeyDown}
onClick={(e) => {
// 确保点击时聚焦
e.stopPropagation();
if (editorRef.current) {
editorRef.current.focus();
// 如果没有内容,将光标移到末尾
if (!editorRef.current.textContent) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(editorRef.current);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}
}
}}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => {
isComposingRef.current = false;
handleInput();
}}
onBlur={() => {
setTimeout(() => {
closeMention();
}, 150);
}}
data-placeholder={placeholder || (isExpanded ? "输入你的创意" : "有什么可以帮您?")}
className={cn(
"w-full bg-transparent border-none outline-none text-foreground resize-none text-[13px] custom-scrollbar empty:before:content-[attr(data-placeholder)] empty:before:text-white/30 cursor-text",
isExpanded
? "px-2 min-h-[40px] leading-relaxed"
: "px-0 leading-normal"
)}
style={isExpanded ? {
maxHeight: '200px',
overflowY: 'auto',
wordWrap: 'break-word',
whiteSpace: 'pre-wrap'
} : {
overflowY: 'hidden',
overflowX: 'auto',
whiteSpace: 'nowrap',
wordWrap: 'normal'
}}
/>
{/* 候选浮层 */}
{mentionOpen && candidates.length > 0 && mentionPosition &&
typeof window !== 'undefined' &&
createPortal(
<div
className="fixed z-[9999] rounded-xl bg-black/85 border border-white/10 shadow-xl backdrop-blur-md text-xs text-foreground interaction-model-popover"
style={{
left: mentionPosition.left,
top: mentionPosition.top,
transform: 'translateY(-100%) translateY(-8px)',
maxWidth: 260,
minWidth: 200,
}}
>
{candidates.map(asset => {
const alias = asset.mentionAlias || asset.label || asset.id;
return (
<button
key={asset.id}
type="button"
onMouseDown={(e) => {
e.preventDefault();
applyMention(asset);
}}
className="w-full text-left px-3 py-1.5 hover:bg-white/10 flex items-center gap-2"
>
<div className={cn(
'w-7 h-7 rounded-full overflow-hidden border border-white/15 flex items-center justify-center bg-black/40 shrink-0'
)}>
{asset.type === 'video' ? (
<SecureVideo
src={asset.src}
className="w-full h-full object-cover"
muted
loop
autoPlay
playsInline
/>
) : asset.type === 'audio' ? (
<div className={cn(
'w-full h-full flex items-center justify-center',
asset.gender ? 'bg-black/40' : 'bg-cyan-500/20'
)}>
{asset.gender ? (
<UserCircle
size={18}
className={cn(
asset.gender === '男' || asset.gender === 'male'
? 'text-blue-400'
: 'text-pink-400'
)}
/>
) : (
<Music className="w-4 h-4 text-cyan-400" />
)}
</div>
) : (
<SecureImage
src={asset.src}
alt={alias}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="flex flex-col min-w-0">
<span className="truncate text-[12px]">{alias}</span>
<span className="text-[10px] text-foreground/50">
{asset.type === 'image' ? '图片素材' : asset.type === 'video' ? '视频素材' : '音频素材'}
</span>
</div>
</button>
);
})}
</div>,
document.body
)
}
</div>
);
};

View File

@@ -0,0 +1,94 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { Loader2, Copy } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Message, renderFormattedMessage } from '../../utils/chatUtils';
interface ChatDisplayProps {
messages: Message[];
isLoading: boolean;
}
export const ChatDisplay = ({ messages, isLoading }: ChatDisplayProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [copiedIndex, setCopiedIndex] = React.useState<number | null>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isLoading]);
const handleCopy = (text: string, index: number) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
});
};
if (messages.length === 0 && !isLoading) return null;
return (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-full left-0 w-full mb-3 max-h-[400px] overflow-y-auto custom-scrollbar p-4 flex flex-col gap-4 pointer-events-auto"
ref={scrollRef}
>
{messages.map((m, i) => {
if (m.role === 'model' && !m.text) return null;
return (
<div key={i} className={`flex w-full ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`flex flex-col max-w-[90%] gap-1 ${m.role === 'user' ? 'items-end' : 'items-start'}`}>
{m.role === 'model' && (
<div className="flex items-center gap-1.5 px-1 mb-0.5">
<span className="w-1 h-1 rounded-full bg-cyan-500/80"></span>
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">AI Assistant</span>
</div>
)}
<div className="group relative transition-all w-full">
<div
className={`
relative px-4 py-3 rounded-2xl shadow-sm border select-text cursor-text text-xs leading-relaxed
${m.role === 'user'
? 'bg-white/10 border-white/10 text-foreground rounded-tr-sm ml-4'
: 'bg-black/40 border-white/5 text-muted-foreground rounded-tl-sm w-full pr-8'
}
`}
>
{m.role === 'model' ? renderFormattedMessage(m.text) : <p className="whitespace-pre-wrap">{m.text}</p>}
{/* Copy Button */}
<button
onClick={() => handleCopy(m.text, i)}
className={`absolute top-2 right-2 p-1 rounded-full bg-black/50 hover:bg-black/80 border border-white/10 text-muted-foreground opacity-0 group-hover:opacity-100 transition-all z-10`}
title="复制"
>
{copiedIndex === i ? <span className="text-[9px] font-bold text-green-500"></span> : <Copy size={10} />}
</button>
</div>
</div>
</div>
</div>
);
})}
{isLoading && (
<div className="flex justify-start w-full animate-in fade-in slide-in-from-bottom-2">
<div className="flex flex-col gap-2 max-w-[85%]">
<span className="text-[10px] font-bold uppercase tracking-wider px-1 text-cyan-500/80">Thinking</span>
<div className="px-5 py-3 bg-black/40 border border-white/5 rounded-2xl rounded-tl-sm flex items-center gap-3 w-fit">
<Loader2 size={14} className="animate-spin text-cyan-500" />
<span className="text-xs font-medium tracking-wide text-muted-foreground">...</span>
</div>
</div>
</div>
)}
</motion.div>
);
};

View File

@@ -0,0 +1,163 @@
'use client';
import React, { memo, useCallback, useState } from 'react';
import { motion } from 'framer-motion';
import { Image as ImageIcon, Film, MessageSquare, Mic2, Music2, Maximize2, RotateCcw, ArrowRight, Loader2, X } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { GenerationType } from './types';
import { TabButton } from './TabButton';
import type { InputAsset } from '../../types/node';
import { AutoResizeTextarea } from './AutoResizeTextarea';
const TABS = [
{ id: 'text', icon: MessageSquare, label: '助手' },
{ id: 'image', icon: ImageIcon, label: '图片' },
{ id: 'video', icon: Film, label: '视频' },
{ id: 'audio', icon: Mic2, label: '音频' },
{ id: 'music', icon: Music2, label: '音乐' },
] as const;
const TabList = memo(({ activeTab, onTabChange, disabled }: { activeTab: GenerationType, onTabChange: (tab: GenerationType) => void, disabled?: boolean }) => {
return (
<div className="flex items-center gap-1 bg-white/5 p-1 rounded-full border border-white/5">
{TABS.map((tab) => (
<TabButton
key={tab.id}
isActive={activeTab === tab.id}
onClick={(e) => {
e.stopPropagation();
onTabChange(tab.id);
}}
icon={<tab.icon className="w-3.5 h-3.5" />}
label={tab.label}
disabled={disabled}
/>
))}
</div>
);
});
TabList.displayName = 'TabList';
export const CollapsedView = memo(({
inputText,
activeTab,
onTabChange,
onInputChange,
onSubmit,
onWorkflowSubmit,
onExpand,
onClearChat,
disableTabs = false,
inputAssets,
inputPlaceholder,
isGenerating = false,
canGenerate = true,
currentTaskId,
onCancel,
}: {
inputText: string;
activeTab: GenerationType;
onTabChange: (tab: GenerationType) => void;
onInputChange: (value: string) => void;
onSubmit: () => void;
onWorkflowSubmit?: () => void;
onExpand: () => void;
onClearChat: () => void;
isVisible?: boolean;
disableTabs?: boolean;
inputAssets?: InputAsset[];
inputPlaceholder?: string;
/** 与展开态一致:生成中时显示 Loader2有 taskId 时可点击取消 */
isGenerating?: boolean;
canGenerate?: boolean;
currentTaskId?: string;
onCancel?: () => void;
}) => {
const [isHoveringCancel, setIsHoveringCancel] = useState(false);
const showCancelOnHover = isGenerating && currentTaskId;
const handleExpandClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (activeTab === 'text') {
onClearChat();
} else {
onExpand();
}
}, [activeTab, onClearChat, onExpand]);
const handleSubmitClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (isGenerating && currentTaskId && onCancel) {
onCancel();
} else {
onSubmit();
}
}, [onSubmit, isGenerating, currentTaskId, onCancel]);
return (
<motion.div
key="collapsed"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
className="w-full h-full flex items-center px-2 gap-3 relative"
>
<TabList activeTab={activeTab} onTabChange={onTabChange} disabled={disableTabs} />
<div
className="flex-1 relative min-w-0 h-full cursor-text"
onClick={(e) => {
e.stopPropagation();
// 点击容器时也聚焦到编辑器
const editor = e.currentTarget.querySelector('[contenteditable]') as HTMLElement;
if (editor) {
editor.focus();
}
}}
>
<AutoResizeTextarea
value={inputText}
onChange={onInputChange}
onEnter={() => {
// 在折叠模式下Enter 直接提交(不支持 Shift+Enter 换行)
onSubmit();
}}
isExpanded={false}
placeholder={inputPlaceholder}
inputAssets={inputAssets}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={handleExpandClick}
className="flex items-center justify-center w-7 h-7 rounded-full text-foreground/50 hover:bg-white/10 hover:text-foreground transition-colors p-0 shrink-0"
title={activeTab === 'text' ? "重新开始对话" : "展开"}
>
{activeTab === 'text' ? <RotateCcw className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleSubmitClick}
disabled={!canGenerate && !(isGenerating && currentTaskId)}
onMouseEnter={() => showCancelOnHover && setIsHoveringCancel(true)}
onMouseLeave={() => setIsHoveringCancel(false)}
className="flex items-center justify-center bg-white/10 w-7 h-7 rounded-full text-foreground hover:bg-white/20 transition-colors p-0 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title={isGenerating && currentTaskId ? '取消' : '提交'}
>
{isGenerating && currentTaskId && isHoveringCancel ? (
<X className="w-4 h-4" />
) : isGenerating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ArrowRight className="w-4 h-4" />
)}
</Button>
</motion.div>
);
});
CollapsedView.displayName = 'CollapsedView';

View File

@@ -0,0 +1,288 @@
import React, { memo, useMemo, useState } from 'react';
import { Plus, History, BookOpen, Wand2, ArrowRight, Loader2, X } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { cn } from '@/utils';
import { ActionButton, IconButton } from './Selectors';
import { GenerationSettings, GenerationType } from './types';
import {
ModelSelector, ResolutionSelector, ImageResolutionSelector, AspectRatioSelector,
DurationSelector, CountSelector, StyleSelector,
CameraControlSelector, ShotTypeToggle, AudioToggle, VoiceSelector
} from './Selectors';
import { useStyles } from '@lib/hooks/useConfig';
import { StyleConfig } from '@/types';
import type { ModelConfig } from '@/types/model';
interface InputFooterProps {
isGenerating: boolean;
canGenerate: boolean;
onGenerate: () => void;
/** 当前任务 ID仅编辑态生图/生视频时有),用于取消 */
currentTaskId?: string;
/** 取消当前任务(仅当 currentTaskId 存在时有效) */
onCancel?: () => void;
activeTab: GenerationType;
settings: GenerationSettings;
availableModels: Record<string, any>;
lyricsModels?: Record<string, ModelConfig>;
musicModels?: Record<string, ModelConfig>;
onUpdateSettings: (s: GenerationSettings) => void;
isEditMode?: boolean;
editNodeType?: string;
}
export const InputFooter = memo(({
isGenerating, canGenerate, onGenerate,
currentTaskId, onCancel,
activeTab, settings, availableModels, onUpdateSettings,
lyricsModels = {}, musicModels = {},
isEditMode = false, editNodeType
}: InputFooterProps) => {
const isVideo = activeTab === 'video';
const isAudio = activeTab === 'audio';
const isMusic = activeTab === 'music';
const isImage = activeTab === 'image';
const { data: stylesData } = useStyles();
const styles = (stylesData?.styles || []) as StyleConfig[];
// Get current model config for capability-based controls
const currentModelConfig = useMemo(() => {
if (!settings.model || !availableModels) return null;
return availableModels[settings.model];
}, [settings.model, availableModels]);
const capabilities = currentModelConfig?.capabilities;
const [isHoveringCancel, setIsHoveringCancel] = useState(false);
const showCancelOnHover = isGenerating && currentTaskId;
// Get voices for audio models
const voices = useMemo(() => {
if (!isAudio || !currentModelConfig?.voices) return [];
return (currentModelConfig.voices as any[]).map((v: any) => ({
value: v.value || v.id,
label: v.label || v.name || v.value || v.id,
gender: v.gender,
desc: v.desc
}));
}, [isAudio, currentModelConfig]);
return (
<div className="mt-auto flex items-center justify-between pt-2">
{/* Left: Parameters - Expanded view contains ALL parameters */}
<div className="flex items-center gap-2">
{isMusic && !isEditMode ? (
<>
<div className="flex items-center gap-1">
<span
onClick={() => {
const nextLyrics = !(settings.musicGenerateLyrics ?? false);
const currentMusic = settings.musicGenerateMusic ?? true;
if (!nextLyrics && !currentMusic) return;
onUpdateSettings({
...settings,
musicGenerateLyrics: nextLyrics,
model: currentMusic
? (settings.musicModel || settings.model)
: (settings.lyricsModel || settings.model),
});
}}
className={cn(
"text-[10px] px-2 py-1 rounded-full cursor-pointer transition-colors",
(settings.musicGenerateLyrics ?? false)
? "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30"
: "text-muted-foreground hover:text-foreground hover:bg-white/5 border border-transparent"
)}
>
</span>
<ModelSelector
selectedModelId={settings.lyricsModel || ''}
models={lyricsModels}
onSelect={(id) => onUpdateSettings({
...settings,
lyricsModel: id,
musicGenerateLyrics: true,
musicGenerateMusic: false,
model: id
})}
/>
</div>
<div className="flex items-center gap-1">
<span
onClick={() => {
const currentLyrics = settings.musicGenerateLyrics ?? false;
const nextMusic = !(settings.musicGenerateMusic ?? true);
if (!currentLyrics && !nextMusic) return;
onUpdateSettings({
...settings,
musicGenerateMusic: nextMusic,
model: nextMusic
? (settings.musicModel || settings.model)
: (settings.lyricsModel || settings.model),
});
}}
className={cn(
"text-[10px] px-2 py-1 rounded-full cursor-pointer transition-colors",
(settings.musicGenerateMusic ?? true)
? "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30"
: "text-muted-foreground hover:text-foreground hover:bg-white/5 border border-transparent"
)}
>
</span>
<ModelSelector
selectedModelId={settings.musicModel || ''}
models={musicModels}
onSelect={(id) => onUpdateSettings({
...settings,
musicModel: id,
musicGenerateMusic: true,
musicGenerateLyrics: false,
model: id
})}
/>
</div>
</>
) : (
<>
<ModelSelector
selectedModelId={settings.model}
models={availableModels}
onSelect={(id) => onUpdateSettings({ ...settings, model: id })}
/>
<div className="h-4 w-[1px] bg-white/10 mx-1" />
</>
)}
{/* Image-specific controls: Resolution, Aspect Ratio, Style */}
{isImage && (
<>
<ImageResolutionSelector
resolution={settings.resolution || '1K'}
onSelect={(r) => onUpdateSettings({ ...settings, resolution: r })}
selectedModelId={settings.model}
models={availableModels}
/>
<AspectRatioSelector
aspectRatio={settings.aspectRatio}
isVideo={false}
onSelect={(r) => onUpdateSettings({ ...settings, aspectRatio: r })}
selectedModelId={settings.model}
models={availableModels}
resolution={settings.resolution}
/>
<StyleSelector
selectedStyleId={settings.styleId}
styles={styles}
onSelect={(id) => onUpdateSettings({ ...settings, styleId: id || undefined })}
selectedModelId={settings.model}
models={availableModels}
/>
</>
)}
{/* Video-specific controls: Resolution, Aspect Ratio, Duration */}
{isVideo && (
<>
<ResolutionSelector
resolution={settings.resolution}
onSelect={(r) => onUpdateSettings({ ...settings, resolution: r })}
selectedModelId={settings.model}
models={availableModels}
/>
<AspectRatioSelector
aspectRatio={settings.aspectRatio}
isVideo={true}
onSelect={(r) => onUpdateSettings({ ...settings, aspectRatio: r })}
selectedModelId={settings.model}
models={availableModels}
resolution={settings.resolution}
/>
<DurationSelector
duration={settings.duration}
onSelect={(d) => onUpdateSettings({ ...settings, duration: d })}
selectedModelId={settings.model}
models={availableModels}
/>
</>
)}
{/* Capability-based dynamic controls (video only) */}
{isVideo && capabilities?.supportsCameraControl && (
<CameraControlSelector
cameraControl={settings.cameraControl}
onSelect={(v) => onUpdateSettings({ ...settings, cameraControl: v })}
/>
)}
{isVideo && capabilities?.supportsShotType && (
<ShotTypeToggle
shotType={settings.shotType}
onToggle={(v) => onUpdateSettings({ ...settings, shotType: v })}
/>
)}
{isVideo && capabilities?.supportsAudio && (
<AudioToggle
enableAudio={settings.enableAudio}
onToggle={(v) => onUpdateSettings({ ...settings, enableAudio: v })}
/>
)}
{/* Audio-specific: voice selector */}
{isAudio && voices.length > 0 && (
<VoiceSelector
voice={settings.voice}
voices={voices}
onSelect={(v) => onUpdateSettings({ ...settings, voice: v })}
/>
)}
{isMusic && (
<span className="text-[10px] text-muted-foreground px-2">
{(settings.musicGenerateLyrics ?? false) && (settings.musicGenerateMusic ?? true)
? '歌词 -> 音乐'
: (settings.musicGenerateLyrics ?? false)
? '仅歌词'
: '仅音乐'}
</span>
)}
{(isImage || isVideo) && (
<CountSelector
count={settings.count}
isVideo={isVideo}
onSelect={(c) => onUpdateSettings({ ...settings, count: c })}
selectedModelId={settings.model}
models={availableModels}
/>
)}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 mr-2 text-foreground/40">
<IconButton icon={<Wand2 className="w-4 h-4" />} />
</div>
<Button
onClick={isGenerating && currentTaskId ? onCancel : onGenerate}
disabled={!canGenerate && !(isGenerating && currentTaskId)}
title={isGenerating && currentTaskId ? '取消' : undefined}
onMouseEnter={() => showCancelOnHover && setIsHoveringCancel(true)}
onMouseLeave={() => setIsHoveringCancel(false)}
className="bg-white/10 hover:bg-white/20 text-foreground border-none w-8 h-8 rounded-full p-0 flex items-center justify-center transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating && currentTaskId && isHoveringCancel ? (
<X className="w-4 h-4" />
) : isGenerating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ArrowRight className="w-4 h-4" />
)}
</Button>
</div>
</div>
);
});
InputFooter.displayName = 'InputFooter';

View File

@@ -0,0 +1,87 @@
'use client';
import React, { memo } from 'react';
import { Music, UserCircle } from 'lucide-react';
import { cn } from '@/utils';
import type { InputAsset } from '../../types/node';
import { SecureImage } from '@/components/common/SecureImage';
import { SecureVideo } from '../../nodes/components/SecureVideo';
export interface ReferencedAssetsChipsProps {
/** 当前已引用的素材列表(由父组件根据 prompt 文本解析得到) */
assets: InputAsset[];
className?: string;
}
/**
* 已引用素材 chips在输入框下方展示当前 prompt 中 @ 引用的素材,
* 与普通输入视觉区分,且不参与输入框尺寸计算。
*/
export const ReferencedAssetsChips = memo(({ assets, className }: ReferencedAssetsChipsProps) => {
if (assets.length === 0) return null;
return (
<div
className={cn(
'mt-1 px-2 flex flex-wrap gap-1 text-[11px] text-foreground/80',
className
)}
>
{assets.map((asset) => {
const alias = asset.mentionAlias || asset.label || asset.id;
return (
<div
key={asset.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-white/5 border border-white/10"
>
<div
className={cn(
'w-4 h-4 rounded-full overflow-hidden border border-white/20 flex items-center justify-center bg-black/40 shrink-0'
)}
>
{asset.type === 'video' ? (
<SecureVideo
src={asset.src}
className="w-full h-full object-cover"
muted
loop
autoPlay
playsInline
/>
) : asset.type === 'audio' ? (
<div
className={cn(
'w-full h-full flex items-center justify-center',
asset.gender ? 'bg-black/40' : 'bg-cyan-500/20'
)}
>
{asset.gender ? (
<UserCircle
size={14}
className={cn(
asset.gender === '男' || asset.gender === 'male'
? 'text-blue-400'
: 'text-pink-400'
)}
/>
) : (
<Music size={14} className="text-cyan-300" />
)}
</div>
) : (
<SecureImage
src={asset.src}
alt={alias}
className="w-full h-full object-cover"
/>
)}
</div>
<span className="truncate max-w-[80px]">@{alias}</span>
</div>
);
})}
</div>
);
});
ReferencedAssetsChips.displayName = 'ReferencedAssetsChips';

View File

@@ -0,0 +1,559 @@
import React from 'react';
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Sparkles, ChevronDown, Check, Scaling, Monitor, Clock, Layers, Aperture, Film, Volume2, VolumeX, Mic2, UserCircle, Image as ImageIcon
} from 'lucide-react';
import { cn } from '@/utils';
// Re-export from new modular structure
export { ModelSelector } from './selectors/ModelSelector';
export { StyleSelector } from './selectors/StyleSelector';
export { AssetSelector } from './selectors/AssetSelector';
const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
import type { ModelConfig } from '@/types/model';
import { useVideoGenerationOptions } from '@/lib/hooks/useGenerationConfig';
import { getResolutionLevels, getAspectRatiosForResolution } from '@/lib/utils/modelResolutionUtils';
import {
IMAGE_ASPECT_RATIOS, VIDEO_ASPECT_RATIOS, VIDEO_RESOLUTIONS, IMAGE_RESOLUTIONS,
IMAGE_COUNTS, VIDEO_COUNTS
} from '../../constants';
export function TooltipButton({ icon, active, onClick, label }: { icon: React.ReactNode, active: boolean, onClick: () => void, label: string }) {
return (
<div className="relative group">
<button
onClick={onClick}
className={cn(
"p-2.5 rounded-full transition-all duration-200 w-auto h-auto",
active
? "bg-white/10 text-foreground shadow-inner hover:bg-white/15"
: "text-foreground/40 hover:text-foreground hover:bg-white/5"
)}
>
{icon}
</button>
<div className="absolute left-14 top-1/2 -translate-y-1/2 bg-black/90 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none border border-white/10">
{label}
</div>
</div>
);
}
export function Badge({ icon, text }: { icon?: React.ReactNode, text: string }) {
return (
<div className="flex items-center gap-1.5 bg-black/30 border border-white/5 px-2.5 py-1 rounded-full text-[10px] text-white/60 hover:text-white/90 hover:border-white/10 transition-colors cursor-pointer select-none">
{icon}
<span>{text}</span>
</div>
);
}
export function ActionButton({ icon, label }: { icon: React.ReactNode, label: string }) {
return (
<button className="flex items-center gap-1.5 bg-white/5 hover:bg-white/10 border border-white/5 hover:border-white/10 px-3 py-1.5 rounded-full text-[10px] text-white/70 hover:text-white transition-all h-auto">
{icon}
<span>{label}</span>
</button>
);
}
export function IconButton({ icon }: { icon: React.ReactNode }) {
return (
<button className="p-2 hover:bg-white/10 rounded-full transition-colors text-current w-auto h-auto">
{icon}
</button>
);
}
export function AspectRatioSelector({
aspectRatio,
isVideo,
onSelect,
selectedModelId,
models,
resolution
}: {
aspectRatio: string,
isVideo: boolean,
onSelect: (ratio: string) => void,
selectedModelId?: string,
models?: Record<string, ModelConfig>,
resolution?: string
}) {
const ratios = React.useMemo(() => {
if (models && selectedModelId && resolution) {
const model = models[selectedModelId];
if (model?.resolutions) {
return getAspectRatiosForResolution(resolution);
}
}
return isVideo ? VIDEO_ASPECT_RATIOS : IMAGE_ASPECT_RATIOS;
}, [isVideo, models, selectedModelId, resolution]);
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<Scaling size={10} />
<span>{aspectRatio}</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-24 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="flex flex-col gap-0.5">
{ratios.map((ratio) => (
<div
key={ratio}
onClick={() => onSelect(ratio)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
aspectRatio === ratio
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<span>{ratio}</span>
{aspectRatio === ratio && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function CountSelector({
count,
isVideo,
onSelect,
selectedModelId,
models
}: {
count: number,
isVideo: boolean,
onSelect: (count: number) => void,
selectedModelId?: string,
models?: Record<string, ModelConfig>
}) {
const counts = React.useMemo(() => {
if (models && selectedModelId) {
const model = models[selectedModelId];
if (model?.counts) {
const { min, max } = model.counts;
if (min != null && max != null) {
const items: number[] = [];
for (let i = min; i <= max; i++) items.push(i);
return items;
}
}
}
return isVideo ? VIDEO_COUNTS : IMAGE_COUNTS;
}, [models, selectedModelId, isVideo]);
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<Layers size={10} />
<span>{count}</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-20 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="flex flex-col gap-0.5">
{counts.map((c) => (
<div
key={c}
onClick={() => onSelect(c)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
count === c
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<span>{c}</span>
{count === c && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function ResolutionSelector({
resolution,
onSelect,
selectedModelId,
models
}: {
resolution: string,
onSelect: (res: string) => void,
selectedModelId?: string,
models?: Record<string, ModelConfig>
}) {
const resolutions = React.useMemo(() => {
if (models && selectedModelId) {
const model = models[selectedModelId];
if (model?.resolutions) {
return getResolutionLevels(selectedModelId);
}
}
return VIDEO_RESOLUTIONS;
}, [models, selectedModelId]);
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<Monitor size={10} />
<span>{resolution}</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-24 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="flex flex-col gap-0.5">
{resolutions.map((res: string) => (
<div
key={res}
onClick={() => onSelect(res)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
resolution === res
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<span>{res}</span>
{resolution === res && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function ImageResolutionSelector({
resolution,
onSelect,
selectedModelId,
models
}: {
resolution: string,
onSelect: (res: string) => void,
selectedModelId?: string,
models?: Record<string, ModelConfig>
}) {
const resolutions = React.useMemo(() => {
if (models && selectedModelId) {
const model = models[selectedModelId];
if (model?.resolutions) {
return getResolutionLevels(selectedModelId);
}
}
return IMAGE_RESOLUTIONS;
}, [models, selectedModelId]);
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<ImageIcon size={10} aria-hidden />
<span>{resolution}</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-24 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="flex flex-col gap-0.5">
{resolutions.map((res: string) => (
<div
key={res}
onClick={() => onSelect(res)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
resolution === res
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<span>{res}</span>
{resolution === res && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function DurationSelector({
duration,
onSelect,
selectedModelId,
models
}: {
duration: string,
onSelect: (dur: string) => void,
selectedModelId?: string,
models?: Record<string, ModelConfig>
}) {
const { config: videoConfig } = useVideoGenerationOptions();
const durations = React.useMemo(() => {
if (models && selectedModelId) {
const model = models[selectedModelId];
if (model?.durations) {
const cfg = model.durations;
if (cfg.values && Array.isArray(cfg.values)) {
return cfg.values.map((d: number) => `${d}s`);
}
if (cfg.min != null && cfg.max != null) {
const items: string[] = [];
for (let i = cfg.min; i <= cfg.max; i++) {
items.push(`${i}s`);
}
return items;
}
if (Array.isArray(cfg)) {
return cfg.map((d: string | number) => String(d).endsWith('s') ? String(d) : `${d}s`);
}
}
}
// 默认值
return ['5s', '10s', '15s'];
}, [models, selectedModelId, videoConfig]);
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<Clock size={10} />
<span>{duration}</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-20 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto custom-scrollbar">
{durations.map((dur: string) => (
<div
key={dur}
onClick={() => onSelect(dur)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
duration === dur
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<span>{dur}</span>
{duration === dur && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
const CAMERA_CONTROLS = [
{ value: 'none', label: '自动' },
{ value: 'down_back', label: '下压后退' },
{ value: 'forward_up', label: '推进上仰' },
{ value: 'right_turn_forward', label: '右旋推进' },
{ value: 'left_turn_forward', label: '左旋推进' },
];
export function CameraControlSelector({
cameraControl,
onSelect
}: {
cameraControl?: string,
onSelect: (value: string | undefined) => void
}) {
const currentLabel = CAMERA_CONTROLS.find(c => c.value === cameraControl)?.label || '运镜';
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<Aperture size={10} />
<span>{currentLabel}</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-28 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="flex flex-col gap-0.5">
{CAMERA_CONTROLS.map((opt) => (
<div
key={opt.value}
onClick={() => onSelect(opt.value === 'none' ? undefined : opt.value)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
(cameraControl || 'none') === opt.value
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<span>{opt.label}</span>
{(cameraControl || 'none') === opt.value && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function ShotTypeToggle({
shotType,
onToggle
}: {
shotType?: 'single' | 'multi',
onToggle: (value: 'single' | 'multi') => void
}) {
const isMulti = shotType === 'multi';
return (
<div
onClick={() => onToggle(isMulti ? 'single' : 'multi')}
className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400"
title={isMulti ? '多镜头叙事' : '单镜头'}
>
<Film size={10} />
<span>{isMulti ? '多镜头' : '单镜头'}</span>
</div>
);
}
export function AudioToggle({
enableAudio,
onToggle
}: {
enableAudio?: boolean,
onToggle: (value: boolean) => void
}) {
const isEnabled = enableAudio !== false;
return (
<div
onClick={() => onToggle(!isEnabled)}
className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400"
title={isEnabled ? '有声视频' : '无声视频'}
>
{isEnabled ? <Volume2 size={10} /> : <VolumeX size={10} />}
<span>{isEnabled ? '有声' : '静音'}</span>
</div>
);
}
export function VoiceSelector({
voice,
voices,
onSelect
}: {
voice?: string,
voices: { value: string; label: string; gender?: string; desc?: string }[],
onSelect: (value: string) => void
}) {
const currentVoice = voices.find(v => v.value === voice);
const displayName = currentVoice?.label || '默认音色';
if (!voices.length) return null;
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<Mic2 size={10} />
<span className="truncate max-w-[80px]">{displayName}</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-44 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="max-h-[280px] overflow-y-auto custom-scrollbar flex flex-col gap-0.5">
{voices.map((v) => (
<div
key={v.value}
onClick={() => onSelect(v.value)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] cursor-pointer transition-all group",
voice === v.value
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
{v.gender && (
<span className="shrink-0 flex items-center justify-center" title={v.gender === '男' || v.gender === 'male' ? '男' : '女'}>
<UserCircle
size={16}
className={cn(
v.gender === '男' || v.gender === 'male'
? "text-blue-400"
: "text-pink-400"
)}
/>
</span>
)}
<span className="font-medium truncate">{v.label}</span>
</div>
{v.desc && (
<p className="text-[9px] text-muted-foreground/60 line-clamp-2 leading-relaxed">
{v.desc}
</p>
)}
</div>
{voice === v.value && (
<Check size={12} className="text-blue-400 shrink-0 mt-0.5" />
)}
</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,49 @@
import React, { memo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from "@/components/ui/button";
import { cn } from '@/utils';
import { styles } from '@/styles/constants';
export const TabButton = memo(({
isActive,
onClick,
icon,
label,
disabled
}: {
isActive: boolean;
onClick: (e: React.MouseEvent) => void;
icon: React.ReactNode;
label: string;
disabled?: boolean;
}) => (
<Button
variant="ghost"
size="sm"
onClick={onClick}
disabled={disabled && !isActive}
className={cn(
`h-7 ${styles.roundedFull} ${styles.buttonGhost}`,
isActive ? `${styles.buttonActive} px-3` : "w-7 px-0",
disabled && !isActive && "opacity-30 pointer-events-none"
)}
title={label}
aria-label={label}
>
{icon}
<AnimatePresence>
{isActive && (
<motion.span
initial={{ width: 0, opacity: 0, marginLeft: 0 }}
animate={{ width: "auto", opacity: 1, marginLeft: 6 }}
exit={{ width: 0, opacity: 0, marginLeft: 0 }}
className="overflow-hidden whitespace-nowrap text-[10px] font-medium"
>
{label}
</motion.span>
)}
</AnimatePresence>
</Button>
));
TabButton.displayName = 'TabButton';

View File

@@ -0,0 +1,23 @@
import { GenerationSettings, Role } from './types';
export const MOCK_ROLES: Role[] = [
{ id: '1', name: 'Girl', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix' },
{ id: '2', name: 'Cat', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mimi' },
{ id: '3', name: 'Dog', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Buddy' },
{ id: '4', name: 'Boy', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Jack' },
];
export const DEFAULT_SETTINGS: GenerationSettings = {
resolution: '1K',
aspectRatio: '16:9',
duration: '5s',
count: 1,
model: '',
inspiration: false,
styleId: undefined,
lyricsModel: '',
musicModel: '',
musicTarget: 'music',
musicGenerateLyrics: false,
musicGenerateMusic: true
};

View File

@@ -0,0 +1,196 @@
import type { InputAsset } from '../../types/node';
import { DefaultService } from '@lib/api';
export type MentionToken =
| { type: 'text'; text: string }
| { type: 'chip'; asset: InputAsset };
const signedUrlCache = new Map<string, string>();
async function getSignedUrl(url: string): Promise<string | null> {
if (!url || !url.startsWith('http')) return null;
if (signedUrlCache.has(url)) return signedUrlCache.get(url)!;
try {
const response = await DefaultService.signUrlEndpoint({ url });
const next = (response as any)?.data?.url;
if (next && typeof next === 'string' && next !== url) {
signedUrlCache.set(url, next);
return next;
}
} catch {
// ignore signing failures and keep original source
}
return null;
}
export function parseMentionTokens(text: string, inputAssets?: InputAsset[]): MentionToken[] {
if (!inputAssets || !text) {
return text ? [{ type: 'text', text }] : [];
}
const candidates = inputAssets.filter((asset) => Boolean(asset.mentionAlias || asset.label));
if (candidates.length === 0) {
return [{ type: 'text', text }];
}
const result: MentionToken[] = [];
let i = 0;
let buffer = '';
while (i < text.length) {
let matched: { asset: InputAsset; length: number } | null = null;
if (text[i] === '@') {
for (const asset of candidates) {
const alias = asset.mentionAlias || asset.label!;
const mentionText = `@${alias}`;
if (text.slice(i, i + mentionText.length) === mentionText) {
const nextChar = text[i + mentionText.length];
const isValidEnd = !nextChar || !/[a-zA-Z0-9]/.test(nextChar);
if (isValidEnd) {
matched = { asset, length: mentionText.length };
break;
}
}
}
}
if (matched) {
if (buffer) {
result.push({ type: 'text', text: buffer });
buffer = '';
}
result.push({ type: 'chip', asset: matched.asset });
i += matched.length;
} else {
buffer += text[i];
i += 1;
}
}
if (buffer) {
result.push({ type: 'text', text: buffer });
}
return result;
}
export function createMentionChipElement(asset: InputAsset, isExpanded?: boolean): HTMLSpanElement {
const alias = asset.mentionAlias || asset.label || asset.id;
const chip = document.createElement('span');
chip.contentEditable = 'false';
chip.className = 'mention-chip';
chip.setAttribute('data-asset-id', asset.id);
chip.setAttribute('data-asset-type', asset.type);
chip.style.cssText = `
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
margin: 0 1px;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
vertical-align: middle;
${!isExpanded ? 'transform: scale(0.9);' : ''}
`;
const thumbnail = document.createElement('span');
thumbnail.style.cssText = `
width: 16px;
height: 16px;
border-radius: 9999px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.4);
flex-shrink: 0;
position: relative;
`;
if (asset.type === 'image' || asset.type === 'video') {
const isVideo = asset.type === 'video';
const mediaElement = document.createElement(isVideo ? 'video' : 'img');
const isLocal = asset.src.startsWith('/files/')
|| asset.src.startsWith('http://localhost')
|| asset.src.startsWith('https://localhost')
|| asset.src.startsWith('data:');
if (isVideo) {
(mediaElement as HTMLVideoElement).muted = true;
(mediaElement as HTMLVideoElement).loop = true;
(mediaElement as HTMLVideoElement).autoplay = true;
(mediaElement as HTMLVideoElement).playsInline = true;
}
// For remote URLs, retry once with freshly signed URL on load failure.
if (!isLocal) {
let retried = false;
mediaElement.addEventListener('error', async () => {
if (retried) return;
retried = true;
const signed = await getSignedUrl(asset.src);
if (signed) {
if (isVideo) {
const video = mediaElement as HTMLVideoElement;
video.src = signed;
video.load();
} else {
(mediaElement as HTMLImageElement).src = signed;
}
}
});
}
if (isVideo) {
(mediaElement as HTMLVideoElement).src = asset.src;
} else {
(mediaElement as HTMLImageElement).src = asset.src;
}
mediaElement.style.cssText = 'width: 100%; height: 100%; object-fit: cover; display: block;';
thumbnail.appendChild(mediaElement);
} else if (asset.type === 'audio') {
const iconWrapper = document.createElement('span');
iconWrapper.style.cssText = `
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
${asset.gender ? 'background-color: rgba(0, 0, 0, 0.4);' : 'background-color: rgba(6, 182, 212, 0.2);'}
`;
iconWrapper.innerHTML = asset.gender
? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="${asset.gender === '男' || asset.gender === 'male' ? 'text-blue-400' : 'text-pink-400'}"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`
: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: rgb(103, 232, 249);"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>';
thumbnail.appendChild(iconWrapper);
}
chip.appendChild(thumbnail);
const label = document.createElement('span');
label.style.cssText = `
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80px;
font-size: 11px;
color: currentColor;
`;
label.textContent = alias;
chip.appendChild(label);
chip.setAttribute('data-mention-text', `@${alias}`);
return chip;
}
export function filterMentionCandidates(inputAssets: InputAsset[] | undefined, mentionQuery: string): InputAsset[] {
return (inputAssets || []).filter((asset) => {
const alias = asset.mentionAlias || asset.label || asset.id;
if (!alias) return false;
if (!mentionQuery) return true;
return alias.includes(mentionQuery);
});
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Image as ImageIcon, ChevronDown, Check, X } from 'lucide-react';
import { cn } from '@/utils';
import { InputAsset } from '../../../types/node';
interface AssetSelectorProps {
isExpanded: boolean;
selectedAssets: string[];
onToggleAsset: (id: string) => void;
inputAssets: InputAsset[];
}
export function AssetSelector({
isExpanded,
selectedAssets,
onToggleAsset,
inputAssets
}: AssetSelectorProps) {
if (!isExpanded || inputAssets.length === 0) return null;
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group pointer-events-auto">
<ImageIcon className="w-3 h-3 group-hover:text-cyan-400 transition-colors" />
<span className="truncate max-w-[80px]">
({inputAssets.length})
</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-56 p-2 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="start"
sideOffset={10}
>
<div className="max-h-[280px] overflow-y-auto custom-scrollbar flex flex-col gap-1">
{inputAssets.map((asset) => {
const isSelected = selectedAssets.includes(asset.id);
return (
<div
key={asset.id}
onClick={() => onToggleAsset(asset.id)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
isSelected
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<div className="flex items-center gap-2.5 overflow-hidden">
{asset.thumbnail ? (
<img
src={asset.thumbnail}
alt={asset.name}
className="w-8 h-8 rounded-md object-cover"
/>
) : (
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-white/5">
<ImageIcon size={14} />
</div>
)}
<div className="flex flex-col truncate">
<span className="truncate">{asset.name}</span>
<span className="text-[9px] text-foreground/30 font-normal">
{asset.type}
</span>
</div>
</div>
{isSelected && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Sparkles, ChevronDown, Check } from 'lucide-react';
import { cn } from '@/utils';
import type { ModelConfig } from '@/types/model';
const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
export function ModelSelector({
selectedModelId,
models,
onSelect
}: {
selectedModelId: string,
models: Record<string, ModelConfig>,
onSelect: (id: string) => void
}) {
React.useEffect(() => {
console.log('[ModelSelector] models:', models, 'count:', Object.keys(models).length);
}, [models]);
const selectedModel = models[selectedModelId];
const SelectedIcon = selectedModel?.icon || Sparkles;
const sortedModels = React.useMemo(() => {
const values = Object.values(models);
console.log('[ModelSelector] sortedModels values:', values.length);
return values.sort((a, b) => {
if (a.is_default && !b.is_default) return -1;
if (!a.is_default && b.is_default) return 1;
return a.name.localeCompare(b.name);
});
}, [models]);
if (!sortedModels.length) {
console.log('[ModelSelector] No models available, rendering placeholder');
return (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-bold text-muted-foreground">
<Sparkles className="w-3 h-3" />
<span>...</span>
</div>
);
}
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<SelectedIcon className="w-3 h-3 group-hover:text-cyan-400 transition-colors" />
<span className="truncate max-w-[100px]">
{selectedModel?.name || "Select Model"}
</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="start"
sideOffset={10}
>
<div className="max-h-[280px] overflow-y-auto custom-scrollbar flex flex-col gap-0.5">
{sortedModels.map((m: ModelConfig) => {
const isSelected = selectedModelId === m.id;
const Icon = m.icon;
return (
<div
key={m.id}
onClick={() => onSelect(m.id)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
isSelected
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<div className="flex items-center gap-2.5 overflow-hidden">
<div className={cn(
"w-5 h-5 rounded-md flex items-center justify-center transition-colors",
isSelected ? "bg-blue-500/20" : "bg-white/5 group-hover:bg-white/10"
)}>
{Icon ? <Icon size={12} /> : <Sparkles size={12} />}
</div>
<div className="flex flex-col truncate">
<span className="truncate">{m.name}</span>
<div className="flex items-center gap-1">
<span className="text-[9px] text-foreground/30 font-normal truncate">
{m.provider_name || capitalize(m.provider)}
</span>
<span className="text-[9px] px-1 py-0.5 rounded bg-white/10 text-foreground/50 uppercase">
{m.type}
</span>
</div>
</div>
</div>
{isSelected && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Palette, ChevronDown, Check } from 'lucide-react';
import { cn } from '@/utils';
import { StyleConfig } from '@/types';
import type { ModelConfig } from '@/types/model';
import { SecureImage } from '@/components/common/SecureImage';
export function StyleSelector({
selectedStyleId,
styles,
onSelect,
selectedModelId,
models
}: {
selectedStyleId?: string,
styles: StyleConfig[],
onSelect: (id: string) => void,
selectedModelId?: string,
models?: Record<string, ModelConfig>
}) {
const selectedStyle = styles.find(s => s.id === selectedStyleId);
const availableStyles = React.useMemo(() => {
if (!models || !selectedModelId) {
return styles.filter(s => s.type !== 'lora');
}
const currentModelConfig = models[selectedModelId];
if (!currentModelConfig) {
return styles.filter(s => s.type !== 'lora');
}
return styles.filter(style => {
if (style.type === 'lora') {
if (!currentModelConfig.capabilities?.supportsLora) return false;
return style.lora?.base_model === currentModelConfig.id;
}
return true;
});
}, [styles, selectedModelId, models]);
if (availableStyles.length === 0) return null;
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
<Palette className="w-3 h-3 group-hover:text-cyan-400 transition-colors" />
<span className="truncate max-w-[80px]">
{selectedStyle?.name || "风格"}
</span>
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent
className="w-48 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
side="top"
align="center"
sideOffset={10}
>
<div className="max-h-[200px] overflow-y-auto custom-scrollbar flex flex-col gap-0.5">
<div
onClick={() => onSelect('')}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
!selectedStyleId
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-md flex items-center justify-center bg-white/5">
<Palette size={12} />
</div>
<span>使</span>
</div>
{!selectedStyleId && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
{availableStyles.map((style) => (
<div
key={style.id}
onClick={() => onSelect(style.id)}
className={cn(
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
selectedStyleId === style.id
? "bg-blue-500/10 text-blue-400"
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
)}
>
<div className="flex items-center gap-2 overflow-hidden">
{style.imageUrl && (
<SecureImage
src={style.imageUrl}
alt={style.name}
className="w-5 h-5 rounded-md overflow-hidden object-cover"
/>
)}
{!style.imageUrl && (
<div className="w-5 h-5 rounded-md flex items-center justify-center bg-white/5">
<Palette size={12} />
</div>
)}
<span className="truncate">{style.name}</span>
</div>
{selectedStyleId === style.id && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,3 @@
export { ModelSelector } from './ModelSelector';
export { StyleSelector } from './StyleSelector';
export { AssetSelector } from './AssetSelector';

View File

@@ -0,0 +1,41 @@
import { InputAsset } from '../../types/node';
import type { AssetMentionMapping } from '../../utils';
export interface GenerationSettings {
resolution: string;
aspectRatio: string;
duration: string;
count: number;
model: string;
inspiration: boolean;
styleId?: string;
shotType?: 'single' | 'multi';
enableAudio?: boolean;
cameraControl?: string;
voice?: string;
musicPrompt?: string;
lyricsModel?: string;
musicModel?: string;
musicTarget?: 'lyrics' | 'music';
musicGenerateLyrics?: boolean;
musicGenerateMusic?: boolean;
}
export type GenerationType = 'video' | 'image' | 'text' | 'audio' | 'music';
export interface InteractionInputProps {
onGenerate?: (data: {
type: GenerationType;
prompt: string;
settings: GenerationSettings;
inputAssets?: InputAsset[];
/** prompt 中使用 @别名 引用到的素材映射信息 */
assetMappings?: AssetMentionMapping[];
}) => Promise<void>;
}
export interface Role {
id: string;
name: string;
avatar: string;
}