@@ -1,5 +1,8 @@
import { useEffect , useMemo , useRef , useState } from "react" ;
import type { PointerEvent as ReactPointerEvent } from "react" ;
import type {
DragEvent as ReactDragEvent ,
PointerEvent as ReactPointerEvent ,
} from "react" ;
import { Link , useParams } from "react-router-dom" ;
import {
api ,
@@ -15,6 +18,8 @@ import Icon from "../components/Icon";
const NODE_W = 230 ;
const PORT_Y = 18 ;
const CANVAS_W = 1600 ;
const CANVAS_H = 1000 ;
const NODE_META : Record <
string ,
@@ -31,6 +36,13 @@ interface NodeRunState {
text : string | null ;
}
interface CtxMenuState {
kind : "node" | "edge" ;
id : string ;
x : number ;
y : number ;
}
function defaultData ( type : string ) : WorkflowNode [ "data" ] {
if ( type === "llm" ) {
return { prompt : "" , model_id : null , temperature : 0.7 , max_tokens : 2048 } ;
@@ -40,6 +52,10 @@ function defaultData(type: string): WorkflowNode["data"] {
return { } ;
}
function newId ( prefix : string ) {
return ` ${ prefix } _ ${ Date . now ( ) . toString ( 36 ) } ${ Math . floor ( Math . random ( ) * 1000 ) } ` ;
}
export default function WorkflowCanvasPage() {
const { workflowId } = useParams < { workflowId : string } > ( ) ;
const workflows = useStore ( ( s ) = > s . workflows ) ;
@@ -59,18 +75,32 @@ export default function WorkflowCanvasPage() {
const [ edges , setEdges ] = useState < WorkflowEdge [ ] > ( [ ] ) ;
const [ selectedNodeId , setSelectedNodeId ] = useState < string | null > ( null ) ;
const [ viewport , setViewport ] = useState ( { x : 0 , y : 0 , scale : 1 } ) ;
const viewportRef = useRef ( viewport ) ;
viewportRef . current = viewport ;
const [ dragNode , setDragNode ] = useState < {
id : string ;
startX : number ;
startY : number ;
origX : number ;
origY : number ;
scale : number ;
} | null > ( null ) ;
const [ panning , setPanning ] = useState < {
startX : number ;
startY : number ;
origX : number ;
origY : number ;
} | null > ( null ) ;
const [ tempEdge , setTempEdge ] = useState < {
from : string ;
x : number ;
y : number ;
x0 : number ;
y0 : number ;
x1 : number ;
y1 : number ;
} | null > ( null ) ;
const [ ctxMenu , setCtxMenu ] = useState < CtxMenuState | null > ( null ) ;
const [ runInput , setRunInput ] = useState ( "" ) ;
const [ runModelId , setRunModelId ] = useState < string | null > ( null ) ;
@@ -80,7 +110,8 @@ export default function WorkflowCanvasPage() {
const [ runError , setRunError ] = useState < string | null > ( null ) ;
const [ msg , setMsg ] = useState < string | null > ( null ) ;
const canvasRef = useRef < HTMLDivElement > ( null ) ;
const canvasWrapRef = useRef < HTMLDivElement > ( null ) ;
const importInputRef = useRef < HTMLInputElement > ( null ) ;
useEffect ( ( ) = > {
refreshWorkflows ( ) ;
@@ -101,6 +132,7 @@ export default function WorkflowCanvasPage() {
setNodeStatus ( { } ) ;
setRunResult ( null ) ;
setRunError ( null ) ;
setCtxMenu ( null ) ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ workflow ? . id ] ) ;
@@ -122,10 +154,43 @@ export default function WorkflowCanvasPage() {
} ;
} , [ workflow ? . id ] ) ;
// 鼠标滚轮缩放(以光标为中心)
useEffect ( ( ) = > {
const el = canvasWrapRef . current ;
if ( ! el ) return ;
const target = el ;
function onWheel ( e : WheelEvent ) {
e . preventDefault ( ) ;
const rect = target . getBoundingClientRect ( ) ;
const mx = e . clientX - rect . left ;
const my = e . clientY - rect . top ;
setViewport ( ( vp ) = > {
const factor = e . deltaY < 0 ? 1.12 : 1 / 1.12 ;
const scale = Math . min ( 2.5 , Math . max ( 0.25 , vp . scale * factor ) ) ;
const wx = ( mx - vp . x ) / vp . scale ;
const wy = ( my - vp . y ) / vp . scale ;
return { scale , x : mx - wx * scale , y : my - wy * scale } ;
} ) ;
}
target . addEventListener ( "wheel" , onWheel , { passive : false } ) ;
return ( ) = > target . removeEventListener ( "wheel" , onWheel ) ;
} , [ ] ) ;
// 关闭右键菜单(Esc)
useEffect ( ( ) = > {
if ( ! ctxMenu ) return ;
function onKey ( e : KeyboardEvent ) {
if ( e . key === "Escape" ) setCtxMenu ( null ) ;
}
window . addEventListener ( "keydown" , onKey ) ;
return ( ) = > window . removeEventListener ( "keydown" , onKey ) ;
} , [ ctxMenu ] ) ;
const selectedNode = useMemo (
( ) = > nodes . find ( ( n ) = > n . id === selectedNodeId ) ? ? null ,
[ nodes , selectedNodeId ] ,
) ;
const selectedNodeRun = selectedNode ? nodeStatus [ selectedNode . id ] ? ? null : null ;
const modelNameById = useMemo ( ( ) = > {
const map = new Map < string , string > ( ) ;
@@ -134,10 +199,16 @@ export default function WorkflowCanvasPage() {
} , [ models ] ) ;
function canvasPoint ( clientX : number , clientY : number ) {
const rect = canvasRef . current ? . getBoundingClientRect ( ) ;
const rect = canvasWrapRef . current ? . getBoundingClientRect ( ) ;
return { x : clientX - ( rect ? . left ? ? 0 ) , y : clientY - ( rect ? . top ? ? 0 ) } ;
}
function graphPoint ( clientX : number , clientY : number ) {
const p = canvasPoint ( clientX , clientY ) ;
const vp = viewportRef . current ;
return { x : ( p . x - vp . x ) / vp . scale , y : ( p . y - vp . y ) / vp . scale } ;
}
function updateNode ( id : string , patch : Partial < WorkflowNode > ) {
setNodes ( ( prev ) = > prev . map ( ( n ) = > ( n . id === id ? { . . . n , . . . patch } : n ) ) ) ;
}
@@ -148,24 +219,31 @@ export default function WorkflowCanvasPage() {
) ;
}
function addNode ( type : string ) {
const count = nodes . length ;
const id = ` n_ ${ Date . now ( ) . toString ( 36 ) } ${ Math . floor ( Math . random ( ) * 1000 ) } ` ;
function addNodeAt ( type : string , x : number , y : number ) {
const meta = NODE_META [ type ] ? ? { label : type , icon : "?" , desc : "" } ;
const node : WorkflowNode = {
id ,
id : newId ( "n" ) ,
type ,
label : meta.label ,
position : { x : 320 + ( count % 3 ) * 40 , y : 160 + ( count % 4 ) * 50 } ,
position : { x , y } ,
data : defaultData ( type ) ,
} ;
setNodes ( ( prev ) = > [ . . . prev , node ] ) ;
setSelectedNodeId ( id ) ;
setSelectedNodeId ( node . id ) ;
}
function addNode ( type : string ) {
const rect = canvasWrapRef . current ? . getBoundingClientRect ( ) ;
const vp = viewportRef . current ;
const cx = rect ? ( rect . width / 2 - vp . x ) / vp.scale : CANVAS_W / 2 ;
const cy = rect ? ( rect . height / 2 - vp . y ) / vp.scale : CANVAS_H / 2 ;
addNodeAt ( type , cx - NODE_W / 2 , cy - 20 ) ;
}
function startNodeDrag ( e : ReactPointerEvent , node : WorkflowNode ) {
e . stopPropagation ( ) ;
setSelectedNodeId ( node . id ) ;
setCtxMenu ( null ) ;
const p = canvasPoint ( e . clientX , e . clientY ) ;
setDragNode ( {
id : node.id ,
@@ -173,56 +251,76 @@ export default function WorkflowCanvasPage() {
startY : p.y ,
origX : node.position.x ,
origY : node.position.y ,
scale : viewportRef.current.scale ,
} ) ;
}
function startTempEdge ( nodeId : string , e : ReactPointerEvent ) {
e . stopPropagation ( ) ;
const p = canvasPoint ( e . clientX , e . clientY ) ;
setTempEdge ( { from : nodeId , x : p.x , y : p.y } ) ;
setCtxMenu ( null ) ;
const node = nodes . find ( ( n ) = > n . id === nodeId ) ;
const p = graphPoint ( e . clientX , e . clientY ) ;
setTempEdge ( {
from : nodeId ,
x0 : ( node ? . position . x ? ? 0 ) + NODE_W ,
y0 : ( node ? . position . y ? ? 0 ) + PORT_Y ,
x1 : p.x ,
y1 : p.y ,
} ) ;
}
function startPan ( e : ReactPointerEvent ) {
if ( e . button !== 0 ) return ;
setCtxMenu ( null ) ;
const p = canvasPoint ( e . clientX , e . clientY ) ;
const vp = viewportRef . current ;
setPanning ( { startX : p.x , startY : p.y , origX : vp.x , origY : vp.y } ) ;
}
// 节点拖动 / 画布平移 / 连线拖拽共用指针监听
useEffect ( ( ) = > {
if ( ! dragNode && ! tempEdge ) return ;
if ( ! dragNode && ! tempEdge && ! panning ) return ;
function onMove ( e : PointerEvent ) {
const p = canvasPoint ( e . clientX , e . clientY ) ;
if ( dragNode ) {
const dx = ( p . x - dragNode . startX ) / dragNode . scale ;
const dy = ( p . y - dragNode . startY ) / dragNode . scale ;
setNodes ( ( prev ) = >
prev . map ( ( n ) = >
n . id === dragNode . id
? {
. . . n ,
position : {
x : dragNode.origX + ( p . x - dragNode . startX ) ,
y : dragNode.origY + ( p . y - dragNode . startY ) ,
} ,
}
? { . . . n , position : { x : dragNode.origX + dx , y : dragNode.origY + dy } }
: n ,
) ,
) ;
}
if ( panning ) {
setViewport ( ( vp ) = > ( {
. . . vp ,
x : panning.origX + ( p . x - panning . startX ) ,
y : panning.origY + ( p . y - panning . startY ) ,
} ) ) ;
}
if ( tempEdge ) {
setTempEdge ( ( prev ) = > ( prev ? { . . . prev , x : p.x , y : p.y } : prev ) ) ;
const g = graphPoint ( e . clientX , e . clientY ) ;
setTempEdge ( ( prev ) = > ( prev ? { . . . prev , x1 : g.x , y1 : g.y } : prev ) ) ;
}
}
function onUp ( e : PointerEvent ) {
if ( tempEdge ) {
const el = document . elementFromPoint ( e . clientX , e . clientY ) as HTMLElement | null ;
const targetNode = el ? . closest ( "[data-port='input']" ) ? . getAttribute ( "data-node" ) ;
if ( targetNode && targetNode !== tempEdge . from ) {
const target = el ? . closest ( "[data-port='input']" ) ? . getAttribute ( "data-node" ) ;
if ( target && target !== tempEdge . from ) {
setEdges ( ( prev ) = > {
const rest = prev . filter (
( ed ) = > ! ( ed . from === tempEdge . from && ed . to === targetNode ) ,
( ed ) = > ! ( ed . from === tempEdge . from && ed . to === target ) ,
) ;
return [
. . . rest ,
{ id : ` e_ ${ Date . now ( ) . toString ( 36 ) } ${ Math . floor ( Math . random ( ) * 1000 ) } ` , from : tempEdge . from , to : targetNode } ,
] ;
return [ . . . rest , { id : newId ( "e" ) , from : tempEdge . from , to : target } ] ;
} ) ;
}
setTempEdge ( null ) ;
}
setDragNode ( null ) ;
setPanning ( null ) ;
}
window . addEventListener ( "pointermove" , onMove ) ;
window . addEventListener ( "pointerup" , onUp ) ;
@@ -230,7 +328,7 @@ export default function WorkflowCanvasPage() {
window . removeEventListener ( "pointermove" , onMove ) ;
window . removeEventListener ( "pointerup" , onUp ) ;
} ;
} , [ dragNode , tempEdge ] ) ;
} , [ dragNode , tempEdge , panning ] ) ;
function deleteEdge ( id : string ) {
setEdges ( ( prev ) = > prev . filter ( ( e ) = > e . id !== id ) ) ;
@@ -242,6 +340,57 @@ export default function WorkflowCanvasPage() {
if ( selectedNodeId === id ) setSelectedNodeId ( null ) ;
}
function handleNodeContextMenu ( e : React.MouseEvent , node : WorkflowNode ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
setSelectedNodeId ( node . id ) ;
setCtxMenu ( { kind : "node" , id : node.id , x : e.clientX , y : e.clientY } ) ;
}
function handleEdgeContextMenu ( e : React.MouseEvent , edge : WorkflowEdge ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
setCtxMenu ( { kind : "edge" , id : edge.id , x : e.clientX , y : e.clientY } ) ;
}
function renameNode ( id : string ) {
const node = nodes . find ( ( n ) = > n . id === id ) ;
if ( ! node ) return ;
const label = prompt ( "修改节点名称" , node . label ) ;
if ( label !== null && label . trim ( ) ) {
updateNode ( id , { label : label.trim ( ) } ) ;
}
}
function editNodeNote ( id : string ) {
const node = nodes . find ( ( n ) = > n . id === id ) ;
if ( ! node ) return ;
const note = prompt ( "节点说明(备注)" , node . data . note ? ? "" ) ;
if ( note !== null ) {
updateNodeData ( id , { note : note.trim ( ) } ) ;
}
}
function toggleNodeFavorite ( id : string ) {
const node = nodes . find ( ( n ) = > n . id === id ) ;
if ( ! node ) return ;
updateNodeData ( id , { favorite : ! node . data . favorite } ) ;
}
function duplicateNode ( id : string ) {
const node = nodes . find ( ( n ) = > n . id === id ) ;
if ( ! node ) return ;
const copy : WorkflowNode = {
. . . node ,
id : newId ( "n" ) ,
label : ` ${ node . label } 副本 ` ,
position : { x : node.position.x + 40 , y : node.position.y + 40 } ,
data : JSON.parse ( JSON . stringify ( node . data ) ) ,
} ;
setNodes ( ( prev ) = > [ . . . prev , copy ] ) ;
setSelectedNodeId ( copy . id ) ;
}
async function saveWorkflow ( ) : Promise < boolean > {
if ( ! workflow ) return false ;
try {
@@ -294,6 +443,69 @@ export default function WorkflowCanvasPage() {
}
}
function handleExport() {
const data = {
app : "xianren-studio" ,
type : "xianren-workflow" ,
version : 1 ,
workflow : { name , icon , description , nodes , edges , model_id : modelId } ,
} ;
const blob = new Blob ( [ JSON . stringify ( data , null , 2 ) ] , { type : "application/json" } ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( "a" ) ;
a . href = url ;
a . download = ` ${ ( name || "workflow" ) . replace ( /[\\/:*?"<>|]/g , "_" ) } .json ` ;
a . click ( ) ;
URL . revokeObjectURL ( url ) ;
setMsg ( "已导出画布 JSON" ) ;
}
async function handleImportFile ( file : File ) {
try {
const text = await file . text ( ) ;
const data = JSON . parse ( text ) ;
const wf = data ? . workflow ? ? data ;
const rawNodes = Array . isArray ( wf ? . nodes ) ? wf.nodes : null ;
if ( ! rawNodes ) throw new Error ( "JSON 中缺少有效的 nodes 数组" ) ;
const sanitized = rawNodes . map ( ( n : WorkflowNode ) = > ( {
id : String ( n . id ? ? newId ( "n" ) ) ,
type : String ( n . type ? ? "llm" ) ,
label : typeof n . label === "string" ? n.label : String ( n . type ? ? "节点" ) ,
position : {
x : Number ( n . position ? . x ? ? 0 ) ,
y : Number ( n . position ? . y ? ? 0 ) ,
} ,
data : n.data && typeof n . data === "object" ? n . data : { } ,
} ) ) ;
const rawEdges = Array . isArray ( wf ? . edges ) ? wf . edges : [ ] ;
const sanitizedEdges = rawEdges
. filter (
( ed : WorkflowEdge ) = >
ed && typeof ed . from === "string" && typeof ed . to === "string" ,
)
. map ( ( ed : WorkflowEdge ) = > ( {
id : String ( ed . id ? ? newId ( "e" ) ) ,
from : String ( ed . from ) ,
to : String ( ed . to ) ,
} ) ) ;
setName ( typeof wf . name === "string" && wf . name ? wf.name : name ) ;
setIcon ( typeof wf . icon === "string" && wf . icon ? wf.icon : icon ) ;
setDescription ( typeof wf . description === "string" ? wf.description : description ) ;
setModelId ( wf . model_id || modelId ) ;
setNodes ( sanitized ) ;
setEdges ( sanitizedEdges ) ;
setSelectedNodeId ( null ) ;
setRunResult ( null ) ;
setNodeStatus ( { } ) ;
await saveWorkflow ( ) ;
setMsg ( "导入成功并已保存" ) ;
} catch ( e ) {
setRunError ( ` 导入失败: ${ String ( e ) } ` ) ;
} finally {
if ( importInputRef . current ) importInputRef . current . value = "" ;
}
}
if ( ! workflowsLoaded ) {
return (
< div className = "flex h-full items-center justify-center text-sm text-slate-400" >
@@ -315,16 +527,17 @@ export default function WorkflowCanvasPage() {
return (
< div className = "flex h-full flex-col" >
{ /* 顶部工具栏 */ }
< div className = "flex items-center gap-3 border-b border-border bg-panel px-4 py-2.5" >
< Link
to = "/workflows"
className = "flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-slate-400 hover:bg-panel-2 hover:text-slate-200"
className = "flex shrink-0 items-center gap-1 whitespace-nowrap rounded-lg px-2 py-1 text-xs text-slate-400 hover:bg-panel-2 hover:text-slate-200"
>
< Icon name = "chevron-left" className = "h-3.5 w-3.5" / >
工 作 流
返 回 工 作 流
< / Link >
< input
className = "input w-52 px-2 py-1 text-sm"
className = "input w-44 flex-none px-2 py-1 text-sm"
value = { name }
onChange = { ( e ) = > setName ( e . target . value ) }
placeholder = "工作流名称"
@@ -335,11 +548,11 @@ export default function WorkflowCanvasPage() {
>
{ workflow . kind === "preset" ? "预制" : "自定义" }
< / span >
< span className = "text-xs text-slate-500" >
< span className = "shrink-0 text-xs text-slate-500" >
{ nodes . length } 节 点 · { edges . length } 连 线
< / span >
{ msg ? < span className = "text-xs text-emerald-400" > { msg } < / span > : null }
< div className = "ml-auto flex items-center gap-2" >
{ msg ? < span className = "shrink-0 text-xs text-emerald-400" > { msg } < / span > : null }
< div className = "ml-auto flex shrink-0 items-center gap-2" >
< button className = "btn-secondary" onClick = { ( ) = > saveWorkflow ( ) } >
保 存
< / button >
@@ -350,37 +563,72 @@ export default function WorkflowCanvasPage() {
< / div >
< div className = "flex min-h-0 flex-1" >
{ /* 节点面板 */ }
{ /* 左侧 节点面板(可拖入画布) */ }
< div className = "flex w-44 shrink-0 flex-col gap-2 overflow-y-auto border-r border-border bg-panel p-3" >
< div className = "text-xs uppercase text-slate-500" > 节 点 < / div >
< div className = "text-xs uppercase text-slate-500" > 节 点 ( 拖 入 画 布 ) < / div >
{ Object . entries ( NODE_META ) . map ( ( [ type , meta ] ) = > (
< button
key = { type }
className = "flex items-center gap-2 rounded-lg border border-border bg-panel-2 px-3 py-2 text-left text-xs text-slate-300 hover:border-accent/50 hover:text-white"
draggable
onDragStart = { ( e ) = > {
e . dataTransfer . setData ( "text/plain" , type ) ;
e . dataTransfer . effectAllowed = "copy" ;
} }
className = "flex cursor-grab items-center gap-2 rounded-lg border border-border bg-panel-2 px-3 py-2 text-left text-xs text-slate-300 hover:border-accent/50 hover:text-white active:cursor-grabbing"
onClick = { ( ) = > addNode ( type ) }
title = { meta . desc }
title = { ` ${ meta . desc } (点击或拖入画布) ` }
>
< span className = { ` text-base ${ meta . color } ` } > { meta . icon } < / span >
< span className = "flex-1" >
< span className = "block font-medium" > { meta . label } < / span >
< span className = "block truncate text-[10px] text-slate-500" > { meta . desc } < / span >
< / span >
< span className = "text-slate-500" > + < / span >
< / button >
) ) }
< div className = "mt-2 rounded-lg border border-dashed border-border p-2 text-[10px] leading-relaxed text-slate-500" >
提 示 词 中 可 用 < code className = "text-slate-300" > { "{{input}}" } < / code > 与 { " " }
< code className = "text-slate-300" > { "{{节点id}}" } < / code > 引 用 上 游 输 出 。
滚 轮 缩 放 · 拖 动 空 白 平 移 · 右 键 节 点 / 连 线 操 作 。
< br / >
提 示 词 可 用 < code className = "text-slate-300" > { "{{input}}" } < / code > 与 { " " }
< code className = "text-slate-300" > { "{{节点id}}" } < / code > 引 用 。
< / div >
< / div >
{ /* 画布 */ }
< div className = "min-w-0 flex-1 overflow-auto bg-[#0a0d13]" >
< div ref = { canvasRef } className = "relative" style = { { width : 1200 , height : 720 } } >
< div
ref = { canvasWrapRef }
className = { ` relative min-w-0 flex-1 overflow-hidden bg-[#0a0d13] ${
panning ? "cursor-grabbing" : "cursor-grab"
} ` }
onPointerDown = { startPan }
onDragOver = { ( e ) = > e . preventDefault ( ) }
onDrop = { ( e : ReactDragEvent ) = > {
e . preventDefault ( ) ;
const type = e . dataTransfer . getData ( "text/plain" ) ;
if ( ! NODE_META [ type ] ) return ;
const g = graphPoint ( e . clientX , e . clientY ) ;
addNodeAt ( type , g . x - NODE_W / 2 , g . y - 20 ) ;
} }
onContextMenu = { ( e ) = > {
e . preventDefault ( ) ;
setCtxMenu ( null ) ;
} }
>
< div
className = "absolute left-0 top-0"
style = { {
width : CANVAS_W ,
height : CANVAS_H ,
transform : ` translate( ${ viewport . x } px, ${ viewport . y } px) scale( ${ viewport . scale } ) ` ,
transformOrigin : "0 0" ,
backgroundImage :
"radial-gradient(circle, rgba(148,163,184,0.16) 1px, transparent 1px)" ,
backgroundSize : "24px 24px" ,
} }
>
< svg
className = "pointer-events-none absolute inset-0"
width = { 1200 }
height = { 720 }
width = { CANVAS_W }
height = { CANVAS_H }
>
{ edges . map ( ( edge ) = > {
const from = nodes . find ( ( n ) = > n . id === edge . from ) ;
@@ -399,7 +647,8 @@ export default function WorkflowCanvasPage() {
stroke = "transparent"
strokeWidth = { 16 }
style = { { pointerEvents : "stroke" , cursor : "pointer" } }
onClick = { ( ) = > deleteEdge ( edge . id ) }
onPointerDown = { ( e ) = > e . stopPropagation ( ) }
onContextMenu = { ( e ) = > handleEdgeContextMenu ( e , edge ) }
/ >
< path
d = { d }
@@ -413,7 +662,7 @@ export default function WorkflowCanvasPage() {
} ) }
{ tempEdge ? (
< path
d = { ` M ${ tempEdge . x } ${ tempEdge . y } L ${ tempEdge . x } ${ tempEdge . y } ` }
d = { ` M ${ tempEdge . x0 } ${ tempEdge . y0 } L ${ tempEdge . x1 } ${ tempEdge . y1 } ` }
fill = "none"
stroke = "#a78bfa"
strokeWidth = { 2 }
@@ -428,19 +677,20 @@ export default function WorkflowCanvasPage() {
selected = { selectedNodeId === node . id }
runState = { nodeStatus [ node . id ] ? ? null }
onSelect = { ( ) = > setSelectedNodeId ( node . id ) }
onContextMenu = { ( e ) = > handleNodeContextMenu ( e , node ) }
onDragStart = { ( e ) = > startNodeDrag ( e , node ) }
onOutputPointerDown = { ( e ) = > startTempEdge ( node . id , e ) }
/ >
) ) }
{ nodes . length === 0 ? (
< div className = "absolute inset-0 flex items-center justify-center text-sm text-slate-600" >
从 左 侧 添 加 节 点 , 开 始 编 排 工 作 流
< div className = "pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-slate-600" >
从 左 侧 拖 入 节 点 , 或 点 击 节 点 添 加 到 画 布
< / div >
) : null }
< / div >
< / div >
{ /* 右侧面板 */ }
{ /* 右侧面板:只显示选中节点的选项 */ }
< div className = "flex w-80 shrink-0 flex-col gap-4 overflow-y-auto border-l border-border bg-panel p-3" >
< section >
< div className = "mb-2 text-xs uppercase text-slate-500" > 运 行 工 作 流 < / div >
@@ -495,6 +745,7 @@ export default function WorkflowCanvasPage() {
{ selectedNode ? (
< NodeEditor
node = { selectedNode }
runState = { selectedNodeRun }
models = { models }
modelNameById = { modelNameById }
onChange = { ( patch ) = > updateNode ( selectedNode . id , patch ) }
@@ -502,7 +753,9 @@ export default function WorkflowCanvasPage() {
onDelete = { ( ) = > deleteNode ( selectedNode . id ) }
/ >
) : (
< div className = "text-xs text-slate-600" > 点 击 画 布 中 的 节 点 进 行 设 置 < / div >
< div className = "rounded-lg border border-dashed border-border px-3 py-4 text-center text-xs text-slate-600" >
点 击 画 布 中 的 节 点 后 , 这 里 才 显 示 该 节 点 的 选 项
< / div >
) }
< / section >
@@ -551,53 +804,95 @@ export default function WorkflowCanvasPage() {
/ >
启 用
< / label >
< / section >
< section >
< div className = "mb-2 text-xs uppercase text-slate-500" > 各 节 点 输 出 < / div >
{ Object . keys ( nodeStatus ) . length === 0 ? (
< div className = "text-xs text-slate-600" > 运 行 工 作 流 后 在 此 查 看 每 个 节 点 的 输 出 < / div >
) : (
< div className = "space-y-2" >
{ nodes . map ( ( n ) = > {
const st = nodeStatus [ n . id ] ;
if ( ! st ) return null ;
return (
< div key = { n . id } className = "rounded-lg border border-border bg-panel-2 px-2.5 py-2" >
< div className = "flex items-center gap-1.5 text-xs text-slate-300" >
< span
className = { ` h-1.5 w-1.5 rounded-full ${
st . status === "done"
? "bg-emerald-400"
: st . status === "running"
? "animate-pulse bg-amber-400"
: "bg-red-400"
} ` }
/ >
{ n . label }
< / div >
{ st . text != null ? (
< div className = "mt-1 line-clamp-3 whitespace-pre-wrap text-[11px] text-slate-500" >
{ st . text }
< / div >
) : null }
< / div >
) ;
} ) }
< / div >
) }
< div className = "mt-3 flex gap-2" >
< button className = "btn-secondary flex-1 text-xs" onClick = { handleExport } >
导 出 画 布
< / button >
< button
className = "btn-secondary flex-1 text-xs"
onClick = { ( ) = > importInputRef . current ? . click ( ) }
>
导 入 画 布
< / button >
< input
ref = { importInputRef }
type = "file"
accept = ".json"
className = "hidden"
onChange = { ( e ) = > {
if ( e . target . files && e . target . files . length > 0 ) {
handleImportFile ( e . target . files [ 0 ] ) ;
}
} }
/ >
< / div >
< / section >
< / div >
< / div >
{ /* 右键菜单 */ }
{ ctxMenu ? (
< div
className = "fixed z-50 w-44 rounded-lg border border-border bg-panel-2 p-1 shadow-xl"
style = { {
left : Math.min ( ctxMenu . x , window . innerWidth - 190 ) ,
top : Math.min ( ctxMenu . y , window . innerHeight - 240 ) ,
} }
onPointerDown = { ( e ) = > e . stopPropagation ( ) }
onClick = { ( e ) = > e . stopPropagation ( ) }
>
{ ctxMenu . kind === "node" ? (
< >
< CtxMenuItem label = "修改节点名称" onClick = { ( ) = > renameNode ( ctxMenu . id ) } / >
< CtxMenuItem label = "说明 / 备注" onClick = { ( ) = > editNodeNote ( ctxMenu . id ) } / >
< CtxMenuItem
label = {
nodes . find ( ( n ) = > n . id === ctxMenu . id ) ? . data . favorite
? "取消收藏"
: "收藏"
}
onClick = { ( ) = > toggleNodeFavorite ( ctxMenu . id ) }
/ >
< CtxMenuItem label = "复制节点" onClick = { ( ) = > duplicateNode ( ctxMenu . id ) } / >
< div className = "my-1 h-px bg-border/60" / >
< CtxMenuItem danger label = "删除节点" onClick = { ( ) = > deleteNode ( ctxMenu . id ) } / >
< / >
) : (
< CtxMenuItem danger label = "删除连线" onClick = { ( ) = > deleteEdge ( ctxMenu . id ) } / >
) }
< / div >
) : null }
< / div >
) ;
}
function CtxMenuItem ( {
label ,
danger ,
onClick ,
} : {
label : string ;
danger? : boolean ;
onClick : ( ) = > void ;
} ) {
return (
< button
className = { ` block w-full rounded px-2.5 py-1.5 text-left text-xs ${
danger ? "text-red-300 hover:bg-red-500/20" : "text-slate-300 hover:bg-white/10"
} ` }
onClick = { onClick }
>
{ label }
< / button >
) ;
}
function CanvasNode ( {
node ,
selected ,
runState ,
onSelect ,
onContextMenu ,
onDragStart ,
onOutputPointerDown ,
} : {
@@ -605,10 +900,17 @@ function CanvasNode({
selected : boolean ;
runState : NodeRunState | null ;
onSelect : ( ) = > void ;
onContextMenu : ( e : React.MouseEvent ) = > void ;
onDragStart : ( e : ReactPointerEvent ) = > void ;
onOutputPointerDown : ( e : ReactPointerEvent ) = > void ;
} ) {
const meta = NODE_META [ node . type ] ? ? { label : node.type , icon : "?" , color : "text-slate-300" , desc : "" } ;
const meta =
NODE_META [ node . type ] ? ? {
label : node.type ,
icon : "?" ,
color : "text-slate-300" ,
desc : "" ,
} ;
const statusColor =
runState ? . status === "done"
? "border-emerald-500/60"
@@ -636,6 +938,8 @@ function CanvasNode({
e . stopPropagation ( ) ;
onSelect ( ) ;
} }
onPointerDown = { ( e ) = > e . stopPropagation ( ) }
onContextMenu = { onContextMenu }
>
< div
className = "flex cursor-grab items-center gap-2 rounded-t-xl border-b border-border/60 bg-panel-2 px-3 py-1.5 active:cursor-grabbing"
@@ -646,12 +950,22 @@ function CanvasNode({
< span className = "min-w-0 flex-1 truncate text-xs font-medium text-slate-200" >
{ node . label }
< / span >
< span className = "truncate text-[10px] text-slate-500" > { node . id } < / span >
{ node . data . favorite ? (
< span className = "shrink-0 text-xs text-amber-300" title = "已收藏" >
★
< / span >
) : null }
< span className = "shrink-0 truncate text-[10px] text-slate-500" > { node . id } < / span >
< / div >
< div className = "px-3 py-2" >
< div className = "line-clamp-2 whitespace-pre-wrap text-[11px] text-slate-400" >
{ preview || "(空)" }
< / div >
{ node . data . note ? (
< div className = "mt-1 line-clamp-2 whitespace-pre-wrap text-[10px] text-amber-200/70" >
📝 { node . data . note }
< / div >
) : null }
{ runState ? . text != null ? (
< div className = "mt-1.5 rounded bg-panel-2 px-2 py-1 line-clamp-2 whitespace-pre-wrap text-[10px] text-emerald-300/90" >
{ runState . text }
@@ -684,6 +998,7 @@ function CanvasNode({
function NodeEditor ( {
node ,
runState ,
models ,
modelNameById ,
onChange ,
@@ -691,6 +1006,7 @@ function NodeEditor({
onDelete ,
} : {
node : WorkflowNode ;
runState : NodeRunState | null ;
models : ModelInfo [ ] ;
modelNameById : Map < string , string > ;
onChange : ( patch : Partial < WorkflowNode > ) = > void ;
@@ -792,10 +1108,36 @@ function NodeEditor({
className = "input mt-1 min-h-[80px] resize-y text-xs"
value = { node . data . template ? ? "" }
onChange = { ( e ) = > onDataChange ( { template : e.target.value } ) }
placeholder = ' 例如:{{n_llm}}'
placeholder = " 例如:{{n_llm}}"
/ >
< / label >
) : null }
< label className = "block text-sm" >
< span className = "text-xs text-slate-400" > 说 明 / 备 注 ( 显 示 在 节 点 上 ) < / span >
< input
className = "input mt-1 text-xs"
value = { node . data . note ? ? "" }
onChange = { ( e ) = > onDataChange ( { note : e.target.value } ) }
placeholder = "可填写节点用途说明"
/ >
< / label >
< label className = "flex cursor-pointer items-center gap-2 text-xs text-slate-300" >
< input
type = "checkbox"
checked = { ! ! node . data . favorite }
onChange = { ( ) = > onDataChange ( { favorite : ! node . data . favorite } ) }
className = "accent-indigo-500"
/ >
收 藏 该 节 点
< / label >
{ runState ? . text != null ? (
< div className = "rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2" >
< div className = "text-[10px] text-emerald-300" > 本 节 点 输 出 < / div >
< div className = "mt-1 max-h-28 overflow-y-auto whitespace-pre-wrap text-[11px] leading-relaxed text-slate-200" >
{ runState . text }
< / div >
< / div >
) : null }
< button
className = "mt-1 w-full rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/20"
onClick = { onDelete }