feat: 实现AI画布工具并隐藏,设置项目为首页
- 新增完整的AI画布工具系统 - 可视化节点编辑器,支持拖拽连线 - 多种节点类型:文本输入、图片生成、视频生成等 - 智能连接验证和数据流转换 - 异步处理引擎,支持进度追踪和取消 - 批量处理系统,支持并发处理 - AI服务集成框架,支持多种AI API - 用户体验优化 - 智能弹框定位,防止被遮挡 - 节点删除功能(悬停删除按钮 + 键盘快捷键) - 通知系统和错误处理 - 快速开始模板 - 键盘快捷键支持 - 界面调整 - 暂时隐藏AI画布,保留代码 - 设置项目列表为首页 - 简化导航栏结构
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
/* 改进字体渲染 */
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
220
apps/desktop/src/components/canvas/AIConfigModal.tsx
Normal file
220
apps/desktop/src/components/canvas/AIConfigModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
143
apps/desktop/src/components/canvas/BaseNode.tsx
Normal file
143
apps/desktop/src/components/canvas/BaseNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
248
apps/desktop/src/components/canvas/CanvasContainer.tsx
Normal file
248
apps/desktop/src/components/canvas/CanvasContainer.tsx
Normal 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 />;
|
||||
};
|
||||
237
apps/desktop/src/components/canvas/CanvasToolbar.tsx
Normal file
237
apps/desktop/src/components/canvas/CanvasToolbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
275
apps/desktop/src/components/canvas/ConnectionIndicator.tsx
Normal file
275
apps/desktop/src/components/canvas/ConnectionIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
217
apps/desktop/src/components/canvas/NodeSelector.tsx
Normal file
217
apps/desktop/src/components/canvas/NodeSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
248
apps/desktop/src/components/canvas/NotificationSystem.tsx
Normal file
248
apps/desktop/src/components/canvas/NotificationSystem.tsx
Normal 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)
|
||||
};
|
||||
};
|
||||
191
apps/desktop/src/components/canvas/QuickStartTemplates.tsx
Normal file
191
apps/desktop/src/components/canvas/QuickStartTemplates.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
140
apps/desktop/src/components/canvas/nodes/BatchResultGrid.tsx
Normal file
140
apps/desktop/src/components/canvas/nodes/BatchResultGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
144
apps/desktop/src/components/canvas/nodes/FolderUploadNode.tsx
Normal file
144
apps/desktop/src/components/canvas/nodes/FolderUploadNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
140
apps/desktop/src/components/canvas/nodes/ImageEditNode.tsx
Normal file
140
apps/desktop/src/components/canvas/nodes/ImageEditNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
80
apps/desktop/src/components/canvas/nodes/ImageUploadNode.tsx
Normal file
80
apps/desktop/src/components/canvas/nodes/ImageUploadNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
51
apps/desktop/src/components/canvas/nodes/TextDisplayNode.tsx
Normal file
51
apps/desktop/src/components/canvas/nodes/TextDisplayNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
53
apps/desktop/src/components/canvas/nodes/TextInputNode.tsx
Normal file
53
apps/desktop/src/components/canvas/nodes/TextInputNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
181
apps/desktop/src/components/canvas/nodes/VideoGenerateNode.tsx
Normal file
181
apps/desktop/src/components/canvas/nodes/VideoGenerateNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
190
apps/desktop/src/components/canvas/nodes/VideoPlayerNode.tsx
Normal file
190
apps/desktop/src/components/canvas/nodes/VideoPlayerNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
243
apps/desktop/src/hooks/useNodeProcessing.ts
Normal file
243
apps/desktop/src/hooks/useNodeProcessing.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
144
apps/desktop/src/pages/CanvasTool.tsx
Normal file
144
apps/desktop/src/pages/CanvasTool.tsx
Normal 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;
|
||||
391
apps/desktop/src/services/aiServices.ts
Normal file
391
apps/desktop/src/services/aiServices.ts
Normal 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());
|
||||
299
apps/desktop/src/services/processingEngine.ts
Normal file
299
apps/desktop/src/services/processingEngine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
227
apps/desktop/src/store/canvasStore.ts
Normal file
227
apps/desktop/src/store/canvasStore.ts
Normal 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';
|
||||
});
|
||||
}
|
||||
}));
|
||||
121
apps/desktop/src/types/canvas.ts
Normal file
121
apps/desktop/src/types/canvas.ts
Normal 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';
|
||||
};
|
||||
}
|
||||
315
apps/desktop/src/utils/batchProcessor.ts
Normal file
315
apps/desktop/src/utils/batchProcessor.ts
Normal 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();
|
||||
344
apps/desktop/src/utils/connectionValidator.ts
Normal file
344
apps/desktop/src/utils/connectionValidator.ts
Normal 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
1
augmentcode.md
Normal file
@@ -0,0 +1 @@
|
||||
d013189f-75ea-4554-ae13-b559ed8fd1f9
|
||||
382
pnpm-lock.yaml
generated
382
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user