Compare commits

...

4 Commits

Author SHA1 Message Date
c9e8b0c123 add run workflow 2025-05-02 17:31:33 +08:00
847b3e96c8 实现LLMNode 2025-05-01 14:10:46 +08:00
37bdf4dbfd 创建节点侧边栏 2025-05-01 13:11:21 +08:00
b57b2114cd 添加reactflow 2025-05-01 13:05:26 +08:00
21 changed files with 1857 additions and 45 deletions

View File

@ -2,7 +2,7 @@
# Description: 聚合 v1 版本的所有 API 路由
from fastapi import APIRouter
from app.api.v1.endpoints import chat, assistants, sessions, messages # Import messages router
from app.api.v1.endpoints import chat, assistants, sessions, messages, workflow # Import messages router
api_router = APIRouter()
@ -10,3 +10,4 @@ api_router.include_router(chat.router, prefix="/chat", tags=["Chat"])
api_router.include_router(assistants.router, prefix="/assistants", tags=["Assistants"])
api_router.include_router(sessions.router, prefix="/sessions", tags=["Sessions"])
api_router.include_router(messages.router, prefix="/messages", tags=["Messages"]) # Add messages router
api_router.include_router(workflow.router, prefix="/workflow", tags=["Workflow"]) # Add messages router

View File

@ -0,0 +1,22 @@
# File: backend/app/api/v1/endpoints/workflow.py (No significant changes needed)
# Description: 工作流相关的 API 路由 (uses the refactored service)
# ... (保持不变, 确保调用 refactored WorkflowService) ...
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from app.models.pydantic_models import WorkflowRunRequest, WorkflowRunResponse
from app.services.workflow_service import WorkflowService # Import the refactored class
from app.db.database import get_db_session # Import if service needs DB
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
workflow_service = WorkflowService()
@router.post("/run", response_model=WorkflowRunResponse)
async def run_workflow(
request: WorkflowRunRequest,
db: Optional[AsyncSession] = Depends(get_db_session) # Make DB optional or required based on component needs
):
print(f"收到运行工作流请求: {len(request.nodes)} 个节点, {len(request.edges)} 条边")
result = await workflow_service.execute_workflow(request.nodes, request.edges, db)
# No need to raise HTTPException here if service returns success=False
return result

View File

@ -0,0 +1,86 @@
# File: backend/app/flow_components/base.py (New)
# Description: Base class for all workflow node components
from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional, Type, ClassVar
from abc import ABC, abstractmethod
from sqlalchemy.ext.asyncio import AsyncSession # Import if components need DB access
# --- Input/Output Field Definitions ---
# These classes help define the expected inputs and outputs for handles/UI fields
class InputField(BaseModel):
name: str # Internal name/key, matches handleId or data key
display_name: str # User-friendly name for UI
info: Optional[str] = None # Tooltip/description
field_type: str # e.g., 'str', 'int', 'float', 'bool', 'dict', 'code', 'prompt', 'llm', 'message'
required: bool = True
value: Any = None # Default value for UI fields
is_handle: bool = False # True if this input comes from a Handle connection
# Add more metadata as needed (e.g., options for dropdowns, range for sliders)
options: Optional[List[str]] = None
range_spec: Optional[Dict[str, float]] = None # e.g., {'min': 0, 'max': 1, 'step': 0.1}
class OutputField(BaseModel):
name: str # Internal name/key, matches handleId
display_name: str
field_type: str # Data type of the output handle
info: Optional[str] = None
# --- Base Component Class ---
class BaseComponent(ABC, BaseModel):
# Class variables for metadata (can be overridden by subclasses)
display_name: ClassVar[str] = "Base Component"
description: ClassVar[str] = "A base component for workflow nodes."
icon: ClassVar[Optional[str]] = None # Icon name (e.g., from Lucide)
name: ClassVar[str] # Unique internal name/type identifier (matches frontend node type)
# Instance variable to hold node-specific data from frontend
node_data: Dict[str, Any] = {}
# Class variables defining inputs and outputs
inputs: ClassVar[List[InputField]] = []
outputs: ClassVar[List[OutputField]] = []
class Config:
arbitrary_types_allowed = True # Allow complex types like AsyncSession if needed
@abstractmethod
async def run(
self,
inputs: Dict[str, Any], # Resolved inputs from UI data and connected nodes
db: Optional[AsyncSession] = None # Pass DB session if needed
) -> Dict[str, Any]: # Return dictionary of output values keyed by output name
"""Executes the component's logic."""
pass
def validate_inputs(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
"""Validates if required inputs are present."""
resolved_inputs = {}
missing = []
for field in self.inputs:
# Combine node_data (UI config) and resolved inputs from connections
value = inputs.get(field.name, self.node_data.get(field.name, field.value))
if field.required and value is None:
missing.append(field.display_name)
# TODO: Add type validation based on field.field_type
resolved_inputs[field.name] = value
if missing:
raise ValueError(f"节点 '{self.display_name}' 缺少必需的输入: {', '.join(missing)}")
return resolved_inputs
# --- Component Registry ---
# Simple dictionary to map node type strings to component classes
component_registry: Dict[str, Type[BaseComponent]] = {}
def register_component(cls: Type[BaseComponent]):
"""Decorator to register component classes."""
if not hasattr(cls, 'name') or not cls.name:
raise ValueError(f"Component class {cls.__name__} must have a 'name' attribute.")
if cls.name in component_registry:
print(f"警告: 组件 '{cls.name}' 被重复注册。")
component_registry[cls.name] = cls
print(f"已注册组件: {cls.name}")
return cls

View File

@ -0,0 +1,27 @@
# File: backend/app/flow_components/chat_input.py (New)
# Description: Backend component for ChatInputNode
from .base import BaseComponent, InputField, OutputField, register_component
from typing import ClassVar, Dict, Any, Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
@register_component
class ChatInputNodeComponent(BaseComponent):
name: ClassVar[str] = "chatInputNode" # Matches frontend type
display_name: ClassVar[str] = "Chat Input"
description: ClassVar[str] = "从 Playground 获取聊天输入。"
icon: ClassVar[str] = "MessageCircleQuestion"
inputs: ClassVar[List[InputField]] = [
InputField(name="text", display_name="Text", field_type="str", required=False, is_handle=False, info="用户输入的文本或默认文本。"),
]
outputs: ClassVar[List[OutputField]] = [
OutputField(name="message-output", display_name="Message", field_type="message", info="输出的聊天消息。") # Use 'message' type
]
async def run(self, inputs: Dict[str, Any], db: Optional[AsyncSession] = None) -> Dict[str, Any]:
# Inputs are already validated and resolved by the base class/executor
text_input = inputs.get("text", self.node_data.get("text", "")) # Get text from UI data or default
print(f"ChatInputNode ({self.node_data.get('id', 'N/A')}): 输出文本 '{text_input}'")
# Output format should match the defined output field name
return {"message-output": text_input} # Output the text

View File

@ -0,0 +1,38 @@
# File: backend/app/flow_components/chat_output.py (New)
# Description: Backend component for ChatOutputNode
from .base import BaseComponent, InputField, OutputField, register_component
from typing import ClassVar, Dict, Any, Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
@register_component
class ChatOutputNodeComponent(BaseComponent):
name: ClassVar[str] = "chatOutputNode"
display_name: ClassVar[str] = "Chat Output"
description: ClassVar[str] = "在 Playground 显示聊天消息。"
icon: ClassVar[str] = "MessageCircleReply"
inputs: ClassVar[List[InputField]] = [
InputField(name="message-input", display_name="Message", field_type="message", required=True, is_handle=True, info="连接要显示的消息。"),
InputField(name="displayText", display_name="Text", field_type="str", required=False, is_handle=False, info="(可选)覆盖显示的文本。"),
]
outputs: ClassVar[List[OutputField]] = [
# This node typically doesn't output further, but could pass through
# OutputField(name="output", display_name="Output", field_type="message")
]
async def run(self, inputs: Dict[str, Any], db: Optional[AsyncSession] = None) -> Dict[str, Any]:
message_input = inputs.get("message-input")
display_override = inputs.get("displayText", self.node_data.get("displayText")) # Check UI data too
if message_input is None:
raise ValueError("ChatOutputNode 未收到输入消息。")
# Determine what to "output" (in this context, what the workflow considers the result)
final_text = display_override if display_override else str(message_input)
print(f"ChatOutputNode ({self.node_data.get('id', 'N/A')}): 最终显示 '{final_text[:50]}...'")
# Since this is often a terminal node for execution, return the processed input
# The executor will decide how to handle this final output
return {"final_output": final_text} # Use a consistent key like 'final_output'

View File

@ -0,0 +1,77 @@
# File: backend/app/flow_components/llm_node.py (New)
# Description: Backend component for LLMNode
from .base import BaseComponent, InputField, OutputField, register_component
from typing import ClassVar, Dict, Any, Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.chat_service import ChatService # Assuming ChatService can be used or adapted
from app.core.config import OPENAI_API_KEY
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage
# Instantiate or get ChatService instance
# This might need better dependency injection in a real app
chat_service_instance = ChatService(default_api_key=OPENAI_API_KEY)
@register_component
class LLMNodeComponent(BaseComponent):
name: ClassVar[str] = "llmNode"
display_name: ClassVar[str] = "LLM 调用"
description: ClassVar[str] = "使用大语言模型生成文本。"
icon: ClassVar[str] = "BrainCircuit"
inputs: ClassVar[List[InputField]] = [
InputField(name="input-text", display_name="输入", field_type="message", required=True, is_handle=True, info="连接输入的文本或消息。"),
InputField(name="systemPrompt", display_name="系统提示", field_type="str", required=True, is_handle=False, info="定义助手的角色和行为。"),
InputField(name="model", display_name="模型名称", field_type="str", required=True, is_handle=False, info="要使用的 LLM 模型。"), # Add options if needed
InputField(name="temperature", display_name="温度", field_type="float", required=True, is_handle=False, value=0.7, range_spec={'min': 0, 'max': 1, 'step': 0.1}),
InputField(name="apiKey", display_name="API Key", field_type="secret", required=False, is_handle=False, info="(不安全)覆盖默认 API Key。"),
# Add other parameters like max_tokens, etc. as InputFields
]
outputs: ClassVar[List[OutputField]] = [
OutputField(name="output-message", display_name="Message", field_type="message", info="LLM 生成的消息。")
]
async def run(self, inputs: Dict[str, Any], db: Optional[AsyncSession] = None) -> Dict[str, Any]:
prompt_input = inputs.get("input-text")
system_prompt = inputs.get("systemPrompt")
model = inputs.get("model")
temperature = inputs.get("temperature")
# api_key = inputs.get("apiKey") # Handle API key securely if used
if not prompt_input or not system_prompt or not model or temperature is None:
raise ValueError("LLMNode 配置或输入不完整。")
print(f"LLMNode ({self.node_data.get('id', 'N/A')}): 运行模型 '{model}' (Temp: {temperature})")
print(f" System Prompt: {system_prompt[:50]}...")
print(f" Input Prompt: {prompt_input[:50]}...")
# --- Adapt ChatService or LangChain call ---
# This simplified call assumes a method that takes direct inputs
# In reality, you might build a small LangChain chain here
try:
# Construct messages for a more robust call
messages: List[BaseMessage] = []
if system_prompt:
messages.append(SystemMessage(content=system_prompt))
# Assume input-text provides the user message content
messages.append(HumanMessage(content=str(prompt_input))) # Ensure it's a string
# Simplified call - needs adaptation based on ChatService structure
# Maybe ChatService needs a method like:
# async def invoke_llm(self, messages: List[BaseMessage], model_name: str, temperature: float, ...) -> str:
# result = await chat_service_instance.invoke_llm(messages, model, temperature)
# --- Temporary Simulation ---
await asyncio.sleep(1)
result = f"AI回复(模拟): 处理了 '{str(prompt_input)[:20]}...'"
# --- End Simulation ---
print(f"LLMNode Output: {result[:50]}...")
return {"output-message": result}
except Exception as e:
print(f"LLMNode 执行失败: {e}")
raise # Re-raise the exception for the executor to handle
# Need asyncio for simulation
import asyncio

View File

@ -7,6 +7,7 @@ from app.api.v1.api import api_router as api_router_v1
import app.core.config # Ensure config is loaded
from app.db.database import create_db_and_tables # Import table creation function
from contextlib import asynccontextmanager
from app.flow_components import base, chat_input, llm_node, chat_output # Add other component modules here
# --- Lifespan context manager for startup/shutdown events ---
@asynccontextmanager
@ -17,6 +18,7 @@ async def lifespan(app: FastAPI):
print("数据库表已检查/创建。")
# You can add the default assistant creation here if needed,
# but doing it in the service/model definition might be simpler for defaults.
print(f"已注册组件: {list(base.component_registry.keys())}")
yield
# Shutdown actions
print("应用程序关闭中...")

View File

@ -2,7 +2,7 @@
# Description: Pydantic 模型定义 API 数据结构
from pydantic import BaseModel, Field
from typing import Optional, List
from typing import Dict, Optional, List
import uuid
from datetime import datetime # Use datetime directly
@ -90,4 +90,43 @@ class MessageRead(MessageBase):
created_at: datetime
class Config:
from_attributes = True
from_attributes = True
# --- Workflow Node/Edge Models (for API request/response) ---
# Mirrors React Flow structure loosely
class NodeData(BaseModel):
# Define common fields or use Dict[str, Any]
label: Optional[str] = None
text: Optional[str] = None # For ChatInput
displayText: Optional[str] = None # For ChatOutput
model: Optional[str] = None # For LLMNode
temperature: Optional[float] = None # For LLMNode
systemPrompt: Optional[str] = None # For LLMNode
# Add other potential data fields from your nodes
# Use Extra.allow for flexibility if needed:
# class Config:
# extra = 'allow'
class NodeModel(BaseModel):
id: str
type: str # e.g., 'chatInputNode', 'llmNode'
position: Dict[str, float] # { x: number, y: number }
data: NodeData # Use the specific data model
class EdgeModel(BaseModel):
id: str
source: str
target: str
sourceHandle: Optional[str] = None
targetHandle: Optional[str] = None
# --- Workflow Execution Models ---
class WorkflowRunRequest(BaseModel):
nodes: List[NodeModel]
edges: List[EdgeModel]
class WorkflowRunResponse(BaseModel):
success: bool
message: Optional[str] = None
output: Optional[str] = None # The final output text
output_node_id: Optional[str] = None # ID of the node that produced the output

View File

@ -0,0 +1,154 @@
# File: backend/app/services/workflow_service.py (Refactor)
# Description: 服务层,使用组件化方式执行工作流
from typing import List, Dict, Optional, Any, Tuple
from app.models.pydantic_models import NodeModel, EdgeModel, WorkflowRunResponse
from app.flow_components.base import component_registry, BaseComponent # Import registry and base
from sqlalchemy.ext.asyncio import AsyncSession
import graphlib # For topological sort
class WorkflowExecutionError(Exception): pass
class WorkflowService:
"""执行工作流的服务 (组件化)"""
async def execute_workflow(
self,
nodes: List[NodeModel],
edges: List[EdgeModel],
db: Optional[AsyncSession] = None # Pass DB if components need it
) -> WorkflowRunResponse:
print("开始执行工作流 (组件化)...")
# 1. 构建依赖图 & 拓扑排序
try:
graph: Dict[str, set[str]] = {node.id: set() for node in nodes}
node_map: Dict[str, NodeModel] = {node.id: node for node in nodes}
handle_to_node_map: Dict[str, Dict[str, str]] = {node.id: {} for node in nodes} # {node_id: {handle_id: input_name}}
# Pre-populate handle map based on component definitions
for node in nodes:
if node.type in component_registry:
component_cls = component_registry[node.type]
for input_field in component_cls.inputs:
if input_field.is_handle:
handle_to_node_map[node.id][input_field.name] = input_field.name # Map handle ID to input name
for edge in edges:
if edge.source in graph and edge.target in graph:
graph[edge.source].add(edge.target) # source depends on target? No, target depends on source
# Let's reverse: target depends on source
# graph[edge.target].add(edge.source) # This seems wrong for graphlib
# graphlib expects {node: {dependencies}}
# So, target node depends on source node
graph[edge.target].add(edge.source)
sorter = graphlib.TopologicalSorter(graph)
execution_order = list(sorter.static_order())
print(f"执行顺序: {execution_order}")
except graphlib.CycleError as e:
print(f"工作流中存在循环: {e}")
return WorkflowRunResponse(success=False, message="工作流中检测到循环,无法执行。")
except Exception as e:
print(f"构建执行图时出错: {e}")
return WorkflowRunResponse(success=False, message=f"构建执行图失败: {e}")
# 2. 执行节点
node_outputs: Dict[str, Dict[str, Any]] = {} # Store outputs {node_id: {output_handle_id: value}}
final_output_value: Any = None
final_output_node_id: Optional[str] = None
for node_id in execution_order:
node = node_map.get(node_id)
if not node:
print(f"错误: 找不到节点 {node_id}")
continue # Should not happen if graph is correct
component_cls = component_registry.get(node.type)
if not component_cls:
print(f"警告: 找不到节点类型 '{node.type}' 的后端组件,跳过节点 {node_id}")
continue
print(f"\n--- 执行节点: {node_id} (类型: {node.type}) ---")
# a. 实例化组件,传入节点数据
component_instance = component_cls(node_data=node.data.model_dump())
# b. 收集输入值
inputs_for_run: Dict[str, Any] = {}
try:
# Gather inputs from connected parent nodes
for edge in edges:
if edge.target == node_id:
source_node_id = edge.source
source_handle_id = edge.sourceHandle
target_handle_id = edge.targetHandle
if source_node_id in node_outputs and source_handle_id in node_outputs[source_node_id]:
# Map target handle ID to the correct input name for the component's run method
input_name = handle_to_node_map.get(node_id, {}).get(target_handle_id)
if input_name:
input_value = node_outputs[source_node_id][source_handle_id]
inputs_for_run[input_name] = input_value
print(f" 输入 '{input_name}' 来自 {source_node_id}.{source_handle_id} = {str(input_value)[:50]}...")
else:
print(f"警告: 找不到节点 {node_id} 的目标 Handle '{target_handle_id}' 对应的输入字段名。")
else:
# This might happen if the source node hasn't run or didn't produce the expected output
print(f"警告: 找不到来自 {source_node_id}.{source_handle_id} 的输出,无法连接到 {node_id}.{target_handle_id}")
# Check if the input is required
target_input_field = next((f for f in component_instance.inputs if f.name == handle_to_node_map.get(node_id, {}).get(target_handle_id)), None)
if target_input_field and target_input_field.required:
raise WorkflowExecutionError(f"节点 '{component_instance.display_name}' ({node_id}) 的必需输入 '{target_input_field.display_name}' 未连接或上游节点未提供输出。")
# c. 验证并合并来自 UI 的输入 (node.data)
# validate_inputs should combine inputs_for_run and node_data
resolved_inputs = component_instance.validate_inputs(inputs_for_run)
print(f" 解析后的输入: {resolved_inputs}")
# d. 执行组件的 run 方法
outputs = await component_instance.run(resolved_inputs, db)
node_outputs[node_id] = outputs # Store outputs
print(f" 节点输出: {outputs}")
# e. 检查是否为最终输出 (来自 ChatOutputNode)
if node.type == 'chatOutputNode' and 'final_output' in outputs:
final_output_value = outputs['final_output']
final_output_node_id = node_id
except ValueError as e: # Catch validation errors
print(f"节点 {node_id} 输入验证失败: {e}")
return WorkflowRunResponse(success=False, message=f"节点 '{component_instance.display_name}' ({node_id}) 执行错误: {e}")
except Exception as e:
print(f"节点 {node_id} 执行时发生错误: {e}")
# Log traceback e
return WorkflowRunResponse(success=False, message=f"节点 '{component_instance.display_name}' ({node_id}) 执行失败: {e}")
print("\n--- 工作流执行完成 ---")
if final_output_value is not None:
return WorkflowRunResponse(
success=True,
output=str(final_output_value), # Ensure output is string
output_node_id=final_output_node_id,
message="工作流执行成功"
)
else:
# Workflow finished but didn't produce output via a ChatOutputNode
print("警告: 工作流执行完毕,但未找到指定的 ChatOutputNode 或其未产生 'final_output'")
# Find the last node's output as a fallback?
last_node_id = execution_order[-1] if execution_order else None
fallback_output = node_outputs.get(last_node_id, {})
output_key = next(iter(fallback_output)) if fallback_output else None
fallback_value = fallback_output.get(output_key) if output_key else "执行完成,但无明确输出。"
return WorkflowRunResponse(
success=True, # Or False depending on requirements
output=str(fallback_value),
output_node_id=last_node_id,
message="工作流执行完成,但未找到指定的输出节点。"
)

View File

@ -0,0 +1,69 @@
// File: frontend/app/workflow/components/ChatInputNode.tsx (新建)
// Description: 自定义 ChatInput 节点
import React, { memo, useCallback, ChangeEvent } from 'react';
import { Handle, Position, useReactFlow, Node } from 'reactflow';
import { MessageCircleQuestion } from 'lucide-react'; // 使用合适的图标
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { ChatInputNodeData, CustomNodeProps } from './types';
const ChatInputNodeComponent = ({ id, data, isConnectable }: CustomNodeProps<ChatInputNodeData>) => {
const { setNodes } = useReactFlow<ChatInputNodeData>();
const handleTextChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
const newText = event.target.value;
setNodes((nds: Node<ChatInputNodeData>[]) =>
nds.map((node) => {
if (node.id === id) {
return { ...node, data: { ...node.data, text: newText } };
}
return node;
})
);
}, [id, setNodes]);
return (
<div className="react-flow__node-genericNode nopan bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg w-96"> {/* 调整宽度 */}
{/* 节点头部 */}
<div className="bg-gray-100 dark:bg-gray-700 p-3 border-b border-gray-300 dark:border-gray-600">
<div className="flex items-center gap-2">
<MessageCircleQuestion size={18} className="text-gray-700 dark:text-gray-300" />
<strong className="text-gray-800 dark:text-gray-200">Chat Input</strong>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1"> Playground </p>
</div>
{/* 节点内容 */}
<div className="p-4 space-y-2">
<div className="nodrag">
<Label htmlFor={`chat-input-text-${id}`} className="text-xs font-semibold text-gray-500 dark:text-gray-400">Text</Label>
<Textarea
id={`chat-input-text-${id}`}
name="text"
value={data.text || ''}
onChange={handleTextChange}
placeholder="输入聊天内容..."
className="mt-1 text-sm min-h-[80px] bg-white dark:bg-gray-700"
rows={3}
/>
</div>
</div>
{/* 输出 Handle */}
<div className="relative p-2 border-t border-gray-300 dark:border-gray-600">
<Label className="text-xs font-semibold text-gray-500 dark:text-gray-400 block text-right pr-7">Message</Label>
<Handle
type="source"
position={Position.Right}
id="message-output"
isConnectable={isConnectable}
className="!w-3 !h-3 !bg-purple-500 top-1/2" // 使用紫色 Handle
/>
</div>
</div>
);
};
ChatInputNodeComponent.displayName = 'ChatInputNode';
export const ChatInputNode = memo(ChatInputNodeComponent);

View File

@ -0,0 +1,84 @@
// File: frontend/app/workflow/components/ChatOutputNode.tsx (新建)
// Description: 自定义 ChatOutput 节点
import React, { memo, useCallback, ChangeEvent } from 'react';
import { Handle, Position, useReactFlow, Node } from 'reactflow';
import { MessageCircleReply } from 'lucide-react'; // 使用合适的图标
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; // 使用 Input 或 Textarea 根据需要
import type { ChatOutputNodeData, CustomNodeProps } from './types';
const ChatOutputNodeComponent = ({ id, data, isConnectable }: CustomNodeProps<ChatOutputNodeData>) => {
const { setNodes } = useReactFlow<ChatOutputNodeData>();
// 处理文本变化 (如果需要可编辑)
const handleTextChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
setNodes((nds: Node<ChatOutputNodeData>[]) =>
nds.map((node) => {
if (node.id === id) {
return { ...node, data: { ...node.data, displayText: newText } };
}
return node;
})
);
}, [id, setNodes]);
return (
<div className="react-flow__node-genericNode bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg w-96">
{/* 输入 Handle */}
<Handle
type="target"
position={Position.Left}
id="message-input"
isConnectable={isConnectable}
className="!w-3 !h-3 !bg-purple-500 top-1/2" // 使用紫色 Handle
/>
{/* 节点头部 */}
<div className="bg-gray-100 dark:bg-gray-700 p-3 border-b border-gray-300 dark:border-gray-600">
<div className="flex items-center gap-2">
<MessageCircleReply size={18} className="text-gray-700 dark:text-gray-300" />
<strong className="text-gray-800 dark:text-gray-200">Chat Output</strong>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1"> Playground </p>
</div>
{/* 节点内容 */}
<div className="p-4 space-y-2">
<div className="nodrag">
<Label htmlFor={`chat-output-text-${id}`} className="text-xs font-semibold text-gray-500 dark:text-gray-400">Text</Label>
{/* 根据截图,这里像是一个 Input可能用于显示模板或结果 */}
<Input
id={`chat-output-text-${id}`}
name="displayText"
value={data.displayText || ''} // 显示传入的数据
onChange={handleTextChange} // 如果需要可编辑
placeholder="等待输入..."
className="mt-1 text-sm h-10 bg-white dark:bg-gray-700"
// readOnly // 如果只是显示,可以设为只读
/>
{/* 或者只是一个简单的文本显示区域 */}
{/* <p className="mt-1 text-sm p-2 border rounded bg-white dark:bg-gray-700 min-h-[40px]">
{data.displayText || '等待输入...'}
</p> */}
</div>
</div>
{/* 输出 Handle (可选,根据是否需要继续传递) */}
{/* <div className="relative p-2 border-t border-gray-300 dark:border-gray-600">
<Label className="text-xs font-semibold text-gray-500 dark:text-gray-400 block text-right pr-7">Message</Label>
<Handle
type="source"
position={Position.Right}
id="message-passthrough"
isConnectable={isConnectable}
className="w-3 h-3 !bg-purple-500 top-1/2"
/>
</div> */}
</div>
);
};
ChatOutputNodeComponent.displayName = 'ChatOutputNode';
export const ChatOutputNode = memo(ChatOutputNodeComponent);

View File

@ -0,0 +1,231 @@
// File: frontend/app/workflow/components/LLMNode.tsx
// Description: 自定义 LLM 节点组件
import React, {
useState,
useCallback,
useEffect,
memo,
ChangeEvent,
} from "react";
import {
Handle,
Position,
useUpdateNodeInternals,
useReactFlow,
Node,
} from "reactflow";
import { BrainCircuit } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import {
type LLMNodeData,
type CustomNodeProps,
availableModels,
} from "./types"; // 导入类型和模型列表
const LLMNodeComponent = ({
id,
data,
isConnectable,
}: CustomNodeProps<LLMNodeData>) => {
// const [currentData, setCurrentData] = useState<LLMNodeData>(data);
const updateNodeInternals = useUpdateNodeInternals();
const { setNodes } = useReactFlow<LLMNodeData>(); // 获取 setNodes 方法
// 处理内部表单变化的通用回调 - 直接更新 React Flow 主状态
const handleDataChange = useCallback(
(key: keyof LLMNodeData, value: any) => {
// 使用 setNodes 更新特定节点的数据
setNodes(
(
nds: Node<LLMNodeData>[] // 显式指定类型
) =>
nds.map((node) => {
if (node.id === id) {
// 创建一个新的 data 对象
const updatedData = { ...node.data, [key]: value };
return { ...node, data: updatedData };
}
return node;
})
);
console.log(`(LLMNode) Node ${id} data updated:`, { [key]: value });
},
[id, setNodes]
); // 依赖 id 和 setNodes
const handleSliderChange = useCallback(
(value: number[]) => {
handleDataChange("temperature", value[0]);
},
[handleDataChange]
);
const handleSelectChange = useCallback(
(value: string) => {
handleDataChange("model", value);
},
[handleDataChange]
);
const handleTextChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const { name, value } = event.target;
handleDataChange(name as keyof LLMNodeData, value);
},
[handleDataChange]
);
// 检查 inputConnected 状态是否需要更新 (如果外部更新了)
// 注意:这个逻辑依赖于父组件正确更新了 data.inputConnected
useEffect(() => {
// 可以在这里添加逻辑,如果 props.data.inputConnected 和 UI 显示不一致时触发更新
// 但通常连接状态由 onConnect/onEdgesChange 在父组件处理更佳
// updateNodeInternals(id); // 如果 Handle 显示依赖 inputConnected可能需要调用
}, [data.inputConnected, id, updateNodeInternals]);
return (
// 调整宽度,例如 w-96 (24rem)
<div className="react-flow__node-genericNode nopan bg-purple-50 dark:bg-gray-800 border border-purple-200 dark:border-gray-700 rounded-lg shadow-lg w-96">
<div className="bg-purple-100 dark:bg-gray-700 p-3 border-b border-purple-200 dark:border-gray-600">
<div className="flex items-center gap-2">
<BrainCircuit
size={18}
className="text-purple-700 dark:text-purple-300"
/>
<strong className="text-purple-800 dark:text-purple-200">
LLM
</strong>
</div>
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
使
</p>
</div>
<div className="p-4 space-y-3">
<div className="relative nodrag">
<Handle
type="target"
position={Position.Left}
id="input-text"
isConnectable={isConnectable}
className="!w-3 !h-3 !bg-blue-500 top-1/2 z-50 !-left-5"
/>
<Label className="text-xs font-semibold text-gray-500 dark:text-gray-400">
</Label>
{/* 直接使用 props.data.inputConnected */}
{!data.inputConnected && (
<p className="text-xs text-gray-400 italic mt-1"></p>
)}
</div>
<div className="nodrag">
<Label
htmlFor={`systemPrompt-${id}`}
className="text-xs font-semibold"
>
</Label>
{/* 直接使用 props.data 和 onChange 回调 */}
<Textarea
id={`systemPrompt-${id}`}
name="systemPrompt"
value={data.systemPrompt}
onChange={handleTextChange}
placeholder="例如:你是一个乐于助人的助手。"
className="mt-1 text-xs min-h-[60px] bg-white dark:bg-gray-700"
rows={3}
/>
</div>
<div className="nodrag">
<Label htmlFor={`model-${id}`} className="text-xs font-semibold">
</Label>
<Select
name="model"
value={data.model}
onValueChange={handleSelectChange}
>
<SelectTrigger
id={`model-${id}`}
className="mt-1 h-8 text-xs bg-white dark:bg-gray-700"
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem
key={model.value}
value={model.value}
className="text-xs"
>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="nodrag">
<Label htmlFor={`apiKey-${id}`} className="text-xs font-semibold">
API Key ()
</Label>
<Input
id={`apiKey-${id}`}
name="apiKey"
type="password"
value={data.apiKey || ""}
onChange={handleTextChange}
placeholder="sk-..."
className="mt-1 h-8 text-xs bg-white dark:bg-gray-700"
/>
<p className="text-xs text-red-500 mt-1">
</p>
</div>
<div className="nodrag">
<Label
htmlFor={`temperature-${id}`}
className="text-xs font-semibold"
>
: {data.temperature?.toFixed(1) ?? "N/A"}
</Label>
<Slider
id={`temperature-${id}`}
min={0}
max={1}
step={0.1}
value={[data.temperature ?? 0.7]}
onValueChange={handleSliderChange}
className="mt-2"
/>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
<span></span>
<span></span>
</div>
</div>
</div>
<div className="relative p-2 border-t border-purple-200 dark:border-gray-600">
<Label className="text-xs font-semibold text-gray-500 dark:text-gray-400 block text-right pr-6">
</Label>
<Handle
type="source"
position={Position.Right}
id="output-message"
isConnectable={isConnectable}
className="!w-3 !h-3 !bg-green-500 top-1/2 z-50"
/>
</div>
</div>
);
};
LLMNodeComponent.displayName = "LLMNode";
export const LLMNode = memo(LLMNodeComponent);

View File

@ -0,0 +1,27 @@
// File: frontend/app/workflow/components/OutputNode.tsx
// Description: 自定义结束节点组件
import React, { memo } from 'react';
import { Handle, Position } from 'reactflow';
import { CheckCircle } from 'lucide-react';
import type { OutputNodeData, CustomNodeProps } from './types'; // 导入类型
const OutputNodeComponent = ({ data }: CustomNodeProps<OutputNodeData>) => {
return (
<div className="react-flow__node-default bg-blue-100 dark:bg-blue-900 border-blue-300 dark:border-blue-700 p-4 rounded-lg shadow-md w-40">
<Handle
type="target"
position={Position.Top}
id="output-target"
className="w-3 h-3 !bg-blue-500"
/>
<div className="flex items-center gap-2 mb-2">
<CheckCircle size={16} className="text-blue-700 dark:text-blue-300" />
<strong className="text-blue-800 dark:text-blue-200">{data.label || '结束流程'}</strong>
</div>
</div>
);
};
OutputNodeComponent.displayName = 'OutputNode';
export const OutputNode = memo(OutputNodeComponent);

View File

@ -0,0 +1,28 @@
// File: frontend/app/workflow/components/StartNode.tsx
// Description: 自定义开始节点组件
import React, { memo } from 'react';
import { Handle, Position } from 'reactflow';
import { Play } from 'lucide-react';
import type { StartNodeData, CustomNodeProps } from './types'; // 导入类型
const StartNodeComponent = ({ data }: CustomNodeProps<StartNodeData>) => {
return (
<div className="react-flow__node-default bg-green-100 dark:bg-green-900 border-green-300 dark:border-green-700 p-4 rounded-lg shadow-md w-40">
<div className="flex items-center gap-2 mb-2">
<Play size={16} className="text-green-700 dark:text-green-300" />
<strong className="text-green-800 dark:text-green-200">{data.label || '开始流程'}</strong>
</div>
<Handle
type="source"
position={Position.Bottom}
id="start-source"
className="w-3 h-3 !bg-green-500"
/>
</div>
);
};
StartNodeComponent.displayName = 'StartNode';
// 使用 memo 优化
export const StartNode = memo(StartNodeComponent);

View File

@ -0,0 +1,46 @@
// File: frontend/app/workflow/components/WorkflowSidebar.tsx
// Description: 侧边栏组件,用于拖放节点
import React, { DragEvent } from 'react';
import { MessageSquareText, BrainCircuit, Database, LogOut, Play, CheckCircle, MessageCircleQuestion, MessageCircleReply } from 'lucide-react';
// 定义可拖拽的节点类型 (从 page.tsx 移动过来)
const nodeTypesForPalette = [
{ type: 'startNode', label: '开始流程', icon: Play, defaultData: { label: '开始' } },
{ type: 'inputNode', label: '文本输入', icon: MessageSquareText, defaultData: { text: '用户输入...' } },
{ type: 'llmNode', label: 'LLM 调用', icon: BrainCircuit, defaultData: { model: 'gpt-3.5-turbo', temperature: 0.7, systemPrompt: '你是一个乐于助人的 AI 助手。' } },
{ type: 'ragNode', label: 'RAG 查询', icon: Database, defaultData: { query: '...', knowledgeBase: 'default' } },
{ type: 'outputNode', label: '结束流程', icon: CheckCircle, defaultData: { label: '结束' } },
{ type: 'chatInputNode', label: 'Chat Input', icon: MessageCircleQuestion, defaultData: { text: '' } }, // 添加 ChatInput
{ type: 'chatOutputNode', label: 'Chat Output', icon: MessageCircleReply, defaultData: { displayText: '' } }, // 添加 ChatOutput
];
export const WorkflowSidebar = () => {
const onDragStart = (event: DragEvent, nodeType: string, defaultData: any) => {
const nodeInfo = JSON.stringify({ nodeType, defaultData });
event.dataTransfer.setData('application/reactflow', nodeInfo);
event.dataTransfer.effectAllowed = 'move';
console.log(`Drag Start: ${nodeType}`);
};
return (
<aside className="w-64 bg-white dark:bg-gray-800 p-4 border-r dark:border-gray-700 shadow-md overflow-y-auto flex-shrink-0"> {/* 添加 flex-shrink-0 */}
<h3 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200"></h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4"></p>
<div className="space-y-2">
{nodeTypesForPalette.map((nodeInfo) => (
<div
key={nodeInfo.type}
className="p-3 border dark:border-gray-600 rounded-lg cursor-grab flex items-center gap-2 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
onDragStart={(event) => onDragStart(event, nodeInfo.type, nodeInfo.defaultData)}
draggable
>
<nodeInfo.icon size={18} className="text-gray-600 dark:text-gray-300 flex-shrink-0" />
<span className="text-sm text-gray-700 dark:text-gray-200">{nodeInfo.label}</span>
</div>
))}
</div>
</aside>
);
};

View File

@ -0,0 +1,48 @@
// File: frontend/app/workflow/components/types.ts
// Description: Shared types for workflow components
import { NodeProps } from 'reactflow';
// 定义 LLM 节点的数据结构
export interface LLMNodeData {
model: string;
temperature: number;
systemPrompt: string;
apiKey?: string;
inputConnected?: boolean;
}
// 定义其他节点可能需要的数据类型
export interface StartNodeData {
label: string;
}
export interface OutputNodeData {
label: string;
}
// --- 新增 Chat 节点类型 ---
export interface ChatInputNodeData {
text: string; // 存储输入的文本
}
export interface ChatOutputNodeData {
// ChatOutput 通常接收输入并显示,或者只是一个结束点
// 如果需要配置显示方式,可以在这里添加字段
// 如果它也像截图那样有个输入框,可能是用于显示模板或最终结果
displayText?: string; // 用于显示或配置的文本
inputConnected?: boolean; // 标记输入是否连接
}
// --- 结束新增 ---
// 可以将 NodeProps 包装一下,方便使用
export type CustomNodeProps<T> = NodeProps<T>;
// 可选的模型列表 (也可以放在这里共享)
export const availableModels = [
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
{ value: "gpt-4", label: "GPT-4" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
{ value: "gemini-pro", label: "Gemini Pro" },
{ value: "deepseek-coder", label: "DeepSeek Coder" },
];

View File

@ -1,12 +1,271 @@
// File: frontend/app/workflow/page.tsx
// Description: 工作流页面占位符
// File: frontend/app/workflow/page.tsx (更新)
// Description: 工作流编辑器主页面,引入模块化组件
'use client';
import React, { useState, useCallback, useRef, DragEvent, useMemo } from 'react';
import ReactFlow, {
ReactFlowProvider,
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Node,
Edge,
Connection,
Position,
MarkerType,
NodeChange,
EdgeChange,
applyNodeChanges,
applyEdgeChanges,
useReactFlow,
XYPosition,
BackgroundVariant,
Panel,
} from 'reactflow';
// 引入自定义节点和侧边栏组件
import { StartNode } from './components/StartNode';
import { OutputNode } from './components/OutputNode';
import { LLMNode } from './components/LLMNode';
import { WorkflowSidebar } from './components/WorkflowSidebar';
import { ChatInputNode } from './components/ChatInputNode'; // 引入新节点
import { ChatOutputNode } from './components/ChatOutputNode'; // 引入新节点
// 引入类型 (如果需要)
// import type { LLMNodeData } from './components/types';
// 引入 API 函数
import { runWorkflow } from '@/lib/api'; // Import runWorkflow
import 'reactflow/dist/style.css';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { LoaderIcon, PlayIcon } from 'lucide-react';
// --- 初始节点和边数据 ---
const initialNodes: Node[] = [
];
const initialEdges: Edge[] = [];
// --- 工作流编辑器组件 ---
function WorkflowEditor() {
const reactFlowWrapper = useRef<HTMLDivElement>(null); // Ref 指向 React Flow 容器
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { screenToFlowPosition } = useReactFlow();
const [isRunning, setIsRunning] = useState(false); // State for run button loading
// --- 注册自定义节点类型 ---
const nodeTypes = useMemo(() => ({
startNode: StartNode,
outputNode: OutputNode,
llmNode: LLMNode, // LLMNode 现在需要一种方式来调用 updateNodeData
chatInputNode: ChatInputNode, // 注册 ChatInputNode
chatOutputNode: ChatOutputNode, // 注册 ChatOutputNode
// inputNode: CustomInputNode,
// ragNode: RAGNode,
}), []); // 移除 updateNodeData 依赖,因为它不应该直接传递
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) => addEdge({ ...connection, markerEnd: { type: MarkerType.ArrowClosed } }, eds));
if (connection.targetHandle === 'input-text' && connection.target) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === connection.target) {
return { ...node, data: { ...node.data, inputConnected: true } };
}
return node;
})
);
}
// 更新 ChatOutputNode 的连接状态 (如果需要)
if (connection.targetHandle === 'message-input' && connection.target) {
setNodes((nds) => nds.map((node) => node.id === connection.target ? { ...node, data: { ...node.data, inputConnected: true } } : node));
}
},
[setEdges, setNodes] // 保持 updateNodeData 依赖
);
const onEdgesChangeIntercepted = useCallback(
(changes: EdgeChange[]) => {
changes.forEach(change => {
if (change.type === 'remove') {
const edgeToRemove = edges.find(edge => edge.id === change.id);
if (edgeToRemove?.targetHandle === 'input-text' && edgeToRemove.target) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === edgeToRemove.target) {
return { ...node, data: { ...node.data, inputConnected: false } };
}
return node;
})
);
}
// 更新 ChatOutputNode 的断开状态 (如果需要)
if (edgeToRemove?.targetHandle === 'message-input' && edgeToRemove.target) {
setNodes((nds) => nds.map((node) => node.id === edgeToRemove.target ? { ...node, data: { ...node.data, inputConnected: false } } : node));
}
}
});
onEdgesChange(changes);
},
[edges, onEdgesChange, setNodes] // 保持 updateNodeData 依赖
);
// 处理画布上的拖拽悬停事件
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault(); // 必须阻止默认行为才能触发 onDrop
event.dataTransfer.dropEffect = 'move'; // 设置放置效果
}, []);
// 处理画布上的放置事件
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault(); // 阻止默认行为(如打开文件)
if (!reactFlowWrapper.current) {
return;
}
// 从 dataTransfer 中获取节点信息
const nodeInfoString = event.dataTransfer.getData('application/reactflow');
if (!nodeInfoString) {
console.warn("No reactflow data found in dataTransfer");
return;
}
let nodeInfo;
try {
nodeInfo = JSON.parse(nodeInfoString);
} catch (error) {
console.error("Failed to parse node info from dataTransfer", error);
return;
}
const { nodeType, defaultData } = nodeInfo;
if (!nodeType) {
console.warn("Node type not found in dataTransfer");
return;
}
// 获取鼠标相对于 React Flow 画布的位置
// 需要计算鼠标位置相对于 reactFlowWrapper 的偏移量
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// 创建新节点
const newNode: Node = {
id: `${nodeType}-${Date.now()}`, // 使用更唯一的 ID
type: nodeType, // 设置节点类型
position,
data: { label: defaultData?.label || `${nodeType} node`, ...defaultData },
// 根据节点类型设置 Handle 位置 (可选,也可以在自定义节点内部定义)
// sourcePosition: Position.Bottom,
// targetPosition: Position.Top,
};
console.log('Node dropped:', newNode);
// 将新节点添加到状态中
setNodes((nds) => nds.concat(newNode));
},
[screenToFlowPosition, setNodes] // 依赖 project 方法和 setNodes
);
// --- 处理工作流运行 ---
const handleRunWorkflow = useCallback(async () => {
setIsRunning(true);
toast.info("正在执行工作流...");
console.log("Running workflow with nodes:", nodes);
console.log("Running workflow with edges:", edges);
try {
const result = await runWorkflow(nodes, edges); // Call the API function
if (result.success && result.output !== undefined && result.output_node_id) {
toast.success(result.message || "工作流执行成功!");
console.log("Workflow output:", result.output);
// 更新 ChatOutputNode 的数据以显示结果
setNodes((nds) =>
nds.map((node) => {
if (node.id === result.output_node_id && node.type === 'chatOutputNode') {
return { ...node, data: { ...node.data, displayText: result.output } };
}
return node;
})
);
} else {
toast.error(result.message || "工作流执行失败。");
console.error("Workflow execution failed:", result.message);
}
} catch (error) {
// This catch block might not be necessary if runWorkflow handles errors
toast.error("执行工作流时发生网络错误。");
console.error("API call error:", error);
} finally {
setIsRunning(false);
}
}, [nodes, edges, setNodes]); // Depend on nodes and edges
export default function WorkflowPage() {
return (
<div>
<h1 className="text-2xl font-semibold mb-4 dark:text-gray-200"></h1>
<p className="text-gray-700 dark:text-gray-400"> React Flow </p>
{/* 后续添加 React Flow 画布和节点面板 */}
<div className="flex flex-col h-full bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<h1 className="text-xl font-semibold p-4 border-b dark:border-gray-700 text-gray-800 dark:text-gray-200">
</h1>
{/* 主容器使用 Flexbox */}
<div className="flex flex-1 overflow-hidden">
{/* 侧边栏 */}
<WorkflowSidebar />
{/* React Flow 画布容器 */}
<div className="flex-1 h-full" ref={reactFlowWrapper}> {/* 添加 ref */}
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChangeIntercepted}
onConnect={onConnect}
onDragOver={onDragOver} // 添加 onDragOver
onDrop={onDrop} // 添加 onDrop
nodeTypes={nodeTypes} // <--- 传递自定义节点类型
fitView
className="bg-gray-50 dark:bg-gray-900"
>
{/* 使用 Panel 添加运行按钮到右上角 */}
<Panel position="top-right" className="p-2">
<Button
onClick={handleRunWorkflow}
disabled={isRunning}
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
>
{isRunning ? (
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
) : (
<PlayIcon className="mr-2 h-4 w-4" />
)}
</Button>
</Panel>
<Controls />
<MiniMap nodeStrokeWidth={3} zoomable pannable />
<Background gap={16} color="#ccc" variant={BackgroundVariant.Dots} />
</ReactFlow>
</div>
</div>
</div>
);
}
}
// --- 主页面组件,包含 Provider ---
export default function WorkflowPage() {
return (
<ReactFlowProvider>
<WorkflowEditor />
</ReactFlowProvider>
);
}

View File

@ -1,33 +1,51 @@
// File: frontend/lib/api.ts (更新)
// Description: 添加调用助手和会话管理 API 的函数
// File: frontend/lib/api.ts (Update)
// Description: 添加运行工作流的 API 函数
import { Assistant, AssistantCreateData, AssistantUpdateData } from "@/types/assistant";
import axios from "axios";
import type { Node, Edge } from "reactflow"; // Import React Flow types
import type {
Assistant,
Session,
Message,
AssistantCreateData,
AssistantUpdateData,
ChatApiResponse,
} from "./types"; // Assuming types are defined
// --- Types ---
export interface Session {
id: string;
title: string;
assistant_id: string;
created_at: string; // ISO date string
updated_at?: string | null; // Add updated_at
// Workflow Run types (match backend pydantic models)
interface WorkflowNodeData {
label?: string | null;
text?: string | null;
displayText?: string | null;
model?: string | null;
temperature?: number | null;
systemPrompt?: string | null;
// Add other node data fields as needed
[key: string]: any; // Allow extra fields
}
// Message type from backend
export interface Message {
id: string;
session_id: string;
sender: 'user' | 'ai'; // Or extend with 'system' if needed
text: string;
order: number;
created_at: string; // ISO date string
interface WorkflowNode {
id: string;
type: string;
position: { x: number; y: number };
data: WorkflowNodeData;
}
// 聊天响应类型
export interface ChatApiResponse {
reply: string;
session_id?: string | null; // 后端返回的新 session id
session_title?: string | null; // 后端返回的新 session title
interface WorkflowEdge {
id: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
}
interface WorkflowRunPayload {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
export interface WorkflowRunResult {
success: boolean;
message?: string | null;
output?: string | null;
output_node_id?: string | null;
}
// --- API Client Setup ---
@ -159,17 +177,61 @@ export const deleteSession = async (sessionId: string): Promise<void> => {
// --- Message API (New) ---
/** 获取指定会话的消息列表 */
export const getMessagesBySession = async (sessionId: string, limit: number = 100, skip: number = 0): Promise<Message[]> => {
export const getMessagesBySession = async (
sessionId: string,
limit: number = 100,
skip: number = 0
): Promise<Message[]> => {
try {
const response = await apiClient.get<Message[]>(
`/messages/session/${sessionId}`,
{
params: { limit, skip },
}
);
return response.data;
} catch (error) {
// Handle 404 specifically if needed (session exists but no messages)
if (axios.isAxiosError(error) && error.response?.status === 404) {
return []; // Return empty list if session not found or no messages
}
throw new Error(handleApiError(error, "getMessagesBySession"));
}
};
// --- Workflow API (New) ---
/**
*
* @param nodes - React Flow
* @param edges - React Flow
* @returns
*/
export const runWorkflow = async (nodes: Node[], edges: Edge[]): Promise<WorkflowRunResult> => {
// Map React Flow nodes/edges to the structure expected by the backend API
const payload: WorkflowRunPayload = {
nodes: nodes.map(n => ({
id: n.id,
type: n.type || 'default', // Ensure type is present
position: n.position,
data: n.data as WorkflowNodeData, // Assume data matches for now
})),
edges: edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
})),
};
try {
const response = await apiClient.get<Message[]>(`/messages/session/${sessionId}`, {
params: { limit, skip }
});
const response = await apiClient.post<WorkflowRunResult>('/workflow/run', payload);
return response.data;
} catch (error) {
// Handle 404 specifically if needed (session exists but no messages)
if (axios.isAxiosError(error) && error.response?.status === 404) {
return []; // Return empty list if session not found or no messages
}
throw new Error(handleApiError(error, 'getMessagesBySession'));
// Return a failed result structure on API error
return {
success: false,
message: handleApiError(error, 'runWorkflow'),
};
}
};
};

49
frontend/lib/types.ts Normal file
View File

@ -0,0 +1,49 @@
// --- Types (从后端模型同步或手动定义) ---
// 这些类型应该与后端 pydantic_models.py 中的 Read 模型匹配
export interface Assistant {
id: string;
name: string;
description?: string | null;
avatar?: string | null;
system_prompt: string;
model: string;
temperature: number;
}
// 创建助手时发送的数据类型
export interface AssistantCreateData {
name: string;
description?: string | null;
avatar?: string | null;
system_prompt: string;
model: string;
temperature: number;
}
// 更新助手时发送的数据类型 (所有字段可选)
export type AssistantUpdateData = Partial<AssistantCreateData>;
// --- Types ---
export interface Session {
id: string;
title: string;
assistant_id: string;
created_at: string; // ISO date string
updated_at?: string | null; // Add updated_at
}
// Message type from backend
export interface Message {
id: string;
session_id: string;
sender: "user" | "ai"; // Or extend with 'system' if needed
text: string;
order: number;
created_at: string; // ISO date string
}
// 聊天响应类型
export interface ChatApiResponse {
reply: string;
session_id?: string | null; // 后端返回的新 session id
session_title?: string | null; // 后端返回的新 session title
}

View File

@ -24,6 +24,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"reactflow": "^11.11.4",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"zod": "^3.24.3"

462
frontend/pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ importers:
react-hook-form:
specifier: ^7.56.1
version: 7.56.1(react@19.1.0)
reactflow:
specifier: ^11.11.4
version: 11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sonner:
specifier: ^2.0.3
version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -680,6 +683,42 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@reactflow/background@11.3.14':
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/controls@11.2.14':
resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/core@11.11.4':
resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/minimap@11.7.14':
resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/node-resizer@2.2.14':
resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/node-toolbar@1.3.14':
resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -786,9 +825,105 @@ packages:
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
'@types/d3-axis@3.0.6':
resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
'@types/d3-brush@3.0.6':
resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
'@types/d3-chord@3.0.6':
resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-contour@3.0.6':
resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
'@types/d3-delaunay@6.0.4':
resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
'@types/d3-dispatch@3.0.6':
resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-dsv@3.0.7':
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-fetch@3.0.7':
resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/d3-format@3.0.4':
resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
'@types/d3-geo@3.1.0':
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
'@types/d3-hierarchy@3.1.7':
resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-polygon@3.0.2':
resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
'@types/d3-quadtree@3.0.6':
resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
'@types/d3-random@3.0.3':
resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
'@types/d3-scale-chromatic@3.1.0':
resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time-format@4.0.3':
resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/d3@7.4.3':
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -1078,6 +1213,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@ -1113,6 +1251,44 @@ 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'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@ -1974,6 +2150,12 @@ packages:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
reactflow@11.11.4:
resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -2238,6 +2420,11 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.5.0:
resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -2270,6 +2457,21 @@ packages:
zod@3.24.3:
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
zustand@4.5.6:
resolution: {integrity: sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
@ -2776,6 +2978,84 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@reactflow/background@11.3.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
classcat: 5.0.5
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
zustand: 4.5.6(@types/react@19.1.2)(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/controls@11.2.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
classcat: 5.0.5
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
zustand: 4.5.6(@types/react@19.1.2)(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/core@11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
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: 19.1.0
react-dom: 19.1.0(react@19.1.0)
zustand: 4.5.6(@types/react@19.1.2)(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/minimap@11.7.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@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: 19.1.0
react-dom: 19.1.0(react@19.1.0)
zustand: 4.5.6(@types/react@19.1.2)(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/node-resizer@2.2.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
classcat: 5.0.5
d3-drag: 3.0.0
d3-selection: 3.0.0
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
zustand: 4.5.6(@types/react@19.1.2)(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/node-toolbar@1.3.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
classcat: 5.0.5
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
zustand: 4.5.6(@types/react@19.1.2)(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.11.0': {}
@ -2859,8 +3139,127 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/d3-array@3.2.1': {}
'@types/d3-axis@3.0.6':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-brush@3.0.6':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-chord@3.0.6': {}
'@types/d3-color@3.1.3': {}
'@types/d3-contour@3.0.6':
dependencies:
'@types/d3-array': 3.2.1
'@types/geojson': 7946.0.16
'@types/d3-delaunay@6.0.4': {}
'@types/d3-dispatch@3.0.6': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-dsv@3.0.7': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-fetch@3.0.7':
dependencies:
'@types/d3-dsv': 3.0.7
'@types/d3-force@3.0.10': {}
'@types/d3-format@3.0.4': {}
'@types/d3-geo@3.1.0':
dependencies:
'@types/geojson': 7946.0.16
'@types/d3-hierarchy@3.1.7': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@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-chromatic@3.1.0': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-selection@3.0.11': {}
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time-format@4.0.3': {}
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3@7.4.3':
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.6
'@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
'@types/estree@1.0.7': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
@ -3185,6 +3584,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classcat@5.0.5: {}
client-only@0.0.1: {}
clsx@2.1.1: {}
@ -3221,6 +3622,42 @@ snapshots:
csstype@3.1.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
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
d3-zoom@3.0.0:
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)
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@ -4195,6 +4632,20 @@ snapshots:
react@19.1.0: {}
reactflow@11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@reactflow/background': 11.3.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@reactflow/controls': 11.2.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@reactflow/core': 11.11.4(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@reactflow/minimap': 11.7.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@reactflow/node-resizer': 2.2.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@reactflow/node-toolbar': 1.3.14(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -4552,6 +5003,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.2
use-sync-external-store@1.5.0(react@19.1.0):
dependencies:
react: 19.1.0
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@ -4602,3 +5057,10 @@ snapshots:
yocto-queue@0.1.0: {}
zod@3.24.3: {}
zustand@4.5.6(@types/react@19.1.2)(react@19.1.0):
dependencies:
use-sync-external-store: 1.5.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.2
react: 19.1.0