feat: 实现AI画布工具并隐藏,设置项目为首页

- 新增完整的AI画布工具系统
  - 可视化节点编辑器,支持拖拽连线
  - 多种节点类型:文本输入、图片生成、视频生成等
  - 智能连接验证和数据流转换
  - 异步处理引擎,支持进度追踪和取消
  - 批量处理系统,支持并发处理
  - AI服务集成框架,支持多种AI API

- 用户体验优化
  - 智能弹框定位,防止被遮挡
  - 节点删除功能(悬停删除按钮 + 键盘快捷键)
  - 通知系统和错误处理
  - 快速开始模板
  - 键盘快捷键支持

- 界面调整
  - 暂时隐藏AI画布,保留代码
  - 设置项目列表为首页
  - 简化导航栏结构
This commit is contained in:
imeepos
2025-08-07 10:12:46 +08:00
parent 365e2c4615
commit 7d8b8a3de1
33 changed files with 5523 additions and 0 deletions

View File

@@ -34,6 +34,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.1",
"react-window": "^1.8.11",
"reactflow": "^11.11.4",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"zustand": "^4.4.7"

View File

@@ -2,6 +2,67 @@
@tailwind components;
@tailwind utilities;
/* 节点选择器动画 */
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-left {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-right {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
.animate-slide-down {
animation: slide-down 0.2s ease-out;
}
.animate-slide-left {
animation: slide-left 0.2s ease-out;
}
.animate-slide-right {
animation: slide-right 0.2s ease-out;
}
/* 自定义基础样式 */
@layer base {
/* 改进字体渲染 */

View File

@@ -37,6 +37,7 @@ import MaterialCenter from './pages/MaterialCenter';
import VideoGeneration from './pages/VideoGeneration';
import { OutfitPhotoGenerationPage } from './pages/OutfitPhotoGeneration';
import ComfyUIManagement from './pages/ComfyUIManagement';
import CanvasTool from './pages/CanvasTool';
import Navigation from './components/Navigation';
import { NotificationSystem, useNotifications } from './components/NotificationSystem';
@@ -117,6 +118,9 @@ function App() {
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8 max-w-full">
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/projects" element={<ProjectList />} />
{/* AI 画布工具暂时隐藏 */}
{/* <Route path="/canvas-tool" element={<CanvasTool />} /> */}
<Route path="/project/:id" element={<ProjectDetails />} />
<Route path="/models" element={<Models />} />
<Route path="/models/:id" element={<ModelDetail />} />

View File

@@ -8,6 +8,7 @@ import {
WrenchScrewdriverIcon,
SparklesIcon,
CommandLineIcon,
PaintBrushIcon,
} from '@heroicons/react/24/outline';
const Navigation: React.FC = () => {
@@ -20,6 +21,13 @@ const Navigation: React.FC = () => {
icon: FolderIcon,
description: '管理项目和素材'
},
// AI画布暂时隐藏
// {
// name: 'AI画布',
// href: '/canvas-tool',
// icon: PaintBrushIcon,
// description: '可视化AI内容生成画布'
// },
{
name: '模特',
href: '/models',
@@ -68,6 +76,11 @@ const Navigation: React.FC = () => {
return location.pathname === '/tools';
}
// 特殊处理:项目页面匹配 /projects 和 /project/:id
if (href === '/projects') {
return location.pathname === '/projects' || location.pathname.startsWith('/project/');
}
// 其他路径使用 startsWith 匹配
return location.pathname.startsWith(href);
};

View File

@@ -0,0 +1,220 @@
import React, { useState, useEffect } from 'react';
import { AIServiceConfig } from '../../services/aiServices';
interface AIConfigModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (config: AIServiceConfig) => void;
currentConfig: AIServiceConfig;
}
export const AIConfigModal: React.FC<AIConfigModalProps> = ({
isOpen,
onClose,
onSave,
currentConfig
}) => {
const [config, setConfig] = useState<AIServiceConfig>(currentConfig);
useEffect(() => {
setConfig(currentConfig);
}, [currentConfig]);
const handleSave = () => {
onSave(config);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800">AI </h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
</button>
</div>
<div className="space-y-6">
{/* OpenAI 配置 */}
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-800 mb-4">OpenAI </h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<input
type="password"
value={config.openai.apiKey}
onChange={(e) => setConfig(prev => ({
...prev,
openai: { ...prev.openai, apiKey: e.target.value }
}))}
placeholder="sk-..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Base URL
</label>
<input
type="url"
value={config.openai.baseURL}
onChange={(e) => setConfig(prev => ({
...prev,
openai: { ...prev.openai, baseURL: e.target.value }
}))}
placeholder="https://api.openai.com/v1"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={config.openai.model}
onChange={(e) => setConfig(prev => ({
...prev,
openai: { ...prev.openai, model: e.target.value }
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="gpt-4">GPT-4</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
<option value="gpt-3.5-turbo">GPT-3.5 Turbo</option>
</select>
</div>
</div>
</div>
{/* 图片生成配置 */}
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={config.imageGeneration.provider}
onChange={(e) => setConfig(prev => ({
...prev,
imageGeneration: {
...prev.imageGeneration,
provider: e.target.value as any
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="openai">OpenAI DALL-E</option>
<option value="midjourney">Midjourney</option>
<option value="stable-diffusion">Stable Diffusion</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<input
type="password"
value={config.imageGeneration.apiKey}
onChange={(e) => setConfig(prev => ({
...prev,
imageGeneration: {
...prev.imageGeneration,
apiKey: e.target.value
}
}))}
placeholder="API Key for image generation service"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* 视频生成配置 */}
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={config.videoGeneration.provider}
onChange={(e) => setConfig(prev => ({
...prev,
videoGeneration: {
...prev.videoGeneration,
provider: e.target.value as any
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="runway">Runway ML</option>
<option value="pika">Pika Labs</option>
<option value="stable-video">Stable Video</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<input
type="password"
value={config.videoGeneration.apiKey}
onChange={(e) => setConfig(prev => ({
...prev,
videoGeneration: {
...prev.videoGeneration,
apiKey: e.target.value
}
}))}
placeholder="API Key for video generation service"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-3 mt-6 pt-6 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
</button>
</div>
{/* 配置说明 */}
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<h4 className="text-sm font-medium text-blue-800 mb-2"></h4>
<ul className="text-xs text-blue-700 space-y-1">
<li> OpenAI API Key </li>
<li> </li>
<li> API Key</li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { Handle, Position } from 'reactflow';
import { NodePort, ProcessingStatus } from '../../types/canvas';
import { useCanvasStore } from '../../store/canvasStore';
import clsx from 'clsx';
interface BaseNodeProps {
id: string;
title: string;
children: React.ReactNode;
inputs?: NodePort[];
outputs?: NodePort[];
className?: string;
}
export const BaseNode: React.FC<BaseNodeProps> = ({
id,
title,
children,
inputs = [],
outputs = [],
className
}) => {
const [isHovered, setIsHovered] = useState(false);
const { processingNodes, deleteNode } = useCanvasStore();
const processingInfo = processingNodes.get(id);
const status = processingInfo?.status || 'idle';
const progress = processingInfo?.progress || 0;
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (confirm('确定要删除这个节点吗?')) {
deleteNode(id);
}
};
return (
<div
className={clsx(
"bg-white rounded-lg shadow-md border-2 border-gray-200 relative",
"min-w-48 min-h-32 p-4 transition-all duration-200",
"hover:shadow-lg hover:border-blue-300",
{
"border-blue-400 bg-blue-50": status === 'loading',
"border-green-400 bg-green-50": status === 'success',
"border-red-400 bg-red-50": status === 'error'
},
className
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 输入端口 */}
{inputs.map(port => (
<Handle
key={port.id}
type="target"
position={Position.Left}
id={port.id}
className={clsx(
"w-3 h-3 border-2 border-white transition-all duration-200",
"hover:scale-125 hover:shadow-lg",
{
"bg-purple-500": port.type === 'text',
"bg-cyan-500": port.type === 'image',
"bg-pink-500": port.type === 'video',
"bg-orange-500 w-4 h-4": port.type === 'batch'
}
)}
style={{ top: `${20 + inputs.indexOf(port) * 30}px` }}
/>
))}
{/* 删除按钮 */}
{isHovered && status !== 'loading' && (
<button
onClick={handleDelete}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors duration-200 flex items-center justify-center text-xs font-bold shadow-lg z-10"
title="删除节点"
>
×
</button>
)}
{/* 节点内容 */}
<div className="node-content">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-800">{title}</h3>
{status === 'loading' && (
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
)}
</div>
<div className="node-body">
{children}
</div>
{/* 进度条 */}
{status === 'loading' && progress !== undefined && (
<div className="mt-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1 text-center">
{Math.round(progress)}%
</div>
</div>
)}
{/* 错误信息 */}
{status === 'error' && processingInfo?.error && (
<div className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded">
{processingInfo.error}
</div>
)}
</div>
{/* 输出端口 */}
{outputs.map(port => (
<Handle
key={port.id}
type="source"
position={Position.Right}
id={port.id}
className={clsx(
"w-3 h-3 border-2 border-white transition-all duration-200",
"hover:scale-125 hover:shadow-lg",
{
"bg-purple-500": port.type === 'text',
"bg-cyan-500": port.type === 'image',
"bg-pink-500": port.type === 'video',
"bg-orange-500 w-4 h-4": port.type === 'batch'
}
)}
style={{ top: `${20 + outputs.indexOf(port) * 30}px` }}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,248 @@
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import ReactFlow, {
Background,
Controls,
MiniMap,
useReactFlow,
ReactFlowProvider,
Connection,
Node
} from 'reactflow';
import 'reactflow/dist/style.css';
import { useCanvasStore } from '../../store/canvasStore';
import { NodeSelector } from './NodeSelector';
import { TextInputNode } from './nodes/TextInputNode';
import { PromptOptimizeNode } from './nodes/PromptOptimizeNode';
import { TextDisplayNode } from './nodes/TextDisplayNode';
import { ImageUploadNode } from './nodes/ImageUploadNode';
import { ImageGenerateNode } from './nodes/ImageGenerateNode';
import { ImageDisplayNode } from './nodes/ImageDisplayNode';
import { ImageEditNode } from './nodes/ImageEditNode';
import { VideoGenerateNode } from './nodes/VideoGenerateNode';
import { VideoPlayerNode } from './nodes/VideoPlayerNode';
import { FolderUploadNode } from './nodes/FolderUploadNode';
import { BatchResultGrid } from './nodes/BatchResultGrid';
import { NodeType } from '../../types/canvas';
import { ConnectionValidator, DataFlowConverter } from '../../utils/connectionValidator';
import { NotificationSystem, useGlobalNotifications, globalNotifications } from './NotificationSystem';
import { ProcessingIndicator, KeyboardShortcutsHint } from './ConnectionIndicator';
// 节点类型映射
const nodeTypes = {
textInput: TextInputNode,
promptOptimize: PromptOptimizeNode,
textDisplay: TextDisplayNode,
imageUpload: ImageUploadNode,
imageGenerate: ImageGenerateNode,
imageDisplay: ImageDisplayNode,
imageEdit: ImageEditNode,
videoGenerate: VideoGenerateNode,
videoPlayer: VideoPlayerNode,
folderUpload: FolderUploadNode,
batchGrid: BatchResultGrid,
};
const CanvasFlow: React.FC = () => {
const [showShortcuts, setShowShortcuts] = useState(false);
const { notifications, removeNotification } = useGlobalNotifications();
const {
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect: storeOnConnect,
showNodeSelector,
nodeSelectorPosition,
showNodeSelectorAt,
hideNodeSelector,
addNode,
updateNode,
deleteNode,
processingNodes
} = useCanvasStore();
const { screenToFlowPosition } = useReactFlow();
// 处理画布点击
const onPaneClick = useCallback((event: React.MouseEvent) => {
// 单击空白区域关闭节点选择器
if (event.detail === 1 && (event.target as HTMLElement).classList.contains('react-flow__pane')) {
hideNodeSelector();
}
// 双击空白区域显示节点选择器
if (event.detail === 2 && (event.target as HTMLElement).classList.contains('react-flow__pane')) {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
showNodeSelectorAt(position);
}
}, [screenToFlowPosition, showNodeSelectorAt, hideNodeSelector]);
// 处理连线
const onConnect = useCallback((connection: Connection) => {
// 验证连接是否有效
const validation = ConnectionValidator.validateConnection(connection, nodes);
if (!validation.valid) {
globalNotifications.error('连接失败', validation.reason);
return;
}
// 添加连线到store
storeOnConnect(connection);
// 触发数据流处理
const sourceNode = nodes.find(n => n.id === connection.source);
const targetNode = nodes.find(n => n.id === connection.target);
if (sourceNode && targetNode && sourceNode.data?.result) {
// 使用数据转换器准备数据
const preparedData = DataFlowConverter.prepareDataForNode(
sourceNode.data.result,
sourceNode.type as NodeType,
targetNode.type as NodeType
);
// 更新目标节点数据
updateNode(connection.target!, {
data: {
...targetNode.data,
...preparedData
}
});
}
}, [storeOnConnect, nodes, updateNode]);
// 创建节点
const createNode = useCallback((nodeType: NodeType, position: { x: number; y: number }) => {
const newNode: Node = {
id: `${nodeType}-${Date.now()}`,
type: nodeType,
position,
data: {
label: nodeType
}
};
addNode(newNode);
globalNotifications.success('节点已创建', `已添加 ${nodeType} 节点`);
}, [addNode]);
// 键盘事件处理
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'F1') {
e.preventDefault();
setShowShortcuts(!showShortcuts);
}
if (e.key === 'Escape') {
hideNodeSelector();
}
if (e.key === 'Delete' || e.key === 'Backspace') {
// 删除选中的节点
const selectedNodes = nodes.filter(node => node.selected);
if (selectedNodes.length > 0) {
const nodeNames = selectedNodes.map(node => node.data?.title || node.type).join(', ');
if (confirm(`确定要删除选中的节点吗?\n${nodeNames}`)) {
selectedNodes.forEach(node => {
deleteNode(node.id);
});
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showShortcuts, hideNodeSelector, nodes, deleteNode]);
// 自定义边样式
const edgeOptions = useMemo(() => ({
animated: true,
style: { strokeWidth: 2 }
}), []);
return (
<div className="w-full h-full relative" style={{ minHeight: '500px' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
defaultEdgeOptions={edgeOptions}
fitView={nodes.length === 0}
fitViewOptions={{
padding: 0.1,
includeHiddenNodes: false,
minZoom: 0.1,
maxZoom: 2
}}
minZoom={0.1}
maxZoom={2}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
preventScrolling={false}
panOnDrag={true}
zoomOnScroll={true}
zoomOnPinch={true}
zoomOnDoubleClick={false}
className="bg-gray-50 w-full h-full"
>
<Background color="#e5e7eb" gap={20} />
<Controls />
<MiniMap
nodeColor="#3b82f6"
maskColor="rgba(0, 0, 0, 0.1)"
className="bg-white border border-gray-200 rounded-lg"
/>
</ReactFlow>
{/* 节点选择器 */}
{showNodeSelector && (
<NodeSelector
position={nodeSelectorPosition}
onSelect={createNode}
onClose={hideNodeSelector}
/>
)}
{/* 处理进度指示器 */}
{Array.from(processingNodes.entries()).map(([nodeId, info]) => (
<ProcessingIndicator
key={nodeId}
nodeId={nodeId}
progress={info.progress}
status={info.status}
/>
))}
{/* 通知系统 */}
<NotificationSystem
notifications={notifications}
onRemove={removeNotification}
/>
{/* 快捷键提示 */}
<KeyboardShortcutsHint visible={showShortcuts} />
{/* 帮助提示 */}
<button
onClick={() => setShowShortcuts(!showShortcuts)}
className="absolute bottom-4 right-4 bg-blue-600 text-white p-2 rounded-full shadow-lg hover:bg-blue-700 transition-colors"
title="显示快捷键 (F1)"
>
?
</button>
</div>
);
};
export const CanvasContainer: React.FC = () => {
return <CanvasFlow />;
};

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { useCanvasStore } from '../../store/canvasStore';
import { Save, FolderOpen, Download, Trash2, RotateCcw, Settings, ZoomIn } from 'lucide-react';
import { AIConfigModal } from './AIConfigModal';
import { AIServiceConfig, loadAIConfig } from '../../services/aiServices';
import { useReactFlow } from 'reactflow';
interface CanvasToolbarProps {
onSave?: () => void;
onLoad?: () => void;
onExport?: () => void;
}
export const CanvasToolbar: React.FC<CanvasToolbarProps> = ({
onSave,
onLoad,
onExport
}) => {
const [showAIConfig, setShowAIConfig] = useState(false);
const [aiConfig, setAiConfig] = useState<AIServiceConfig>(loadAIConfig());
const { fitView } = useReactFlow();
const {
nodes,
edges,
clearCanvas,
resetCanvas,
processingNodes
} = useCanvasStore();
const handleSave = () => {
if (onSave) {
onSave();
} else {
// 默认保存逻辑
const canvasData = {
nodes,
edges,
timestamp: new Date().toISOString()
};
const dataStr = JSON.stringify(canvasData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `canvas-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
}
};
const handleLoad = () => {
if (onLoad) {
onLoad();
} else {
// 默认加载逻辑
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
try {
const canvasData = JSON.parse(event.target?.result as string);
// TODO: 实现加载逻辑
console.log('加载画布数据:', canvasData);
} catch (error) {
console.error('加载失败:', error);
}
};
reader.readAsText(file);
}
};
input.click();
}
};
const handleExport = () => {
if (onExport) {
onExport();
} else {
// 默认导出逻辑
console.log('导出画布');
}
};
const handleClear = () => {
if (confirm('确定要清空画布吗?此操作不可撤销。')) {
clearCanvas();
}
};
const handleReset = () => {
if (confirm('确定要重置画布吗?这将清空所有内容并重置视图。')) {
resetCanvas();
}
};
const handleAIConfigSave = (newConfig: AIServiceConfig) => {
setAiConfig(newConfig);
// 保存到本地存储
localStorage.setItem('aiConfig', JSON.stringify(newConfig));
};
const handleFitView = () => {
fitView({
padding: 0.1,
includeHiddenNodes: false,
minZoom: 0.1,
maxZoom: 2
});
};
const activeProcessingCount = processingNodes.size;
const hasNodes = nodes.length > 0;
return (
<div className="h-12 bg-white border-b border-gray-200 flex items-center justify-between px-4 flex-shrink-0">
{/* 左侧:标题和说明 */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<h1 className="text-lg font-semibold text-gray-800">AI画布工具</h1>
</div>
<div className="text-sm text-gray-500 hidden md:block">
线
</div>
{/* 状态指示器 */}
{activeProcessingCount > 0 && (
<div className="flex items-center space-x-1 text-sm text-blue-600 bg-blue-50 px-2 py-1 rounded">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span>{activeProcessingCount} </span>
</div>
)}
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center space-x-2">
{/* 文件操作 */}
<div className="flex items-center space-x-1">
<button
onClick={handleSave}
disabled={!hasNodes}
className="flex items-center space-x-1 px-3 py-1.5 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="保存画布"
>
<Save size={16} />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={handleLoad}
className="flex items-center space-x-1 px-3 py-1.5 text-sm bg-gray-500 text-white rounded-md hover:bg-gray-600 transition-colors"
title="加载画布"
>
<FolderOpen size={16} />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={handleExport}
disabled={!hasNodes}
className="flex items-center space-x-1 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="导出结果"
>
<Download size={16} />
<span className="hidden sm:inline"></span>
</button>
</div>
<div className="w-px h-6 bg-gray-300 mx-2" />
{/* AI 配置 */}
<button
onClick={() => setShowAIConfig(true)}
className="flex items-center space-x-1 px-3 py-1.5 text-sm bg-purple-500 text-white rounded-md hover:bg-purple-600 transition-colors"
title="AI服务配置"
>
<Settings size={16} />
<span className="hidden sm:inline"></span>
</button>
<div className="w-px h-6 bg-gray-300 mx-2" />
{/* 画布操作 */}
<div className="flex items-center space-x-1">
<button
onClick={handleFitView}
disabled={!hasNodes}
className="flex items-center space-x-1 px-3 py-1.5 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="适应视图"
>
<ZoomIn size={16} />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={handleReset}
disabled={!hasNodes}
className="flex items-center space-x-1 px-3 py-1.5 text-sm bg-yellow-500 text-white rounded-md hover:bg-yellow-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="重置画布"
>
<RotateCcw size={16} />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={handleClear}
disabled={!hasNodes}
className="flex items-center space-x-1 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="清空画布"
>
<Trash2 size={16} />
<span className="hidden sm:inline"></span>
</button>
</div>
</div>
{/* AI 配置模态框 */}
<AIConfigModal
isOpen={showAIConfig}
onClose={() => setShowAIConfig(false)}
onSave={handleAIConfigSave}
currentConfig={aiConfig}
/>
</div>
);
};

View File

@@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
import { useCanvasStore } from '../../store/canvasStore';
interface ConnectionIndicatorProps {
sourceNodeId?: string;
targetNodeId?: string;
isConnecting: boolean;
}
export const ConnectionIndicator: React.FC<ConnectionIndicatorProps> = ({
sourceNodeId,
targetNodeId,
isConnecting
}) => {
const { nodes } = useCanvasStore();
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
if (isConnecting) {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}
}, [isConnecting]);
if (!isConnecting || !sourceNodeId) return null;
const sourceNode = nodes.find(n => n.id === sourceNodeId);
if (!sourceNode) return null;
return (
<div className="fixed inset-0 pointer-events-none z-40">
{/* 连接线 */}
<svg className="absolute inset-0 w-full h-full">
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="#3b82f6"
/>
</marker>
</defs>
<path
d={`M ${sourceNode.position.x + 200} ${sourceNode.position.y + 50} Q ${
(sourceNode.position.x + 200 + mousePosition.x) / 2
} ${sourceNode.position.y + 50} ${mousePosition.x} ${mousePosition.y}`}
stroke="#3b82f6"
strokeWidth="2"
fill="none"
strokeDasharray="5,5"
markerEnd="url(#arrowhead)"
className="animate-pulse"
/>
</svg>
{/* 连接提示 */}
<div
className="absolute bg-blue-600 text-white text-xs px-2 py-1 rounded shadow-lg"
style={{
left: mousePosition.x + 10,
top: mousePosition.y - 30
}}
>
</div>
</div>
);
};
// 端口高亮组件
export const PortHighlight: React.FC<{
nodeId: string;
portId: string;
type: 'source' | 'target';
isCompatible: boolean;
}> = ({ nodeId, portId, type, isCompatible }) => {
return (
<div
className={`absolute w-4 h-4 rounded-full border-2 pointer-events-none z-30 ${
isCompatible
? 'border-green-400 bg-green-100 animate-pulse'
: 'border-red-400 bg-red-100'
}`}
style={{
transform: 'translate(-50%, -50%)'
}}
/>
);
};
// 连接验证提示
export const ConnectionValidationTooltip: React.FC<{
position: { x: number; y: number };
message: string;
type: 'success' | 'error' | 'warning';
}> = ({ position, message, type }) => {
const getColors = () => {
switch (type) {
case 'success':
return 'bg-green-600 text-white';
case 'error':
return 'bg-red-600 text-white';
case 'warning':
return 'bg-yellow-600 text-white';
}
};
return (
<div
className={`fixed z-50 px-3 py-2 rounded-lg shadow-lg text-sm max-w-xs ${getColors()}`}
style={{
left: position.x + 10,
top: position.y - 40
}}
>
{message}
<div
className={`absolute top-full left-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent ${
type === 'success' ? 'border-t-green-600' :
type === 'error' ? 'border-t-red-600' :
'border-t-yellow-600'
}`}
/>
</div>
);
};
// 节点操作提示
export const NodeActionHint: React.FC<{
nodeId: string;
action: string;
description: string;
}> = ({ nodeId, action, description }) => {
const { nodes } = useCanvasStore();
const node = nodes.find(n => n.id === nodeId);
if (!node) return null;
return (
<div
className="absolute bg-gray-800 text-white text-xs px-2 py-1 rounded shadow-lg z-40 pointer-events-none"
style={{
left: node.position.x + 100,
top: node.position.y - 30
}}
>
<div className="font-medium">{action}</div>
<div className="text-gray-300">{description}</div>
</div>
);
};
// 处理进度指示器
export const ProcessingIndicator: React.FC<{
nodeId: string;
progress: number;
status: string;
}> = ({ nodeId, progress, status }) => {
const { nodes } = useCanvasStore();
const node = nodes.find(n => n.id === nodeId);
if (!node || status !== 'loading') return null;
return (
<div
className="absolute bg-white rounded-lg shadow-lg border border-gray-200 p-2 z-40 pointer-events-none"
style={{
left: node.position.x + 220,
top: node.position.y + 20
}}
>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<div className="text-xs text-gray-600">
... {Math.round(progress)}%
</div>
</div>
<div className="w-24 h-1 bg-gray-200 rounded-full mt-1">
<div
className="h-1 bg-blue-500 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
};
// 批量处理状态指示器
export const BatchProcessingIndicator: React.FC<{
nodeId: string;
completed: number;
total: number;
failed: number;
}> = ({ nodeId, completed, total, failed }) => {
const { nodes } = useCanvasStore();
const node = nodes.find(n => n.id === nodeId);
if (!node || total === 0) return null;
const progress = (completed / total) * 100;
return (
<div
className="absolute bg-white rounded-lg shadow-lg border border-gray-200 p-3 z-40 pointer-events-none min-w-32"
style={{
left: node.position.x + 220,
top: node.position.y + 20
}}
>
<div className="text-xs font-medium text-gray-800 mb-1">
</div>
<div className="flex items-center justify-between text-xs text-gray-600 mb-1">
<span>: {completed}/{total}</span>
{failed > 0 && <span className="text-red-600">: {failed}</span>}
</div>
<div className="w-32 h-2 bg-gray-200 rounded-full">
<div
className="h-2 bg-blue-500 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1 text-center">
{Math.round(progress)}%
</div>
</div>
);
};
// 键盘快捷键提示
export const KeyboardShortcutsHint: React.FC<{
visible: boolean;
}> = ({ visible }) => {
if (!visible) return null;
const shortcuts = [
{ key: '双击空白', action: '创建节点' },
{ key: '单击空白', action: '关闭弹框' },
{ key: 'Delete/Backspace', action: '删除选中节点' },
{ key: '悬停节点', action: '显示删除按钮' },
{ key: 'F1', action: '显示/隐藏快捷键' },
{ key: 'Esc', action: '关闭弹框' },
{ key: 'Space+拖拽', action: '平移画布' },
{ key: '滚轮', action: '缩放画布' }
];
return (
<div className="fixed bottom-4 left-4 bg-white rounded-lg shadow-lg border border-gray-200 p-4 z-40 max-w-xs">
<h3 className="text-sm font-medium text-gray-800 mb-2"></h3>
<div className="space-y-1">
{shortcuts.map((shortcut, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-600">{shortcut.action}</span>
<kbd className="bg-gray-100 px-1 rounded text-gray-800 font-mono">
{shortcut.key}
</kbd>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,217 @@
import React, { useEffect, useState, useRef } from 'react';
import { NodeType } from '../../types/canvas';
import { useCanvasStore } from '../../store/canvasStore';
interface NodeSelectorProps {
position: { x: number; y: number };
onSelect: (nodeType: NodeType, position: { x: number; y: number }) => void;
onClose: () => void;
}
interface CalculatedPosition {
x: number;
y: number;
placement: 'top' | 'bottom' | 'left' | 'right';
}
export const NodeSelector: React.FC<NodeSelectorProps> = ({ position, onSelect, onClose }) => {
const [calculatedPosition, setCalculatedPosition] = useState<CalculatedPosition>({
x: position.x,
y: position.y,
placement: 'bottom'
});
const [searchTerm, setSearchTerm] = useState('');
const selectorRef = useRef<HTMLDivElement>(null);
// 计算最佳位置
const calculateOptimalPosition = (): CalculatedPosition => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 弹框的预估尺寸
const modalWidth = 320; // max-w-md 约等于 320px
const modalHeight = 480; // 预估高度
const padding = 20; // 边距
let x = position.x;
let y = position.y;
let placement: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
// 检查右侧空间
if (x + modalWidth + padding > viewportWidth) {
x = position.x - modalWidth;
placement = 'left';
}
// 检查下方空间
if (y + modalHeight + padding > viewportHeight) {
y = position.y - modalHeight;
placement = placement === 'left' ? 'left' : 'top';
}
// 确保不超出左边界
if (x < padding) {
x = padding;
}
// 确保不超出上边界
if (y < padding) {
y = padding;
}
return { x, y, placement };
};
// 组件挂载后计算位置
useEffect(() => {
const newPosition = calculateOptimalPosition();
setCalculatedPosition(newPosition);
}, [position]);
// 点击外部关闭和键盘导航
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const nodeCategories = {
'输入节点': [
{ type: 'textInput' as NodeType, label: '文本输入', icon: '📝', description: '输入文本内容' },
{ type: 'imageUpload' as NodeType, label: '图片上传', icon: '🖼️', description: '上传单张图片' },
{ type: 'folderUpload' as NodeType, label: '文件夹', icon: '📁', description: '批量上传图片' }
],
'处理节点': [
{ type: 'promptOptimize' as NodeType, label: '提示词优化', icon: '✨', description: 'AI优化提示词' },
{ type: 'imageGenerate' as NodeType, label: '图片生成', icon: '🎨', description: '根据提示词生成图片' },
{ type: 'imageEdit' as NodeType, label: '图片编辑', icon: '✏️', description: '编辑和修改图片' },
{ type: 'videoGenerate' as NodeType, label: '视频生成', icon: '🎬', description: '图片转动态视频' }
],
'输出节点': [
{ type: 'textDisplay' as NodeType, label: '文本显示', icon: '📄', description: '显示文本结果' },
{ type: 'imageDisplay' as NodeType, label: '图片显示', icon: '🖼️', description: '显示图片结果' },
{ type: 'videoPlayer' as NodeType, label: '视频播放', icon: '▶️', description: '播放视频结果' },
{ type: 'batchGrid' as NodeType, label: '批量结果', icon: '📊', description: '网格显示批量结果' }
]
};
return (
<div
ref={selectorRef}
className={`fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4 z-50 max-w-md transition-all duration-200 ${
calculatedPosition.placement === 'top' ? 'animate-slide-up' :
calculatedPosition.placement === 'bottom' ? 'animate-slide-down' :
calculatedPosition.placement === 'left' ? 'animate-slide-left' :
'animate-slide-right'
}`}
style={{
left: calculatedPosition.x,
top: calculatedPosition.y,
maxHeight: '80vh',
overflowY: 'auto'
}}
>
{/* 位置指示器 */}
{calculatedPosition.placement === 'bottom' && (
<div className="absolute -top-2 left-6 w-4 h-4 bg-white border-l border-t border-gray-200 transform rotate-45"></div>
)}
{calculatedPosition.placement === 'top' && (
<div className="absolute -bottom-2 left-6 w-4 h-4 bg-white border-r border-b border-gray-200 transform rotate-45"></div>
)}
{calculatedPosition.placement === 'right' && (
<div className="absolute -left-2 top-6 w-4 h-4 bg-white border-l border-b border-gray-200 transform rotate-45"></div>
)}
{calculatedPosition.placement === 'left' && (
<div className="absolute -right-2 top-6 w-4 h-4 bg-white border-r border-t border-gray-200 transform rotate-45"></div>
)}
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-800"></h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 hover:bg-gray-100 rounded"
>
</button>
</div>
{/* 搜索框 */}
<div className="mb-4">
<input
type="text"
placeholder="搜索节点..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
autoFocus
/>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{Object.entries(nodeCategories).map(([category, nodes]) => {
// 过滤节点
const filteredNodes = nodes.filter(node =>
searchTerm === '' ||
node.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
node.description.toLowerCase().includes(searchTerm.toLowerCase())
);
if (filteredNodes.length === 0) return null;
return (
<div key={category}>
<h4 className="text-sm font-medium text-gray-600 mb-2 sticky top-0 bg-white">{category}</h4>
<div className="grid grid-cols-1 gap-1.5">
{filteredNodes.map(node => (
<button
key={node.type}
className="flex items-center p-2.5 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 text-left group"
onClick={() => {
onSelect(node.type, position);
onClose();
}}
>
<div className="text-xl mr-3 group-hover:scale-110 transition-transform">{node.icon}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-800 text-sm truncate">{node.label}</div>
<div className="text-xs text-gray-500 truncate">{node.description}</div>
</div>
</button>
))}
</div>
</div>
);
})}
{/* 无搜索结果 */}
{searchTerm && Object.entries(nodeCategories).every(([_, nodes]) =>
nodes.every(node =>
!node.label.toLowerCase().includes(searchTerm.toLowerCase()) &&
!node.description.toLowerCase().includes(searchTerm.toLowerCase())
)
) && (
<div className="text-center py-8 text-gray-500">
<div className="text-4xl mb-2">🔍</div>
<div className="text-sm"></div>
<div className="text-xs text-gray-400 mt-1"></div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,248 @@
import React, { useState, useEffect } from 'react';
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
export interface Notification {
id: string;
type: NotificationType;
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
interface NotificationSystemProps {
notifications: Notification[];
onRemove: (id: string) => void;
}
const NotificationItem: React.FC<{
notification: Notification;
onRemove: (id: string) => void;
}> = ({ notification, onRemove }) => {
const [isVisible, setIsVisible] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
useEffect(() => {
// 入场动画
setTimeout(() => setIsVisible(true), 50);
// 自动移除
if (notification.duration !== 0) {
const timer = setTimeout(() => {
handleRemove();
}, notification.duration || 5000);
return () => clearTimeout(timer);
}
}, [notification.duration]);
const handleRemove = () => {
setIsLeaving(true);
setTimeout(() => {
onRemove(notification.id);
}, 300);
};
const getIcon = () => {
switch (notification.type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'error':
return <XCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
case 'info':
return <Info className="w-5 h-5 text-blue-500" />;
}
};
const getBackgroundColor = () => {
switch (notification.type) {
case 'success':
return 'bg-green-50 border-green-200';
case 'error':
return 'bg-red-50 border-red-200';
case 'warning':
return 'bg-yellow-50 border-yellow-200';
case 'info':
return 'bg-blue-50 border-blue-200';
}
};
return (
<div
className={`
transform transition-all duration-300 ease-in-out
${isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
${getBackgroundColor()}
border rounded-lg p-4 shadow-lg max-w-sm w-full
`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900">
{notification.title}
</h4>
{notification.message && (
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
)}
{notification.action && (
<button
onClick={notification.action.onClick}
className="text-sm font-medium text-blue-600 hover:text-blue-800 mt-2 transition-colors"
>
{notification.action.label}
</button>
)}
</div>
<button
onClick={handleRemove}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};
export const NotificationSystem: React.FC<NotificationSystemProps> = ({
notifications,
onRemove
}) => {
return (
<div className="fixed top-4 right-4 z-50 space-y-2">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onRemove={onRemove}
/>
))}
</div>
);
};
// Hook for managing notifications
export const useNotifications = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = (notification: Omit<Notification, 'id'>) => {
const id = `notification-${Date.now()}-${Math.random()}`;
setNotifications(prev => [...prev, { ...notification, id }]);
};
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
const clearAll = () => {
setNotifications([]);
};
// 便捷方法
const success = (title: string, message?: string, options?: Partial<Notification>) => {
addNotification({ type: 'success', title, message, ...options });
};
const error = (title: string, message?: string, options?: Partial<Notification>) => {
addNotification({ type: 'error', title, message, duration: 0, ...options });
};
const warning = (title: string, message?: string, options?: Partial<Notification>) => {
addNotification({ type: 'warning', title, message, ...options });
};
const info = (title: string, message?: string, options?: Partial<Notification>) => {
addNotification({ type: 'info', title, message, ...options });
};
return {
notifications,
addNotification,
removeNotification,
clearAll,
success,
error,
warning,
info
};
};
// 全局通知管理器
class GlobalNotificationManager {
private listeners: Array<(notifications: Notification[]) => void> = [];
private notifications: Notification[] = [];
subscribe(listener: (notifications: Notification[]) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
private notify() {
this.listeners.forEach(listener => listener([...this.notifications]));
}
add(notification: Omit<Notification, 'id'>) {
const id = `global-notification-${Date.now()}-${Math.random()}`;
this.notifications.push({ ...notification, id });
this.notify();
}
remove(id: string) {
this.notifications = this.notifications.filter(n => n.id !== id);
this.notify();
}
clear() {
this.notifications = [];
this.notify();
}
success(title: string, message?: string, options?: Partial<Notification>) {
this.add({ type: 'success', title, message, ...options });
}
error(title: string, message?: string, options?: Partial<Notification>) {
this.add({ type: 'error', title, message, duration: 0, ...options });
}
warning(title: string, message?: string, options?: Partial<Notification>) {
this.add({ type: 'warning', title, message, ...options });
}
info(title: string, message?: string, options?: Partial<Notification>) {
this.add({ type: 'info', title, message, ...options });
}
}
export const globalNotifications = new GlobalNotificationManager();
// Hook for global notifications
export const useGlobalNotifications = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
return globalNotifications.subscribe(setNotifications);
}, []);
return {
notifications,
removeNotification: globalNotifications.remove.bind(globalNotifications)
};
};

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { useCanvasStore } from '../../store/canvasStore';
import { Node } from 'reactflow';
interface Template {
id: string;
name: string;
description: string;
icon: string;
nodes: Partial<Node>[];
edges: any[];
}
const templates: Template[] = [
{
id: 'text-to-image',
name: '文本生成图片',
description: '输入文本 → 优化提示词 → 生成图片',
icon: '🎨',
nodes: [
{
id: 'text-1',
type: 'textInput',
position: { x: 100, y: 100 },
data: { text: '一只可爱的小猫' }
},
{
id: 'prompt-1',
type: 'promptOptimize',
position: { x: 350, y: 100 },
data: {}
},
{
id: 'image-1',
type: 'imageGenerate',
position: { x: 600, y: 100 },
data: {}
},
{
id: 'display-1',
type: 'imageDisplay',
position: { x: 850, y: 100 },
data: {}
}
],
edges: [
{ id: 'e1', source: 'text-1', target: 'prompt-1', sourceHandle: 'text-output', targetHandle: 'text-input' },
{ id: 'e2', source: 'prompt-1', target: 'image-1', sourceHandle: 'text-output', targetHandle: 'prompt-input' },
{ id: 'e3', source: 'image-1', target: 'display-1', sourceHandle: 'image-output', targetHandle: 'image-input' }
]
},
{
id: 'batch-processing',
name: '批量图片处理',
description: '文件夹上传 → 批量处理 → 结果展示',
icon: '📁',
nodes: [
{
id: 'folder-1',
type: 'folderUpload',
position: { x: 100, y: 150 },
data: {}
},
{
id: 'batch-1',
type: 'batchGrid',
position: { x: 400, y: 150 },
data: {}
}
],
edges: [
{ id: 'e1', source: 'folder-1', target: 'batch-1', sourceHandle: 'batch-output', targetHandle: 'batch-input' }
]
},
{
id: 'image-workflow',
name: '完整图片工作流',
description: '图片上传 → 多路径处理 → 结果对比',
icon: '🔄',
nodes: [
{
id: 'upload-1',
type: 'imageUpload',
position: { x: 100, y: 200 },
data: {}
},
{
id: 'display-1',
type: 'imageDisplay',
position: { x: 400, y: 150 },
data: {}
},
{
id: 'display-2',
type: 'imageDisplay',
position: { x: 400, y: 250 },
data: {}
}
],
edges: [
{ id: 'e1', source: 'upload-1', target: 'display-1', sourceHandle: 'image-output', targetHandle: 'image-input' },
{ id: 'e2', source: 'upload-1', target: 'display-2', sourceHandle: 'image-output', targetHandle: 'image-input' }
]
}
];
interface QuickStartTemplatesProps {
onClose: () => void;
}
export const QuickStartTemplates: React.FC<QuickStartTemplatesProps> = ({ onClose }) => {
const { setNodes, setEdges, clearCanvas } = useCanvasStore();
const handleTemplateSelect = (template: Template) => {
// 清空当前画布
clearCanvas();
// 应用模板
setNodes(template.nodes as Node[]);
setEdges(template.edges);
// 关闭模板选择器
onClose();
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
</button>
</div>
<p className="text-gray-600 mt-2">AI创作流程</p>
</div>
<div className="p-6 overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{templates.map(template => (
<div
key={template.id}
onClick={() => handleTemplateSelect(template)}
className="border border-gray-200 rounded-lg p-4 hover:border-blue-300 hover:bg-blue-50 cursor-pointer transition-all duration-200"
>
<div className="flex items-start space-x-3">
<div className="text-3xl">{template.icon}</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-800 mb-1">{template.name}</h3>
<p className="text-sm text-gray-600 mb-3">{template.description}</p>
<div className="text-xs text-gray-500">
{template.nodes.length} {template.edges.length}
</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<h3 className="font-medium text-gray-800 mb-2">💡 使</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> </li>
<li> 线</li>
<li> 线</li>
<li> </li>
</ul>
</div>
</div>
<div className="p-6 border-t border-gray-200 bg-gray-50">
<div className="flex justify-between items-center">
<button
onClick={onClose}
className="text-gray-600 hover:text-gray-800 transition-colors"
>
</button>
<div className="text-sm text-gray-500">
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort, BatchResult } from '../../../types/canvas';
interface BatchResultGridProps {
id: string;
data: {
results?: BatchResult[];
gridSize?: number;
totalCount?: number;
completedCount?: number;
};
}
export const BatchResultGrid: React.FC<BatchResultGridProps> = ({ id, data }) => {
const inputs: NodePort[] = [
{ id: 'batch-input', type: 'batch', label: '批量输入', position: 'input' }
];
const results = data.results || [];
const completedCount = results.filter(r => r.status === 'success').length;
const totalCount = results.length;
const gridSize = data.gridSize || 3;
const getStatusIcon = (status: string) => {
switch (status) {
case 'loading': return '⏳';
case 'success': return '✅';
case 'error': return '❌';
default: return '⏸️';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'loading': return 'text-blue-600';
case 'success': return 'text-green-600';
case 'error': return 'text-red-600';
default: return 'text-gray-400';
}
};
return (
<BaseNode
id={id}
title="批量结果网格"
inputs={inputs}
className="min-w-80"
>
<div className="space-y-3">
{/* 进度统计 */}
{totalCount > 0 && (
<div className="bg-gray-50 p-2 rounded">
<div className="flex items-center justify-between text-xs">
<span className="font-medium"></span>
<span className="text-gray-600">{completedCount}/{totalCount}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%` }}
/>
</div>
</div>
)}
{/* 结果网格 */}
{results.length > 0 ? (
<div
className="grid gap-2 max-h-48 overflow-y-auto"
style={{
gridTemplateColumns: `repeat(${gridSize}, 1fr)`
}}
>
{results.map((item, index) => (
<div
key={item.id || index}
className="border border-gray-200 rounded p-1 bg-white hover:shadow-sm transition-shadow"
>
{/* 预览区域 */}
<div className="aspect-square bg-gray-100 rounded mb-1 relative overflow-hidden">
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.filename}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
{item.status === 'loading' ? '处理中...' : '无预览'}
</div>
)}
{/* 状态指示器 */}
<div className="absolute top-1 right-1 text-xs">
{getStatusIcon(item.status)}
</div>
</div>
{/* 文件信息 */}
<div className="text-xs">
<div className="truncate font-medium text-gray-800" title={item.filename}>
{item.filename}
</div>
<div className={`text-xs ${getStatusColor(item.status)}`}>
{item.status === 'loading' && '处理中...'}
{item.status === 'success' && '完成'}
{item.status === 'error' && '失败'}
{item.status === 'idle' && '等待中'}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-400 text-center py-8">
...
</div>
)}
{/* 操作按钮 */}
{results.length > 0 && (
<div className="flex justify-between items-center text-xs">
<span className="text-gray-500">
{results.length}
</span>
<div className="space-x-2">
<button className="text-blue-500 hover:text-blue-700 transition-colors">
</button>
<button className="text-green-500 hover:text-green-700 transition-colors">
</button>
</div>
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,144 @@
import React, { useState, useCallback } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort } from '../../../types/canvas';
import { useCanvasStore } from '../../../store/canvasStore';
import { batchProcessor, BatchItem } from '../../../utils/batchProcessor';
interface FolderUploadNodeProps {
id: string;
data: {
files?: File[];
fileCount?: number;
totalSize?: number;
result?: File[];
};
}
export const FolderUploadNode: React.FC<FolderUploadNodeProps> = ({ id, data }) => {
const [files, setFiles] = useState<File[]>(data.files || []);
const [isProcessing, setIsProcessing] = useState(false);
const [batchResults, setBatchResults] = useState<BatchItem[]>([]);
const { updateNode } = useCanvasStore();
const outputs: NodePort[] = [
{ id: 'batch-output', type: 'batch', label: '批量输出', position: 'output' }
];
const handleFolderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
const imageFiles = selectedFiles.filter(file => file.type.startsWith('image/'));
setFiles(imageFiles);
setBatchResults([]); // 清空之前的结果
const totalSize = imageFiles.reduce((sum, file) => sum + file.size, 0);
// 准备批量数据
const batchData = imageFiles.map(file => ({
filename: file.name,
data: file,
size: file.size,
type: file.type
}));
// 更新节点数据
updateNode(id, {
data: {
...data,
files: imageFiles,
fileCount: imageFiles.length,
totalSize,
result: batchData
}
});
}, [id, data, updateNode]);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<BaseNode
id={id}
title="文件夹上传"
outputs={outputs}
>
<div className="space-y-2">
<input
type="file"
multiple
webkitdirectory=""
onChange={handleFolderChange}
className="w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-orange-50 file:text-orange-700 hover:file:bg-orange-100"
/>
{files.length > 0 ? (
<div className="bg-orange-50 p-2 rounded text-xs">
<div className="font-medium text-orange-800">
📁 {files.length}
</div>
<div className="text-orange-600">
: {formatFileSize(data.totalSize || 0)}
</div>
{/* 文件列表预览 */}
<div className="mt-2 max-h-20 overflow-y-auto">
{files.slice(0, 5).map((file, index) => (
<div key={index} className="text-orange-600 truncate text-xs">
{file.name} ({formatFileSize(file.size)})
</div>
))}
{files.length > 5 && (
<div className="text-orange-500 text-xs">
... {files.length - 5}
</div>
)}
</div>
{/* 批量处理状态 */}
{isProcessing && (
<div className="mt-2 text-blue-600">
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
<span>...</span>
</div>
</div>
)}
{/* 批量结果预览 */}
{batchResults.length > 0 && (
<div className="mt-2">
<div className="text-green-600 font-medium">
: {batchResults.filter(r => r.status === 'success').length}/{batchResults.length}
</div>
<div className="grid grid-cols-3 gap-1 mt-1 max-h-16 overflow-y-auto">
{batchResults.slice(0, 6).map((result, index) => (
<div
key={result.id}
className={`w-full h-8 rounded border text-xs flex items-center justify-center ${
result.status === 'success' ? 'bg-green-100 text-green-700' :
result.status === 'error' ? 'bg-red-100 text-red-700' :
'bg-blue-100 text-blue-700'
}`}
>
{result.status === 'success' ? '✓' :
result.status === 'error' ? '✗' : '⏳'}
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="w-full h-20 border-2 border-dashed border-orange-300 rounded flex items-center justify-center text-xs text-orange-400">
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort } from '../../../types/canvas';
interface ImageDisplayNodeProps {
id: string;
data: {
inputImage?: string;
result?: string;
};
}
export const ImageDisplayNode: React.FC<ImageDisplayNodeProps> = ({ id, data }) => {
const inputs: NodePort[] = [
{ id: 'image-input', type: 'image', label: '图片输入', position: 'input' }
];
const handleDownload = () => {
if (data.result) {
const link = document.createElement('a');
link.href = data.result;
link.download = `generated-image-${Date.now()}.png`;
link.click();
}
};
return (
<BaseNode
id={id}
title="图片显示"
inputs={inputs}
>
<div className="space-y-2">
{data.result ? (
<div className="space-y-2">
<img
src={data.result}
alt="显示图片"
className="w-full h-32 object-cover rounded border"
/>
<div className="flex justify-between items-center">
<span className="text-xs text-gray-500"></span>
<button
onClick={handleDownload}
className="text-xs text-blue-500 hover:text-blue-700 transition-colors"
>
</button>
</div>
</div>
) : (
<div className="w-full h-32 border-2 border-dashed border-gray-300 rounded flex items-center justify-center text-xs text-gray-400">
...
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort, DataFlow } from '../../../types/canvas';
import { useNodeProcessing, useNodeStatus } from '../../../hooks/useNodeProcessing';
interface ImageEditNodeProps {
id: string;
data: {
originalImage?: string;
editedImage?: string;
editInstruction?: string;
editType?: 'style-transfer' | 'background-remove' | 'enhance' | 'resize';
result?: string;
};
}
export const ImageEditNode: React.FC<ImageEditNodeProps> = ({ id, data }) => {
const { processNode } = useNodeProcessing();
const { status, progress } = useNodeStatus(id);
const [editType, setEditType] = useState<string>(data.editType || 'enhance');
const [instruction, setInstruction] = useState(data.editInstruction || '');
const inputs: NodePort[] = [
{ id: 'image-input', type: 'image', label: '原始图片', position: 'input' },
{ id: 'instruction-input', type: 'text', label: '编辑指令', position: 'input' }
];
const outputs: NodePort[] = [
{ id: 'image-output', type: 'image', label: '编辑后图片', position: 'output' }
];
// 监听输入数据变化并处理
useEffect(() => {
if (data.originalImage && instruction && status !== 'loading') {
const inputData: DataFlow = {
type: 'single',
dataType: 'image',
content: {
imageUrl: data.originalImage,
instruction: instruction,
editType: editType
}
};
processNode(id, 'imageEdit', inputData).catch(error => {
console.error('Image editing failed:', error);
});
}
}, [data.originalImage, instruction, editType, id, processNode, status]);
const editTypeOptions = [
{ value: 'enhance', label: '图片增强', icon: '✨' },
{ value: 'style-transfer', label: '风格转换', icon: '🎨' },
{ value: 'background-remove', label: '背景移除', icon: '🖼️' },
{ value: 'resize', label: '尺寸调整', icon: '📐' }
];
return (
<BaseNode
id={id}
title="图片编辑"
inputs={inputs}
outputs={outputs}
>
<div className="space-y-3">
{/* 编辑类型选择 */}
<div>
<label className="text-xs font-medium text-gray-700 mb-1 block"></label>
<select
value={editType}
onChange={(e) => setEditType(e.target.value)}
className="w-full text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{editTypeOptions.map(option => (
<option key={option.value} value={option.value}>
{option.icon} {option.label}
</option>
))}
</select>
</div>
{/* 编辑指令输入 */}
<div>
<label className="text-xs font-medium text-gray-700 mb-1 block"></label>
<textarea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder="描述你想要的编辑效果..."
className="w-full h-16 text-xs border border-gray-300 rounded px-2 py-1 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* 原始图片预览 */}
{data.originalImage && (
<div>
<div className="text-xs font-medium text-gray-700 mb-1">:</div>
<img
src={data.originalImage}
alt="原始图片"
className="w-full h-20 object-cover rounded border"
/>
</div>
)}
{/* 处理状态 */}
{status === 'loading' && (
<div className="text-xs text-blue-600 text-center">
... {Math.round(progress)}%
</div>
)}
{/* 编辑结果 */}
{data.result && status === 'success' && (
<div>
<div className="text-xs font-medium text-green-600 mb-1">:</div>
<img
src={data.result}
alt="编辑后的图片"
className="w-full h-24 object-cover rounded border"
/>
</div>
)}
{/* 等待输入提示 */}
{!data.originalImage && (
<div className="text-xs text-gray-400 text-center py-4">
...
</div>
)}
{/* 错误状态 */}
{status === 'error' && (
<div className="text-xs text-red-600 bg-red-50 p-2 rounded">
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,84 @@
import React, { useEffect } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort, DataFlow } from '../../../types/canvas';
import { useNodeProcessing, useNodeStatus } from '../../../hooks/useNodeProcessing';
interface ImageGenerateNodeProps {
id: string;
data: {
prompt?: string;
generatedImage?: string;
result?: string;
};
}
export const ImageGenerateNode: React.FC<ImageGenerateNodeProps> = ({ id, data }) => {
const { processNode } = useNodeProcessing();
const { status, progress } = useNodeStatus(id);
const inputs: NodePort[] = [
{ id: 'prompt-input', type: 'text', label: '提示词输入', position: 'input' }
];
const outputs: NodePort[] = [
{ id: 'image-output', type: 'image', label: '生成图片', position: 'output' }
];
// 监听输入数据变化并处理
useEffect(() => {
if (data.prompt && data.prompt !== data.result && status !== 'loading') {
const inputData: DataFlow = {
type: 'single',
dataType: 'text',
content: data.prompt
};
processNode(id, 'imageGenerate', inputData).catch(error => {
console.error('Image generation failed:', error);
});
}
}, [data.prompt, id, processNode, data.result, status]);
return (
<BaseNode
id={id}
title="图片生成"
inputs={inputs}
outputs={outputs}
>
<div className="space-y-2">
{data.prompt && (
<div className="text-xs text-gray-600">
<div className="font-medium">:</div>
<div className="bg-gray-50 p-2 rounded text-xs max-h-16 overflow-y-auto">
{data.prompt}
</div>
</div>
)}
{status === 'loading' && (
<div className="text-xs text-blue-600 text-center">
🎨 AI生成中... {Math.round(progress)}%
</div>
)}
{data.result && status === 'success' && (
<div className="space-y-2">
<div className="text-xs font-medium text-green-600">:</div>
<img
src={data.result}
alt="生成的图片"
className="w-full h-24 object-cover rounded border"
/>
</div>
)}
{!data.prompt && (
<div className="text-xs text-gray-400 text-center py-4">
...
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,80 @@
import React, { useState, useCallback } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort } from '../../../types/canvas';
import { useCanvasStore } from '../../../store/canvasStore';
interface ImageUploadNodeProps {
id: string;
data: {
image?: File | null;
preview?: string;
result?: string;
};
}
export const ImageUploadNode: React.FC<ImageUploadNodeProps> = ({ id, data }) => {
const [preview, setPreview] = useState(data.preview || '');
const { updateNode } = useCanvasStore();
const outputs: NodePort[] = [
{ id: 'image-output', type: 'image', label: '图片输出', position: 'output' }
];
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const imageUrl = event.target?.result as string;
setPreview(imageUrl);
// 更新节点数据
updateNode(id, {
data: {
...data,
image: file,
preview: imageUrl,
result: imageUrl
}
});
};
reader.readAsDataURL(file);
}
}, [id, data, updateNode]);
return (
<BaseNode
id={id}
title="图片上传"
outputs={outputs}
>
<div className="space-y-2">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
{preview && (
<div className="relative">
<img
src={preview}
alt="预览"
className="w-full h-24 object-cover rounded border"
/>
<div className="absolute top-1 right-1 bg-black/50 text-white text-xs px-1 rounded">
</div>
</div>
)}
{!preview && (
<div className="w-full h-24 border-2 border-dashed border-gray-300 rounded flex items-center justify-center text-xs text-gray-400">
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort, DataFlow } from '../../../types/canvas';
import { useNodeProcessing, useNodeStatus } from '../../../hooks/useNodeProcessing';
interface PromptOptimizeNodeProps {
id: string;
data: {
inputPrompt?: string;
optimizedPrompt?: string;
result?: string;
};
}
export const PromptOptimizeNode: React.FC<PromptOptimizeNodeProps> = ({ id, data }) => {
const { processNode } = useNodeProcessing();
const { status, progress } = useNodeStatus(id);
const inputs: NodePort[] = [
{ id: 'text-input', type: 'text', label: '原始提示词', position: 'input' }
];
const outputs: NodePort[] = [
{ id: 'text-output', type: 'text', label: '优化提示词', position: 'output' }
];
// 监听输入数据变化并处理
useEffect(() => {
if (data.inputPrompt && data.inputPrompt !== data.result && status !== 'loading') {
const inputData: DataFlow = {
type: 'single',
dataType: 'text',
content: data.inputPrompt
};
processNode(id, 'promptOptimize', inputData).catch(error => {
console.error('Prompt optimization failed:', error);
});
}
}, [data.inputPrompt, id, processNode, data.result, status]);
return (
<BaseNode
id={id}
title="提示词优化"
inputs={inputs}
outputs={outputs}
>
<div className="space-y-2">
{data.inputPrompt && (
<div className="text-xs text-gray-600">
<div className="font-medium">:</div>
<div className="bg-gray-50 p-2 rounded text-xs">
{data.inputPrompt}
</div>
</div>
)}
{status === 'loading' && (
<div className="text-xs text-blue-600 text-center">
🤖 AI优化中... {Math.round(progress)}%
</div>
)}
{data.result && status === 'success' && (
<div className="text-xs text-gray-600">
<div className="font-medium text-green-600">:</div>
<div className="bg-green-50 p-2 rounded text-xs max-h-20 overflow-y-auto">
{data.result}
</div>
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort } from '../../../types/canvas';
interface TextDisplayNodeProps {
id: string;
data: {
inputText?: string;
result?: string;
};
}
export const TextDisplayNode: React.FC<TextDisplayNodeProps> = ({ id, data }) => {
const inputs: NodePort[] = [
{ id: 'text-input', type: 'text', label: '文本输入', position: 'input' }
];
return (
<BaseNode
id={id}
title="文本显示"
inputs={inputs}
>
<div className="space-y-2">
{data.result ? (
<div className="bg-gray-50 p-3 rounded-md">
<div className="text-sm text-gray-800 whitespace-pre-wrap max-h-32 overflow-y-auto">
{data.result}
</div>
</div>
) : (
<div className="text-xs text-gray-400 text-center py-4">
...
</div>
)}
{data.result && (
<div className="flex justify-between items-center text-xs text-gray-500">
<span>{data.result.length} </span>
<button
onClick={() => navigator.clipboard.writeText(data.result)}
className="text-blue-500 hover:text-blue-700 transition-colors"
>
</button>
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,53 @@
import React, { useState, useCallback } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort } from '../../../types/canvas';
import { useCanvasStore } from '../../../store/canvasStore';
interface TextInputNodeProps {
id: string;
data: {
text?: string;
placeholder?: string;
};
}
export const TextInputNode: React.FC<TextInputNodeProps> = ({ id, data }) => {
const [text, setText] = useState(data.text || '');
const { updateNode } = useCanvasStore();
const outputs: NodePort[] = [
{ id: 'text-output', type: 'text', label: '文本输出', position: 'output' }
];
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
setText(newText);
// 更新节点数据
updateNode(id, {
data: { ...data, text: newText, result: newText }
});
}, [id, data, updateNode]);
return (
<BaseNode
id={id}
title="文本输入"
outputs={outputs}
>
<div className="space-y-2">
<textarea
value={text}
onChange={handleTextChange}
placeholder={data.placeholder || "输入文本内容..."}
className="w-full h-20 p-2 border border-gray-300 rounded-md resize-none text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{text && (
<div className="text-xs text-gray-500">
{text.length}
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,181 @@
import React, { useEffect, useState } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort, DataFlow } from '../../../types/canvas';
import { useNodeProcessing, useNodeStatus } from '../../../hooks/useNodeProcessing';
interface VideoGenerateNodeProps {
id: string;
data: {
sourceImage?: string;
motionPrompt?: string;
generatedVideo?: string;
settings?: {
duration: number;
fps: number;
quality: 'low' | 'medium' | 'high';
};
result?: string;
};
}
export const VideoGenerateNode: React.FC<VideoGenerateNodeProps> = ({ id, data }) => {
const { processNode } = useNodeProcessing();
const { status, progress } = useNodeStatus(id);
const [motionPrompt, setMotionPrompt] = useState(data.motionPrompt || '');
const [settings, setSettings] = useState({
duration: data.settings?.duration || 3,
fps: data.settings?.fps || 24,
quality: data.settings?.quality || 'medium' as const
});
const inputs: NodePort[] = [
{ id: 'image-input', type: 'image', label: '源图片', position: 'input' },
{ id: 'motion-input', type: 'text', label: '运动描述', position: 'input' }
];
const outputs: NodePort[] = [
{ id: 'video-output', type: 'video', label: '生成视频', position: 'output' }
];
// 监听输入数据变化并处理
useEffect(() => {
if (data.sourceImage && motionPrompt && status !== 'loading') {
const inputData: DataFlow = {
type: 'single',
dataType: 'image',
content: {
imageUrl: data.sourceImage,
motionPrompt: motionPrompt,
settings: settings
}
};
processNode(id, 'videoGenerate', inputData).catch(error => {
console.error('Video generation failed:', error);
});
}
}, [data.sourceImage, motionPrompt, settings, id, processNode, status]);
const qualityOptions = [
{ value: 'low', label: '低质量 (快速)', icon: '⚡' },
{ value: 'medium', label: '中等质量', icon: '⚖️' },
{ value: 'high', label: '高质量 (慢速)', icon: '💎' }
];
return (
<BaseNode
id={id}
title="视频生成"
inputs={inputs}
outputs={outputs}
className="min-w-64"
>
<div className="space-y-3">
{/* 运动描述输入 */}
<div>
<label className="text-xs font-medium text-gray-700 mb-1 block"></label>
<textarea
value={motionPrompt}
onChange={(e) => setMotionPrompt(e.target.value)}
placeholder="描述图片中的运动效果,如:轻微摇摆、缓慢旋转、波浪效果..."
className="w-full h-16 text-xs border border-gray-300 rounded px-2 py-1 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* 视频设置 */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-gray-700 mb-1 block">()</label>
<input
type="number"
min="1"
max="10"
value={settings.duration}
onChange={(e) => setSettings(prev => ({ ...prev, duration: parseInt(e.target.value) || 3 }))}
className="w-full text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="text-xs font-medium text-gray-700 mb-1 block"></label>
<select
value={settings.fps}
onChange={(e) => setSettings(prev => ({ ...prev, fps: parseInt(e.target.value) }))}
className="w-full text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value={12}>12 FPS</option>
<option value={24}>24 FPS</option>
<option value={30}>30 FPS</option>
</select>
</div>
</div>
{/* 质量选择 */}
<div>
<label className="text-xs font-medium text-gray-700 mb-1 block"></label>
<select
value={settings.quality}
onChange={(e) => setSettings(prev => ({ ...prev, quality: e.target.value as any }))}
className="w-full text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{qualityOptions.map(option => (
<option key={option.value} value={option.value}>
{option.icon} {option.label}
</option>
))}
</select>
</div>
{/* 源图片预览 */}
{data.sourceImage && (
<div>
<div className="text-xs font-medium text-gray-700 mb-1">:</div>
<img
src={data.sourceImage}
alt="源图片"
className="w-full h-20 object-cover rounded border"
/>
</div>
)}
{/* 处理状态 */}
{status === 'loading' && (
<div className="text-xs text-blue-600 text-center">
🎬 ... {Math.round(progress)}%
<div className="text-xs text-gray-500 mt-1">
{settings.duration * 10}
</div>
</div>
)}
{/* 生成结果 */}
{data.result && status === 'success' && (
<div>
<div className="text-xs font-medium text-green-600 mb-1">:</div>
<video
src={data.result}
controls
className="w-full h-24 rounded border"
poster={data.sourceImage}
>
</video>
</div>
)}
{/* 等待输入提示 */}
{!data.sourceImage && (
<div className="text-xs text-gray-400 text-center py-4">
...
</div>
)}
{/* 错误状态 */}
{status === 'error' && (
<div className="text-xs text-red-600 bg-red-50 p-2 rounded">
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,190 @@
import React, { useState, useRef } from 'react';
import { BaseNode } from '../BaseNode';
import { NodePort } from '../../../types/canvas';
interface VideoPlayerNodeProps {
id: string;
data: {
videoUrl?: string;
inputVideo?: string;
result?: string;
};
}
export const VideoPlayerNode: React.FC<VideoPlayerNodeProps> = ({ id, data }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const inputs: NodePort[] = [
{ id: 'video-input', type: 'video', label: '视频输入', position: 'input' }
];
const videoUrl = data.inputVideo || data.videoUrl || data.result;
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const vol = parseFloat(e.target.value);
setVolume(vol);
if (videoRef.current) {
videoRef.current.volume = vol;
}
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const downloadVideo = () => {
if (videoUrl) {
const link = document.createElement('a');
link.href = videoUrl;
link.download = `video_${Date.now()}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
return (
<BaseNode
id={id}
title="视频播放器"
inputs={inputs}
className="min-w-80"
>
<div className="space-y-3">
{videoUrl ? (
<>
{/* 视频播放区域 */}
<div className="relative bg-black rounded overflow-hidden">
<video
ref={videoRef}
src={videoUrl}
className="w-full h-48 object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
{/* 播放按钮覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity">
<button
onClick={handlePlayPause}
className="bg-white bg-opacity-80 rounded-full p-3 hover:bg-opacity-100 transition-all"
>
{isPlaying ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
)}
</button>
</div>
</div>
{/* 控制栏 */}
<div className="space-y-2">
{/* 进度条 */}
<div className="flex items-center space-x-2 text-xs">
<span className="text-gray-500 min-w-10">{formatTime(currentTime)}</span>
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleSeek}
className="flex-1 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-gray-500 min-w-10">{formatTime(duration)}</span>
</div>
{/* 控制按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<button
onClick={handlePlayPause}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
{isPlaying ? '⏸️' : '▶️'}
</button>
{/* 音量控制 */}
<div className="flex items-center space-x-1">
<span className="text-xs">🔊</span>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
{/* 下载按钮 */}
<button
onClick={downloadVideo}
className="text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 border border-green-300 rounded hover:bg-green-50"
>
📥
</button>
</div>
</div>
{/* 视频信息 */}
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
<div>: {formatTime(duration)}</div>
<div className="truncate">URL: {videoUrl}</div>
</div>
</>
) : (
<div className="text-xs text-gray-400 text-center py-12 border-2 border-dashed border-gray-300 rounded">
...
</div>
)}
</div>
</BaseNode>
);
};

View File

@@ -0,0 +1,243 @@
import { useCallback } from 'react';
import { useCanvasStore } from '../store/canvasStore';
import { ProcessingEngine } from '../services/processingEngine';
import { aiService } from '../services/aiServices';
import { DataFlow, NodeType } from '../types/canvas';
// 创建全局处理引擎实例
const processingEngine = new ProcessingEngine();
/**
* 节点处理 Hook
*/
export const useNodeProcessing = () => {
const {
startProcessing,
updateProgress,
completeProcessing,
errorProcessing,
cancelProcessing,
updateNode
} = useCanvasStore();
/**
* 处理节点
*/
const processNode = useCallback(async (
nodeId: string,
nodeType: NodeType,
inputData: DataFlow
) => {
try {
// 开始处理
startProcessing(nodeId);
// 使用处理引擎处理节点
const result = await processingEngine.processNode(nodeId, nodeType, inputData, {
onProgress: (progress) => {
updateProgress(nodeId, progress);
},
onComplete: (result) => {
completeProcessing(nodeId, result);
// 更新节点数据
updateNode(nodeId, {
data: (prevData: any) => ({
...prevData,
result: result
})
});
},
onError: (error) => {
errorProcessing(nodeId, error);
}
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Processing failed';
errorProcessing(nodeId, errorMessage);
throw error;
}
}, [startProcessing, updateProgress, completeProcessing, errorProcessing, updateNode]);
/**
* 取消节点处理
*/
const cancelNodeProcessing = useCallback((nodeId: string) => {
processingEngine.cancelProcessing(nodeId);
cancelProcessing(nodeId);
}, [cancelProcessing]);
/**
* 检查节点是否正在处理
*/
const isNodeProcessing = useCallback((nodeId: string) => {
return processingEngine.isProcessing(nodeId);
}, []);
/**
* 获取所有正在处理的节点
*/
const getActiveProcesses = useCallback(() => {
return processingEngine.getActiveProcesses();
}, []);
return {
processNode,
cancelNodeProcessing,
isNodeProcessing,
getActiveProcesses
};
};
/**
* 批量处理 Hook
*/
export const useBatchProcessing = () => {
const { updateProgress, updateNode } = useCanvasStore();
/**
* 处理批量数据
*/
const processBatch = useCallback(async (
nodeId: string,
items: any[],
processType: 'prompt-optimize' | 'image-generate' | 'image-edit' | 'video-generate'
) => {
try {
const results = await aiService.processBatch(
items,
processType,
(completed, total) => {
const progress = (completed / total) * 100;
updateProgress(nodeId, progress);
// 更新节点显示的批量结果
updateNode(nodeId, {
data: (prevData: any) => ({
...prevData,
results: items.slice(0, completed).map((item, index) => ({
id: `item-${index}`,
filename: item.filename || `Item ${index + 1}`,
status: 'success',
result: results[index],
thumbnail: typeof results[index] === 'string' ? results[index] : undefined
})),
totalCount: total,
completedCount: completed
})
});
}
);
return results;
} catch (error) {
throw error;
}
}, [updateProgress, updateNode]);
return {
processBatch
};
};
/**
* 节点状态 Hook
*/
export const useNodeStatus = (nodeId: string) => {
const { processingNodes } = useCanvasStore();
const processingInfo = processingNodes.get(nodeId);
return {
status: processingInfo?.status || 'idle',
progress: processingInfo?.progress || 0,
error: processingInfo?.error,
startTime: processingInfo?.startTime,
estimatedTime: processingInfo?.estimatedTime
};
};
/**
* 连接验证 Hook
*/
export const useConnectionValidation = () => {
const { nodes } = useCanvasStore();
/**
* 验证连接是否有效
*/
const validateConnection = useCallback((connection: any) => {
const sourceNode = nodes.find(n => n.id === connection.source);
const targetNode = nodes.find(n => n.id === connection.target);
if (!sourceNode || !targetNode) return false;
// 基本的类型兼容性检查
const sourceType = getNodeOutputType(sourceNode.type);
const targetType = getNodeInputType(targetNode.type);
// 允许的连接类型
const compatibleConnections = [
['text', 'text'],
['image', 'image'],
['video', 'video'],
['batch', 'batch'],
['text', 'batch'], // 文本可以转为批量
['image', 'batch'], // 图片可以转为批量
['batch', 'text'], // 批量可以取第一个作为单个
['batch', 'image'] // 批量可以取第一个作为单个
];
return compatibleConnections.some(([src, tgt]) =>
sourceType === src && targetType === tgt
);
}, [nodes]);
return {
validateConnection
};
};
/**
* 获取节点输出类型
*/
function getNodeOutputType(nodeType: string): string {
switch (nodeType) {
case 'textInput':
case 'promptOptimize':
return 'text';
case 'imageUpload':
case 'imageGenerate':
case 'imageEdit':
return 'image';
case 'videoGenerate':
return 'video';
case 'folderUpload':
return 'batch';
default:
return 'text';
}
}
/**
* 获取节点输入类型
*/
function getNodeInputType(nodeType: string): string {
switch (nodeType) {
case 'promptOptimize':
case 'textDisplay':
return 'text';
case 'imageGenerate':
case 'imageEdit':
case 'imageDisplay':
return 'image';
case 'videoGenerate':
case 'videoPlayer':
return 'video';
case 'batchGrid':
return 'batch';
default:
return 'text';
}
}

View File

@@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { ReactFlowProvider } from 'reactflow';
import { CanvasContainer } from '../components/canvas/CanvasContainer';
import { CanvasToolbar } from '../components/canvas/CanvasToolbar';
import { QuickStartTemplates } from '../components/canvas/QuickStartTemplates';
import { useCanvasStore } from '../store/canvasStore';
const CanvasTool: React.FC = () => {
const [showQuickStart, setShowQuickStart] = useState(true);
const { nodes, edges, setNodes, setEdges } = useCanvasStore();
const handleSave = () => {
try {
const canvasData = {
nodes,
edges,
timestamp: new Date().toISOString(),
version: '1.0'
};
const dataStr = JSON.stringify(canvasData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ai-canvas-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('保存失败:', error);
alert('保存失败,请重试');
}
};
const handleLoad = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
try {
const canvasData = JSON.parse(event.target?.result as string);
// 验证数据格式
if (canvasData.nodes && canvasData.edges) {
setNodes(canvasData.nodes);
setEdges(canvasData.edges);
console.log('画布加载成功');
} else {
throw new Error('无效的画布文件格式');
}
} catch (error) {
console.error('加载失败:', error);
alert('加载失败,请检查文件格式');
}
};
reader.readAsText(file);
}
};
input.click();
};
const handleExport = () => {
try {
// 收集所有节点的结果
const results = nodes
.filter(node => node.data?.result)
.map(node => ({
id: node.id,
type: node.type,
result: node.data.result,
position: node.position
}));
if (results.length === 0) {
alert('没有可导出的结果');
return;
}
const exportData = {
results,
exportTime: new Date().toISOString(),
totalNodes: nodes.length,
completedNodes: results.length
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ai-canvas-results-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('导出失败:', error);
alert('导出失败,请重试');
}
};
return (
<ReactFlowProvider>
<div className="fixed inset-0 flex flex-col bg-gray-50 z-10">
{/* 工具栏 */}
<CanvasToolbar
onSave={handleSave}
onLoad={handleLoad}
onExport={handleExport}
/>
{/* 主画布区域 */}
<div className="flex-1 relative overflow-hidden">
<CanvasContainer />
{/* 快速开始模板 */}
{showQuickStart && nodes.length === 0 && (
<QuickStartTemplates onClose={() => setShowQuickStart(false)} />
)}
</div>
{/* 使用说明 */}
<div className="absolute bottom-4 left-4 bg-white/90 backdrop-blur-sm rounded-lg p-3 shadow-lg border border-gray-200 max-w-sm">
<h3 className="text-sm font-semibold text-gray-800 mb-2">使</h3>
<ul className="text-xs text-gray-600 space-y-1">
<li> </li>
<li> 线</li>
<li> 线</li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</ReactFlowProvider>
);
};
export default CanvasTool;

View File

@@ -0,0 +1,391 @@
import { DataFlow } from '../types/canvas';
/**
* AI服务配置接口
*/
export interface AIServiceConfig {
openai: {
apiKey: string;
baseURL: string;
model: string;
};
imageGeneration: {
provider: 'openai' | 'midjourney' | 'stable-diffusion';
apiKey: string;
defaultSettings: any;
};
videoGeneration: {
provider: 'runway' | 'pika' | 'stable-video';
apiKey: string;
defaultSettings: any;
};
}
/**
* AI服务管理器
*/
export class AIServiceManager {
private config: AIServiceConfig;
private baseURL: string;
constructor(config: AIServiceConfig) {
this.config = config;
this.baseURL = config.openai.baseURL;
}
/**
* 提示词优化
*/
async optimizePrompt(
prompt: string,
options: {
style?: 'creative' | 'detailed' | 'concise';
language?: 'zh' | 'en';
} = {},
onProgress?: (progress: number) => void
): Promise<string> {
try {
// 模拟进度更新
onProgress?.(20);
const response = await this.makeRequest('/api/prompt/optimize', {
prompt,
style: options.style || 'detailed',
language: options.language || 'zh'
});
onProgress?.(100);
return response.optimizedPrompt;
} catch (error) {
// 如果API调用失败使用本地模拟
return this.mockOptimizePrompt(prompt, onProgress);
}
}
/**
* 图片生成
*/
async generateImage(
prompt: string,
options: {
size?: '512x512' | '1024x1024';
style?: 'realistic' | 'artistic' | 'anime';
quality?: 'standard' | 'hd';
} = {},
onProgress?: (progress: number) => void
): Promise<string> {
try {
onProgress?.(10);
const response = await this.makeRequest('/api/image/generate', {
prompt,
size: options.size || '512x512',
style: options.style || 'realistic',
quality: options.quality || 'standard'
});
// 如果返回任务ID轮询进度
if (response.taskId) {
return await this.pollImageProgress(response.taskId, onProgress);
}
onProgress?.(100);
return response.imageUrl;
} catch (error) {
// 如果API调用失败使用本地模拟
return this.mockGenerateImage(prompt, onProgress);
}
}
/**
* 图片编辑
*/
async editImage(
imageUrl: string,
instruction: string,
options: {
editType?: 'style-transfer' | 'background-remove' | 'enhance' | 'resize';
} = {},
onProgress?: (progress: number) => void
): Promise<string> {
try {
onProgress?.(15);
const response = await this.makeRequest('/api/image/edit', {
imageUrl,
instruction,
editType: options.editType || 'enhance'
});
if (response.taskId) {
return await this.pollImageProgress(response.taskId, onProgress);
}
onProgress?.(100);
return response.editedImageUrl;
} catch (error) {
return this.mockEditImage(imageUrl, onProgress);
}
}
/**
* 视频生成
*/
async generateVideo(
imageUrl: string,
motionPrompt: string,
options: {
duration?: number;
fps?: number;
quality?: 'low' | 'medium' | 'high';
} = {},
onProgress?: (progress: number) => void
): Promise<string> {
try {
onProgress?.(5);
const response = await this.makeRequest('/api/video/generate', {
imageUrl,
motionPrompt,
duration: options.duration || 3,
fps: options.fps || 24,
quality: options.quality || 'medium'
});
if (response.taskId) {
return await this.pollVideoProgress(response.taskId, onProgress);
}
onProgress?.(100);
return response.videoUrl;
} catch (error) {
return this.mockGenerateVideo(imageUrl, onProgress);
}
}
/**
* 批量处理
*/
async processBatch(
items: any[],
processType: 'prompt-optimize' | 'image-generate' | 'image-edit' | 'video-generate',
onProgress?: (completed: number, total: number) => void
): Promise<any[]> {
const results = [];
const total = items.length;
for (let i = 0; i < total; i++) {
try {
let result;
const item = items[i];
switch (processType) {
case 'prompt-optimize':
result = await this.optimizePrompt(item.prompt || item);
break;
case 'image-generate':
result = await this.generateImage(item.prompt || item);
break;
case 'image-edit':
result = await this.editImage(item.imageUrl, item.instruction);
break;
case 'video-generate':
result = await this.generateVideo(item.imageUrl, item.motionPrompt);
break;
default:
throw new Error(`Unknown process type: ${processType}`);
}
results.push(result);
onProgress?.(i + 1, total);
} catch (error) {
results.push({ error: error instanceof Error ? error.message : 'Processing failed' });
onProgress?.(i + 1, total);
}
}
return results;
}
/**
* 轮询图片生成进度
*/
private async pollImageProgress(taskId: string, onProgress?: (progress: number) => void): Promise<string> {
while (true) {
try {
const status = await this.makeRequest(`/api/image/progress/${taskId}`, null, 'GET');
if (onProgress) onProgress(status.progress || 0);
if (status.status === 'completed') {
return status.imageUrl;
}
if (status.status === 'failed') {
throw new Error(status.error || 'Image generation failed');
}
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
// 如果轮询失败,返回模拟结果
return this.mockGenerateImage('', onProgress);
}
}
}
/**
* 轮询视频生成进度
*/
private async pollVideoProgress(taskId: string, onProgress?: (progress: number) => void): Promise<string> {
while (true) {
try {
const status = await this.makeRequest(`/api/video/progress/${taskId}`, null, 'GET');
if (onProgress) onProgress(status.progress || 0);
if (status.status === 'completed') {
return status.videoUrl;
}
if (status.status === 'failed') {
throw new Error(status.error || 'Video generation failed');
}
await new Promise(resolve => setTimeout(resolve, 3000));
} catch (error) {
return this.mockGenerateVideo('', onProgress);
}
}
}
/**
* 发起API请求
*/
private async makeRequest(endpoint: string, data?: any, method: 'GET' | 'POST' = 'POST'): Promise<any> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.openai.apiKey}`
},
body: data ? JSON.stringify(data) : undefined
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// 模拟方法 - 当API不可用时使用
private async mockOptimizePrompt(prompt: string, onProgress?: (progress: number) => void): Promise<string> {
for (let i = 0; i <= 100; i += 20) {
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 200));
}
return `优化后的提示词: ${prompt}添加了更多细节描述包括光线、构图、风格等元素使其更适合AI图像生成。`;
}
private async mockGenerateImage(prompt: string, onProgress?: (progress: number) => void): Promise<string> {
for (let i = 0; i <= 100; i += 10) {
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 300));
}
return `https://picsum.photos/512/512?random=${Date.now()}`;
}
private async mockEditImage(imageUrl: string, onProgress?: (progress: number) => void): Promise<string> {
for (let i = 0; i <= 100; i += 15) {
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 250));
}
return `https://picsum.photos/512/512?random=${Date.now()}&edit=true`;
}
private async mockGenerateVideo(imageUrl: string, onProgress?: (progress: number) => void): Promise<string> {
for (let i = 0; i <= 100; i += 5) {
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 500));
}
return `https://sample-videos.com/zip/10/mp4/SampleVideo_360x240_1mb.mp4?t=${Date.now()}`;
}
}
/**
* 错误处理工具
*/
export class ErrorHandler {
static async withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)));
}
}
throw new Error('Max retries exceeded');
}
static handleAPIError(error: any): string {
if (error.response?.status === 429) {
return '请求过于频繁,请稍后重试';
}
if (error.response?.status === 401) {
return 'API密钥无效请检查配置';
}
if (error.response?.status >= 500) {
return '服务器错误,请稍后重试';
}
return error.message || '未知错误';
}
}
/**
* 加载AI服务配置
*/
export const loadAIConfig = (): AIServiceConfig => {
// 从本地存储加载配置,如果没有则使用默认值
const savedConfig = localStorage.getItem('aiConfig');
if (savedConfig) {
try {
return JSON.parse(savedConfig);
} catch (error) {
console.warn('Failed to parse saved AI config:', error);
}
}
// 默认配置
return {
openai: {
apiKey: '',
baseURL: 'https://api.openai.com/v1',
model: 'gpt-4'
},
imageGeneration: {
provider: 'openai',
apiKey: '',
defaultSettings: {
size: '512x512',
quality: 'standard'
}
},
videoGeneration: {
provider: 'runway',
apiKey: '',
defaultSettings: {
duration: 3,
fps: 24,
quality: 'medium'
}
}
};
};
// 创建默认的AI服务实例
export const aiService = new AIServiceManager(loadAIConfig());

View File

@@ -0,0 +1,299 @@
import { DataFlow, NodeType, ProcessingStatus } from '../types/canvas';
interface ProcessingCallbacks {
onProgress?: (progress: number) => void;
onComplete?: (result: any) => void;
onError?: (error: string) => void;
}
/**
* 异步处理引擎 - 负责管理节点的异步处理任务
*/
export class ProcessingEngine {
private activeProcesses = new Map<string, AbortController>();
private processingCallbacks = new Map<string, ProcessingCallbacks>();
/**
* 处理单个节点
*/
async processNode(
nodeId: string,
nodeType: NodeType,
inputData: DataFlow,
callbacks: ProcessingCallbacks = {}
): Promise<DataFlow> {
// 创建取消控制器
const controller = new AbortController();
this.activeProcesses.set(nodeId, controller);
this.processingCallbacks.set(nodeId, callbacks);
try {
// 根据数据类型选择处理方式
if (inputData.type === 'batch') {
return await this.processBatch(nodeId, nodeType, inputData, controller.signal);
} else {
return await this.processSingle(nodeId, nodeType, inputData, controller.signal);
}
} finally {
// 清理资源
this.activeProcesses.delete(nodeId);
this.processingCallbacks.delete(nodeId);
}
}
/**
* 处理单个数据项
*/
private async processSingle(
nodeId: string,
nodeType: NodeType,
inputData: DataFlow,
signal: AbortSignal
): Promise<DataFlow> {
const callbacks = this.processingCallbacks.get(nodeId);
try {
let result: any;
switch (nodeType) {
case 'promptOptimize':
result = await this.processPromptOptimize(inputData.content, signal, callbacks?.onProgress);
break;
case 'imageGenerate':
result = await this.processImageGenerate(inputData.content, signal, callbacks?.onProgress);
break;
case 'imageEdit':
result = await this.processImageEdit(inputData.content, signal, callbacks?.onProgress);
break;
case 'videoGenerate':
result = await this.processVideoGenerate(inputData.content, signal, callbacks?.onProgress);
break;
default:
throw new Error(`Unsupported node type: ${nodeType}`);
}
const resultData: DataFlow = {
type: 'single',
dataType: this.getOutputDataType(nodeType),
content: result,
metadata: {
timestamp: Date.now()
}
};
callbacks?.onComplete?.(result);
return resultData;
} catch (error) {
if (signal.aborted) {
throw new Error('Processing cancelled');
}
const errorMessage = error instanceof Error ? error.message : 'Processing failed';
callbacks?.onError?.(errorMessage);
throw error;
}
}
/**
* 处理批量数据
*/
private async processBatch(
nodeId: string,
nodeType: NodeType,
batchData: DataFlow,
signal: AbortSignal
): Promise<DataFlow> {
const callbacks = this.processingCallbacks.get(nodeId);
const items = batchData.content;
const results = [];
const total = items.length;
for (let i = 0; i < total; i++) {
if (signal.aborted) {
throw new Error('Batch processing cancelled');
}
try {
// 更新整体进度
const overallProgress = (i / total) * 100;
callbacks?.onProgress?.(overallProgress);
// 处理单个项目
const itemData: DataFlow = {
type: 'single',
dataType: batchData.dataType,
content: items[i]
};
const result = await this.processSingle(nodeId, nodeType, itemData, signal);
results.push({
id: `item-${i}`,
filename: items[i].filename || `Item ${i + 1}`,
status: 'success' as ProcessingStatus,
result: result.content,
thumbnail: this.generateThumbnail(result.content, this.getOutputDataType(nodeType))
});
} catch (error) {
results.push({
id: `item-${i}`,
filename: items[i].filename || `Item ${i + 1}`,
status: 'error' as ProcessingStatus,
error: error instanceof Error ? error.message : 'Processing failed'
});
}
}
// 完成进度
callbacks?.onProgress?.(100);
return {
type: 'batch',
dataType: this.getOutputDataType(nodeType),
content: results,
metadata: {
totalItems: total,
completedItems: results.filter(r => r.status === 'success').length,
failedItems: results.filter(r => r.status === 'error').length,
timestamp: Date.now()
}
};
}
/**
* 取消处理
*/
cancelProcessing(nodeId: string): void {
const controller = this.activeProcesses.get(nodeId);
if (controller) {
controller.abort();
this.activeProcesses.delete(nodeId);
this.processingCallbacks.delete(nodeId);
}
}
/**
* 检查是否正在处理
*/
isProcessing(nodeId: string): boolean {
return this.activeProcesses.has(nodeId);
}
/**
* 获取所有正在处理的节点
*/
getActiveProcesses(): string[] {
return Array.from(this.activeProcesses.keys());
}
/**
* 提示词优化处理
*/
private async processPromptOptimize(
prompt: string,
signal: AbortSignal,
onProgress?: (progress: number) => void
): Promise<string> {
// 模拟AI处理延迟和进度
for (let i = 0; i <= 100; i += 10) {
if (signal.aborted) throw new Error('Processing cancelled');
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 200));
}
// 模拟优化结果
const optimizedPrompt = `优化后的提示词: ${prompt}添加了更多细节描述包括光线、构图、风格等元素使其更适合AI图像生成。`;
return optimizedPrompt;
}
/**
* 图片生成处理
*/
private async processImageGenerate(
prompt: string,
signal: AbortSignal,
onProgress?: (progress: number) => void
): Promise<string> {
// 模拟图片生成过程
for (let i = 0; i <= 100; i += 5) {
if (signal.aborted) throw new Error('Processing cancelled');
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 300));
}
// 模拟生成的图片URL
const mockImageUrl = `https://picsum.photos/512/512?random=${Date.now()}`;
return mockImageUrl;
}
/**
* 图片编辑处理
*/
private async processImageEdit(
imageData: any,
signal: AbortSignal,
onProgress?: (progress: number) => void
): Promise<string> {
// 模拟图片编辑过程
for (let i = 0; i <= 100; i += 8) {
if (signal.aborted) throw new Error('Processing cancelled');
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 250));
}
// 模拟编辑后的图片URL
const editedImageUrl = `https://picsum.photos/512/512?random=${Date.now()}&edit=true`;
return editedImageUrl;
}
/**
* 视频生成处理
*/
private async processVideoGenerate(
imageData: any,
signal: AbortSignal,
onProgress?: (progress: number) => void
): Promise<string> {
// 模拟视频生成过程(较长时间)
for (let i = 0; i <= 100; i += 2) {
if (signal.aborted) throw new Error('Processing cancelled');
onProgress?.(i);
await new Promise(resolve => setTimeout(resolve, 500));
}
// 模拟生成的视频URL
const mockVideoUrl = `https://sample-videos.com/zip/10/mp4/SampleVideo_360x240_1mb.mp4?t=${Date.now()}`;
return mockVideoUrl;
}
/**
* 获取输出数据类型
*/
private getOutputDataType(nodeType: NodeType): 'text' | 'image' | 'video' | 'batch' {
switch (nodeType) {
case 'promptOptimize':
return 'text';
case 'imageGenerate':
case 'imageEdit':
return 'image';
case 'videoGenerate':
return 'video';
default:
return 'text';
}
}
/**
* 生成缩略图
*/
private generateThumbnail(content: any, dataType: string): string | undefined {
if (dataType === 'image' && typeof content === 'string') {
return content; // 图片URL直接作为缩略图
}
if (dataType === 'video' && typeof content === 'string') {
// 为视频生成缩略图(这里简化处理)
return `https://picsum.photos/200/150?random=${Date.now()}`;
}
return undefined;
}
}

View File

@@ -0,0 +1,227 @@
import { create } from 'zustand';
import { Node, Edge, Connection, addEdge, applyNodeChanges, applyEdgeChanges } from 'reactflow';
import { NodeType, ProcessingStatus, DataFlow } from '../types/canvas';
interface CanvasState {
nodes: Node[];
edges: Edge[];
selectedNodes: string[];
viewport: { x: number; y: number; zoom: number };
showNodeSelector: boolean;
nodeSelectorPosition: { x: number; y: number };
processingNodes: Map<string, ProcessingInfo>;
}
interface ProcessingInfo {
status: ProcessingStatus;
progress: number;
startTime: number;
estimatedTime?: number;
error?: string;
}
interface CanvasActions {
// 节点操作
addNode: (node: Node) => void;
updateNode: (nodeId: string, updates: Partial<Node>) => void;
deleteNode: (nodeId: string) => void;
setNodes: (nodes: Node[]) => void;
onNodesChange: (changes: any[]) => void;
// 连线操作
addEdge: (edge: Edge) => void;
deleteEdge: (edgeId: string) => void;
setEdges: (edges: Edge[]) => void;
onEdgesChange: (changes: any[]) => void;
onConnect: (connection: Connection) => void;
// 选择操作
setSelectedNodes: (nodeIds: string[]) => void;
clearSelection: () => void;
// 视图操作
setViewport: (viewport: { x: number; y: number; zoom: number }) => void;
// 节点选择器
showNodeSelectorAt: (position: { x: number; y: number }) => void;
hideNodeSelector: () => void;
// 处理状态
startProcessing: (nodeId: string) => void;
updateProgress: (nodeId: string, progress: number) => void;
completeProcessing: (nodeId: string, result: any) => void;
errorProcessing: (nodeId: string, error: string) => void;
cancelProcessing: (nodeId: string) => void;
// 画布操作
clearCanvas: () => void;
resetCanvas: () => void;
// 获取活动处理
getActiveProcesses: () => string[];
}
export const useCanvasStore = create<CanvasState & CanvasActions>((set, get) => ({
// 初始状态
nodes: [],
edges: [],
selectedNodes: [],
viewport: { x: 0, y: 0, zoom: 1 },
showNodeSelector: false,
nodeSelectorPosition: { x: 0, y: 0 },
processingNodes: new Map(),
// 节点操作
addNode: (node: Node) => set(state => ({
nodes: [...state.nodes, node]
})),
updateNode: (nodeId: string, updates: Partial<Node>) => set(state => ({
nodes: state.nodes.map(node =>
node.id === nodeId ? { ...node, ...updates } : node
)
})),
deleteNode: (nodeId: string) => set(state => ({
nodes: state.nodes.filter(node => node.id !== nodeId),
edges: state.edges.filter(edge =>
edge.source !== nodeId && edge.target !== nodeId
)
})),
setNodes: (nodes: Node[]) => set({ nodes }),
onNodesChange: (changes: any[]) => set(state => ({
nodes: applyNodeChanges(changes, state.nodes)
})),
// 连线操作
addEdge: (edge: Edge) => set(state => ({
edges: [...state.edges, edge]
})),
deleteEdge: (edgeId: string) => set(state => ({
edges: state.edges.filter(edge => edge.id !== edgeId)
})),
setEdges: (edges: Edge[]) => set({ edges }),
onEdgesChange: (changes: any[]) => set(state => ({
edges: applyEdgeChanges(changes, state.edges)
})),
onConnect: (connection: Connection) => set(state => {
const newEdge = {
...connection,
id: `edge-${connection.source}-${connection.target}`,
animated: true,
style: { strokeWidth: 2 }
};
return {
edges: addEdge(newEdge, state.edges)
};
}),
// 选择操作
setSelectedNodes: (nodeIds: string[]) => set({ selectedNodes: nodeIds }),
clearSelection: () => set({ selectedNodes: [] }),
// 视图操作
setViewport: (viewport: { x: number; y: number; zoom: number }) => set({ viewport }),
// 节点选择器
showNodeSelectorAt: (position: { x: number; y: number }) => set({
showNodeSelector: true,
nodeSelectorPosition: position
}),
hideNodeSelector: () => set({ showNodeSelector: false }),
// 处理状态
startProcessing: (nodeId: string) => set(state => ({
processingNodes: new Map(state.processingNodes.set(nodeId, {
status: 'loading',
progress: 0,
startTime: Date.now()
}))
})),
updateProgress: (nodeId: string, progress: number) => set(state => {
const current = state.processingNodes.get(nodeId);
if (current) {
return {
processingNodes: new Map(state.processingNodes.set(nodeId, {
...current,
progress
}))
};
}
return state;
}),
completeProcessing: (nodeId: string, result: any) => set(state => {
const current = state.processingNodes.get(nodeId);
if (current) {
// 更新节点数据
const updatedNodes = state.nodes.map(node =>
node.id === nodeId ? { ...node, data: { ...node.data, result } } : node
);
return {
nodes: updatedNodes,
processingNodes: new Map(state.processingNodes.set(nodeId, {
...current,
status: 'success',
progress: 100
}))
};
}
return state;
}),
errorProcessing: (nodeId: string, error: string) => set(state => {
const current = state.processingNodes.get(nodeId);
if (current) {
return {
processingNodes: new Map(state.processingNodes.set(nodeId, {
...current,
status: 'error',
error
}))
};
}
return state;
}),
cancelProcessing: (nodeId: string) => set(state => {
const newMap = new Map(state.processingNodes);
newMap.delete(nodeId);
return { processingNodes: newMap };
}),
// 画布操作
clearCanvas: () => set({
nodes: [],
edges: [],
selectedNodes: [],
processingNodes: new Map()
}),
resetCanvas: () => set({
nodes: [],
edges: [],
selectedNodes: [],
viewport: { x: 0, y: 0, zoom: 1 },
showNodeSelector: false,
processingNodes: new Map()
}),
// 获取活动处理
getActiveProcesses: () => {
const state = get();
return Array.from(state.processingNodes.keys()).filter(nodeId => {
const info = state.processingNodes.get(nodeId);
return info?.status === 'loading';
});
}
}));

View File

@@ -0,0 +1,121 @@
// Canvas Tool 相关类型定义
export type NodeType =
| 'textInput' | 'imageUpload' | 'folderUpload' // 输入节点
| 'promptOptimize' | 'imageGenerate' | 'imageEdit' | 'videoGenerate' // 处理节点
| 'textDisplay' | 'imageDisplay' | 'videoPlayer' | 'batchGrid'; // 输出节点
export type DataType = 'text' | 'image' | 'video' | 'batch';
export type ProcessingStatus = 'idle' | 'loading' | 'success' | 'error';
export interface NodePort {
id: string;
type: DataType;
label: string;
position: 'input' | 'output';
}
export interface DataFlow {
type: 'single' | 'batch';
dataType: DataType;
content: any;
metadata?: {
filename?: string;
size?: number;
timestamp?: number;
};
}
export interface BaseNodeData {
id: string;
type: NodeType;
position: { x: number; y: number };
data: any;
status: ProcessingStatus;
progress?: number;
error?: string;
inputs?: NodePort[];
outputs?: NodePort[];
}
export interface ProcessingInfo {
status: ProcessingStatus;
progress: number;
startTime: number;
estimatedTime?: number;
error?: string;
}
export interface BatchResult {
id: string;
filename: string;
status: ProcessingStatus;
result?: any;
thumbnail?: string;
error?: string;
}
export interface NodeCategory {
name: string;
nodes: Array<{
type: NodeType;
label: string;
icon: string;
description: string;
}>;
}
// 节点数据接口
export interface TextInputNodeData {
text: string;
placeholder: string;
}
export interface ImageUploadNodeData {
image: File | null;
preview: string;
}
export interface FolderUploadNodeData {
files: File[];
fileCount: number;
totalSize: number;
}
export interface PromptOptimizeNodeData {
inputPrompt: string;
optimizedPrompt: string;
optimizationSettings: {
style: 'creative' | 'detailed' | 'concise';
language: 'zh' | 'en';
};
}
export interface ImageGenerateNodeData {
prompt: string;
generatedImage: string;
settings: {
size: '512x512' | '1024x1024';
style: 'realistic' | 'artistic' | 'anime';
quality: 'standard' | 'hd';
};
}
export interface ImageEditNodeData {
originalImage: string;
editedImage: string;
editType: 'style-transfer' | 'background-remove' | 'enhance' | 'resize';
editSettings: any;
}
export interface VideoGenerateNodeData {
sourceImage: string;
generatedVideo: string;
motionPrompt: string;
settings: {
duration: number;
fps: number;
quality: 'low' | 'medium' | 'high';
};
}

View File

@@ -0,0 +1,315 @@
import { DataFlow, NodeType, ProcessingStatus } from '../types/canvas';
import { aiService } from '../services/aiServices';
export interface BatchItem {
id: string;
filename: string;
data: any;
status: ProcessingStatus;
result?: any;
error?: string;
thumbnail?: string;
progress?: number;
}
export interface BatchProcessingOptions {
concurrency: number; // 并发数
retryAttempts: number; // 重试次数
retryDelay: number; // 重试延迟(ms)
onProgress?: (completed: number, total: number, currentItem?: BatchItem) => void;
onItemComplete?: (item: BatchItem) => void;
onItemError?: (item: BatchItem, error: string) => void;
}
/**
* 批量处理引擎 - 支持并发处理和进度追踪
*/
export class BatchProcessor {
private activeProcesses = new Map<string, AbortController>();
private processingQueue: BatchItem[] = [];
private completedItems: BatchItem[] = [];
private failedItems: BatchItem[] = [];
/**
* 处理批量数据
*/
async processBatch(
items: any[],
nodeType: NodeType,
options: Partial<BatchProcessingOptions> = {}
): Promise<BatchItem[]> {
const config: BatchProcessingOptions = {
concurrency: 3,
retryAttempts: 2,
retryDelay: 1000,
...options
};
// 初始化批量项目
const batchItems: BatchItem[] = items.map((item, index) => ({
id: `batch-${Date.now()}-${index}`,
filename: item.filename || item.name || `Item ${index + 1}`,
data: item,
status: 'idle' as ProcessingStatus
}));
this.processingQueue = [...batchItems];
this.completedItems = [];
this.failedItems = [];
// 启动并发处理
const workers = Array.from({ length: config.concurrency }, (_, i) =>
this.processWorker(i, nodeType, config)
);
// 等待所有工作线程完成
await Promise.all(workers);
// 返回所有结果
return [...this.completedItems, ...this.failedItems];
}
/**
* 工作线程 - 处理队列中的项目
*/
private async processWorker(
workerId: number,
nodeType: NodeType,
config: BatchProcessingOptions
): Promise<void> {
while (this.processingQueue.length > 0) {
const item = this.processingQueue.shift();
if (!item) break;
// 更新状态为处理中
item.status = 'loading';
item.progress = 0;
try {
// 处理单个项目
const result = await this.processItem(item, nodeType, config);
// 成功完成
item.status = 'success';
item.result = result;
item.progress = 100;
item.thumbnail = this.generateThumbnail(result, nodeType);
this.completedItems.push(item);
config.onItemComplete?.(item);
} catch (error) {
// 处理失败
item.status = 'error';
item.error = error instanceof Error ? error.message : 'Processing failed';
item.progress = 0;
this.failedItems.push(item);
config.onItemError?.(item, item.error);
}
// 更新总体进度
const totalCompleted = this.completedItems.length + this.failedItems.length;
const totalItems = totalCompleted + this.processingQueue.length;
config.onProgress?.(totalCompleted, totalItems, item);
}
}
/**
* 处理单个项目
*/
private async processItem(
item: BatchItem,
nodeType: NodeType,
config: BatchProcessingOptions
): Promise<any> {
const controller = new AbortController();
this.activeProcesses.set(item.id, controller);
try {
let result: any;
// 根据节点类型选择处理方法
switch (nodeType) {
case 'promptOptimize':
result = await this.processPromptOptimize(item, controller.signal, config);
break;
case 'imageGenerate':
result = await this.processImageGenerate(item, controller.signal, config);
break;
case 'imageEdit':
result = await this.processImageEdit(item, controller.signal, config);
break;
case 'videoGenerate':
result = await this.processVideoGenerate(item, controller.signal, config);
break;
default:
throw new Error(`Unsupported batch processing for node type: ${nodeType}`);
}
return result;
} finally {
this.activeProcesses.delete(item.id);
}
}
/**
* 批量提示词优化
*/
private async processPromptOptimize(
item: BatchItem,
signal: AbortSignal,
config: BatchProcessingOptions
): Promise<string> {
const prompt = typeof item.data === 'string' ? item.data : item.data.prompt || item.data.text;
return await this.withRetry(
() => aiService.optimizePrompt(prompt, {}, (progress) => {
item.progress = progress;
}),
config.retryAttempts,
config.retryDelay,
signal
);
}
/**
* 批量图片生成
*/
private async processImageGenerate(
item: BatchItem,
signal: AbortSignal,
config: BatchProcessingOptions
): Promise<string> {
const prompt = typeof item.data === 'string' ? item.data : item.data.prompt || item.data.text;
return await this.withRetry(
() => aiService.generateImage(prompt, {}, (progress) => {
item.progress = progress;
}),
config.retryAttempts,
config.retryDelay,
signal
);
}
/**
* 批量图片编辑
*/
private async processImageEdit(
item: BatchItem,
signal: AbortSignal,
config: BatchProcessingOptions
): Promise<string> {
const { imageUrl, instruction } = item.data;
return await this.withRetry(
() => aiService.editImage(imageUrl, instruction, {}, (progress) => {
item.progress = progress;
}),
config.retryAttempts,
config.retryDelay,
signal
);
}
/**
* 批量视频生成
*/
private async processVideoGenerate(
item: BatchItem,
signal: AbortSignal,
config: BatchProcessingOptions
): Promise<string> {
const { imageUrl, motionPrompt } = item.data;
return await this.withRetry(
() => aiService.generateVideo(imageUrl, motionPrompt, {}, (progress) => {
item.progress = progress;
}),
config.retryAttempts,
config.retryDelay,
signal
);
}
/**
* 重试机制
*/
private async withRetry<T>(
operation: () => Promise<T>,
maxRetries: number,
delay: number,
signal: AbortSignal
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (signal.aborted) {
throw new Error('Operation cancelled');
}
try {
return await operation();
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
// 等待重试延迟
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, attempt)));
}
}
throw new Error('Max retries exceeded');
}
/**
* 生成缩略图
*/
private generateThumbnail(result: any, nodeType: NodeType): string | undefined {
if (nodeType === 'imageGenerate' || nodeType === 'imageEdit') {
return typeof result === 'string' ? result : undefined;
}
if (nodeType === 'videoGenerate') {
// 为视频生成缩略图
return `https://picsum.photos/200/150?random=${Date.now()}`;
}
return undefined;
}
/**
* 取消批量处理
*/
cancelBatch(): void {
// 取消所有活动的处理
for (const controller of this.activeProcesses.values()) {
controller.abort();
}
this.activeProcesses.clear();
// 清空队列
this.processingQueue = [];
}
/**
* 获取处理统计
*/
getStats(): {
total: number;
completed: number;
failed: number;
pending: number;
processing: number;
} {
return {
total: this.completedItems.length + this.failedItems.length + this.processingQueue.length + this.activeProcesses.size,
completed: this.completedItems.length,
failed: this.failedItems.length,
pending: this.processingQueue.length,
processing: this.activeProcesses.size
};
}
}
// 创建全局批量处理器实例
export const batchProcessor = new BatchProcessor();

View File

@@ -0,0 +1,344 @@
import { Node, Connection } from 'reactflow';
import { NodeType, DataType, DataFlow } from '../types/canvas';
/**
* 连接验证器 - 验证节点间的连接是否有效
*/
export class ConnectionValidator {
/**
* 验证连接是否有效
*/
static validateConnection(
connection: Connection,
nodes: Node[]
): { valid: boolean; reason?: string } {
const sourceNode = nodes.find(n => n.id === connection.source);
const targetNode = nodes.find(n => n.id === connection.target);
if (!sourceNode || !targetNode) {
return { valid: false, reason: '找不到源节点或目标节点' };
}
// 防止自连接
if (connection.source === connection.target) {
return { valid: false, reason: '不能连接到自身' };
}
// 防止循环连接
if (this.wouldCreateCycle(connection, nodes)) {
return { valid: false, reason: '连接会创建循环依赖' };
}
// 检查数据类型兼容性
const sourceOutputType = this.getNodeOutputType(sourceNode.type as NodeType);
const targetInputType = this.getNodeInputType(targetNode.type as NodeType);
if (!this.areTypesCompatible(sourceOutputType, targetInputType)) {
return {
valid: false,
reason: `数据类型不兼容: ${sourceOutputType}${targetInputType}`
};
}
// 检查是否已存在连接
const existingConnections = this.getExistingConnections(nodes);
const isDuplicate = existingConnections.some(edge =>
edge.source === connection.source &&
edge.target === connection.target
);
if (isDuplicate) {
return { valid: false, reason: '连接已存在' };
}
return { valid: true };
}
/**
* 检查是否会创建循环
*/
private static wouldCreateCycle(connection: Connection, nodes: Node[]): boolean {
// 简化的循环检测 - 检查目标节点是否能到达源节点
const visited = new Set<string>();
const stack = [connection.target];
while (stack.length > 0) {
const currentId = stack.pop()!;
if (currentId === connection.source) {
return true; // 发现循环
}
if (visited.has(currentId)) {
continue;
}
visited.add(currentId);
// 找到当前节点的所有输出连接
const outgoingConnections = this.getExistingConnections(nodes)
.filter(edge => edge.source === currentId);
for (const edge of outgoingConnections) {
stack.push(edge.target);
}
}
return false;
}
/**
* 获取现有连接
*/
private static getExistingConnections(nodes: Node[]): Array<{source: string, target: string}> {
// 这里应该从实际的边数据中获取,暂时返回空数组
return [];
}
/**
* 检查数据类型是否兼容
*/
private static areTypesCompatible(sourceType: DataType, targetType: DataType): boolean {
// 完全匹配
if (sourceType === targetType) return true;
// 批量数据兼容性
if (sourceType === 'batch') {
// 批量数据可以连接到任何类型(取第一个元素)
return true;
}
if (targetType === 'batch') {
// 任何类型都可以转换为批量数据
return true;
}
// 特殊兼容性规则
const compatibilityRules: Record<DataType, DataType[]> = {
'text': ['text'],
'image': ['image', 'video'], // 图片可以用于视频生成
'video': ['video'],
'batch': ['text', 'image', 'video', 'batch']
};
return compatibilityRules[sourceType]?.includes(targetType) || false;
}
/**
* 获取节点输出类型
*/
private static getNodeOutputType(nodeType: NodeType): DataType {
const outputTypes: Record<NodeType, DataType> = {
'textInput': 'text',
'promptOptimize': 'text',
'textDisplay': 'text',
'imageUpload': 'image',
'imageGenerate': 'image',
'imageEdit': 'image',
'imageDisplay': 'image',
'videoGenerate': 'video',
'videoPlayer': 'video',
'folderUpload': 'batch',
'batchGrid': 'batch'
};
return outputTypes[nodeType] || 'text';
}
/**
* 获取节点输入类型
*/
private static getNodeInputType(nodeType: NodeType): DataType {
const inputTypes: Record<NodeType, DataType> = {
'textInput': 'text',
'promptOptimize': 'text',
'textDisplay': 'text',
'imageUpload': 'image',
'imageGenerate': 'text', // 接受提示词
'imageEdit': 'image',
'imageDisplay': 'image',
'videoGenerate': 'image', // 接受源图片
'videoPlayer': 'video',
'folderUpload': 'batch',
'batchGrid': 'batch'
};
return inputTypes[nodeType] || 'text';
}
}
/**
* 数据流转换器 - 处理不同数据类型之间的转换
*/
export class DataFlowConverter {
/**
* 转换数据流以匹配目标节点类型
*/
static convertDataFlow(
sourceData: DataFlow,
sourceNodeType: NodeType,
targetNodeType: NodeType
): DataFlow {
const targetInputType = ConnectionValidator['getNodeInputType'](targetNodeType);
// 如果类型完全匹配,直接返回
if (sourceData.dataType === targetInputType) {
return sourceData;
}
// 批量到单个的转换
if (sourceData.type === 'batch' && targetInputType !== 'batch') {
return {
type: 'single',
dataType: targetInputType,
content: this.extractFirstFromBatch(sourceData.content),
metadata: {
...sourceData.metadata,
convertedFrom: 'batch',
originalCount: sourceData.content?.length || 0
}
};
}
// 单个到批量的转换
if (sourceData.type === 'single' && targetInputType === 'batch') {
return {
type: 'batch',
dataType: sourceData.dataType,
content: [sourceData.content],
metadata: {
...sourceData.metadata,
convertedFrom: 'single',
totalItems: 1,
completedItems: 1
}
};
}
// 特殊转换规则
return this.applySpecialConversions(sourceData, sourceNodeType, targetNodeType);
}
/**
* 从批量数据中提取第一个元素
*/
private static extractFirstFromBatch(batchContent: any[]): any {
if (!Array.isArray(batchContent) || batchContent.length === 0) {
return null;
}
const firstItem = batchContent[0];
// 如果是批量结果对象,提取实际结果
if (firstItem && typeof firstItem === 'object' && 'result' in firstItem) {
return firstItem.result;
}
return firstItem;
}
/**
* 应用特殊转换规则
*/
private static applySpecialConversions(
sourceData: DataFlow,
sourceNodeType: NodeType,
targetNodeType: NodeType
): DataFlow {
// 图片到视频的转换
if (sourceData.dataType === 'image' && targetNodeType === 'videoGenerate') {
return {
...sourceData,
metadata: {
...sourceData.metadata,
convertedFor: 'videoGeneration'
}
};
}
// 文本到图片生成的转换
if (sourceData.dataType === 'text' && targetNodeType === 'imageGenerate') {
return {
...sourceData,
metadata: {
...sourceData.metadata,
convertedFor: 'imageGeneration'
}
};
}
// 默认返回原数据
return sourceData;
}
/**
* 根据目标节点类型准备数据
*/
static prepareDataForNode(
data: any,
sourceNodeType: NodeType,
targetNodeType: NodeType
): Record<string, any> {
const updates: Record<string, any> = {};
switch (targetNodeType) {
case 'promptOptimize':
updates.inputPrompt = data;
break;
case 'imageGenerate':
updates.prompt = data;
break;
case 'imageEdit':
if (sourceNodeType.includes('image')) {
updates.originalImage = data;
} else {
updates.editInstruction = data;
}
break;
case 'videoGenerate':
if (sourceNodeType.includes('image')) {
updates.sourceImage = data;
} else {
updates.motionPrompt = data;
}
break;
case 'textDisplay':
updates.inputText = data;
updates.result = data;
break;
case 'imageDisplay':
updates.inputImage = data;
updates.result = data;
break;
case 'videoPlayer':
updates.inputVideo = data;
updates.result = data;
break;
case 'batchGrid':
if (Array.isArray(data)) {
updates.results = data.map((item: any, index: number) => ({
id: `item-${index}`,
filename: item.filename || `Item ${index + 1}`,
status: 'success',
result: item.result || item,
thumbnail: item.thumbnail || (typeof item.result === 'string' ? item.result : undefined)
}));
updates.totalCount = data.length;
updates.completedCount = data.length;
} else {
updates.results = [{
id: 'item-0',
filename: 'Single Item',
status: 'success',
result: data,
thumbnail: typeof data === 'string' ? data : undefined
}];
updates.totalCount = 1;
updates.completedCount = 1;
}
break;
default:
updates.result = data;
}
return updates;
}
}

1
augmentcode.md Normal file
View File

@@ -0,0 +1 @@
d013189f-75ea-4554-ae13-b559ed8fd1f9

382
pnpm-lock.yaml generated
View File

@@ -81,6 +81,9 @@ importers:
react-window:
specifier: ^1.8.11
version: 1.8.11(react-dom@18.3.1)(react@18.3.1)
reactflow:
specifier: ^11.11.4
version: 11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
@@ -975,6 +978,114 @@ packages:
dev: true
optional: true
/@reactflow/background@11.3.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
classcat: 5.0.5
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
zustand: 4.5.7(@types/react@18.3.23)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/controls@11.2.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
classcat: 5.0.5
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
zustand: 4.5.7(@types/react@18.3.23)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/core@11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@types/d3': 7.4.3
'@types/d3-drag': 3.0.7
'@types/d3-selection': 3.0.11
'@types/d3-zoom': 3.0.8
classcat: 5.0.5
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
zustand: 4.5.7(@types/react@18.3.23)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/minimap@11.7.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
'@types/d3-selection': 3.0.11
'@types/d3-zoom': 3.0.8
classcat: 5.0.5
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
zustand: 4.5.7(@types/react@18.3.23)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-resizer@2.2.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
classcat: 5.0.5
d3-drag: 3.0.0
d3-selection: 3.0.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
zustand: 4.5.7(@types/react@18.3.23)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-toolbar@1.3.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
classcat: 5.0.5
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
zustand: 4.5.7(@types/react@18.3.23)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@remix-run/router@1.23.0:
resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==}
engines: {node: '>=14.0.0'}
@@ -1386,6 +1497,185 @@ packages:
'@babel/types': 7.28.1
dev: true
/@types/d3-array@3.2.1:
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
dev: false
/@types/d3-axis@3.0.6:
resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
dependencies:
'@types/d3-selection': 3.0.11
dev: false
/@types/d3-brush@3.0.6:
resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
dependencies:
'@types/d3-selection': 3.0.11
dev: false
/@types/d3-chord@3.0.6:
resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
dev: false
/@types/d3-color@3.1.3:
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
dev: false
/@types/d3-contour@3.0.6:
resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
dependencies:
'@types/d3-array': 3.2.1
'@types/geojson': 7946.0.16
dev: false
/@types/d3-delaunay@6.0.4:
resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
dev: false
/@types/d3-dispatch@3.0.7:
resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
dev: false
/@types/d3-drag@3.0.7:
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
dependencies:
'@types/d3-selection': 3.0.11
dev: false
/@types/d3-dsv@3.0.7:
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
dev: false
/@types/d3-ease@3.0.2:
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
dev: false
/@types/d3-fetch@3.0.7:
resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
dependencies:
'@types/d3-dsv': 3.0.7
dev: false
/@types/d3-force@3.0.10:
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
dev: false
/@types/d3-format@3.0.4:
resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
dev: false
/@types/d3-geo@3.1.0:
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
dependencies:
'@types/geojson': 7946.0.16
dev: false
/@types/d3-hierarchy@3.1.7:
resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
dev: false
/@types/d3-interpolate@3.0.4:
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
dependencies:
'@types/d3-color': 3.1.3
dev: false
/@types/d3-path@3.1.1:
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
dev: false
/@types/d3-polygon@3.0.2:
resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
dev: false
/@types/d3-quadtree@3.0.6:
resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
dev: false
/@types/d3-random@3.0.3:
resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
dev: false
/@types/d3-scale-chromatic@3.1.0:
resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
dev: false
/@types/d3-scale@4.0.9:
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
dependencies:
'@types/d3-time': 3.0.4
dev: false
/@types/d3-selection@3.0.11:
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
dev: false
/@types/d3-shape@3.1.7:
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
dependencies:
'@types/d3-path': 3.1.1
dev: false
/@types/d3-time-format@4.0.3:
resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
dev: false
/@types/d3-time@3.0.4:
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
dev: false
/@types/d3-timer@3.0.2:
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
dev: false
/@types/d3-transition@3.0.9:
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
dependencies:
'@types/d3-selection': 3.0.11
dev: false
/@types/d3-zoom@3.0.8:
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
dev: false
/@types/d3@7.4.3:
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
dependencies:
'@types/d3-array': 3.2.1
'@types/d3-axis': 3.0.6
'@types/d3-brush': 3.0.6
'@types/d3-chord': 3.0.6
'@types/d3-color': 3.1.3
'@types/d3-contour': 3.0.6
'@types/d3-delaunay': 6.0.4
'@types/d3-dispatch': 3.0.7
'@types/d3-drag': 3.0.7
'@types/d3-dsv': 3.0.7
'@types/d3-ease': 3.0.2
'@types/d3-fetch': 3.0.7
'@types/d3-force': 3.0.10
'@types/d3-format': 3.0.4
'@types/d3-geo': 3.1.0
'@types/d3-hierarchy': 3.1.7
'@types/d3-interpolate': 3.0.4
'@types/d3-path': 3.1.1
'@types/d3-polygon': 3.0.2
'@types/d3-quadtree': 3.0.6
'@types/d3-random': 3.0.3
'@types/d3-scale': 4.0.9
'@types/d3-scale-chromatic': 3.1.0
'@types/d3-selection': 3.0.11
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-time-format': 4.0.3
'@types/d3-timer': 3.0.2
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
dev: false
/@types/debug@4.1.12:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies:
@@ -1401,6 +1691,10 @@ packages:
/@types/estree@1.0.8:
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
/@types/geojson@7946.0.16:
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
dev: false
/@types/hast@3.0.4:
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
dependencies:
@@ -1832,6 +2126,10 @@ packages:
readdirp: 4.1.2
dev: true
/classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
dev: false
/cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1944,6 +2242,71 @@ packages:
/csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
/d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
dev: false
/d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
dev: false
/d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
dev: false
/d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
dev: false
/d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
dependencies:
d3-color: 3.1.0
dev: false
/d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
dev: false
/d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
dev: false
/d3-transition@3.0.1(d3-selection@3.0.0):
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
dev: false
/d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
dev: false
/data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -4139,6 +4502,25 @@ packages:
dependencies:
loose-envify: 1.4.0
/reactflow@11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/background': 11.3.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
'@reactflow/controls': 11.2.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
'@reactflow/core': 11.11.4(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
'@reactflow/minimap': 11.7.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
'@reactflow/node-resizer': 2.2.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
'@reactflow/node-toolbar': 1.3.14(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
dependencies: