mirror of
https://gitee.com/dlmu-cone/Arborflow
synced 2026-10-24 02:02:19 +08:00
feat: launch ArborFlow behavior tree studio
This commit is contained in:
27
src/components/BehaviorNodeCard.tsx
Normal file
27
src/components/BehaviorNodeCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
import { CircleDot, OctagonAlert } from 'lucide-react'
|
||||
import { NODE_DEFINITION_MAP, canHaveChildren } from '../lib/catalog'
|
||||
import type { BehaviorNode } from '../types'
|
||||
|
||||
export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
|
||||
const definition = NODE_DEFINITION_MAP.get(data.nodeType)
|
||||
const Icon = definition?.icon || CircleDot
|
||||
const status = data.runtimeStatus || 'IDLE'
|
||||
|
||||
return (
|
||||
<div className={`behavior-node category-${data.category} runtime-${status.toLowerCase()} ${selected ? 'is-selected' : ''}`}>
|
||||
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
|
||||
<div className="node-accent" />
|
||||
<div className="node-icon"><Icon size={17} strokeWidth={1.9} /></div>
|
||||
<div className="node-copy">
|
||||
<div className="node-title" title={data.label}>{data.label}</div>
|
||||
<div className="node-type">{data.registrationName}</div>
|
||||
</div>
|
||||
{data.breakpoint && <OctagonAlert className="breakpoint-icon" size={15} aria-label="Breakpoint" />}
|
||||
{status !== 'IDLE' && <span className={`runtime-badge runtime-${status.toLowerCase()}`}>{status}</span>}
|
||||
{canHaveChildren(data.nodeType) && (
|
||||
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
src/components/Inspector.tsx
Normal file
131
src/components/Inspector.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react'
|
||||
import { CATEGORY_LABELS } from '../lib/catalog'
|
||||
import type { BehaviorNode, BehaviorTreeDocument, ValidationIssue } from '../types'
|
||||
|
||||
interface InspectorProps {
|
||||
document: BehaviorTreeDocument
|
||||
selectedNode: BehaviorNode | null
|
||||
issues: ValidationIssue[]
|
||||
onUpdateDocument: (patch: Partial<Pick<BehaviorTreeDocument, 'title' | 'mainTreeId'>>) => void
|
||||
onUpdateNode: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onSelectIssue: (nodeId: string) => void
|
||||
}
|
||||
|
||||
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) {
|
||||
const updatePort = (oldKey: string, key: string, value: string) => {
|
||||
if (!selectedNode) return
|
||||
const ports = { ...selectedNode.data.ports }
|
||||
if (oldKey !== key) delete ports[oldKey]
|
||||
ports[key] = value
|
||||
onUpdateNode(selectedNode.id, { ports })
|
||||
}
|
||||
|
||||
const removePort = (key: string) => {
|
||||
if (!selectedNode) return
|
||||
const ports = { ...selectedNode.data.ports }
|
||||
delete ports[key]
|
||||
onUpdateNode(selectedNode.id, { ports })
|
||||
}
|
||||
|
||||
const addPort = () => {
|
||||
if (!selectedNode) return
|
||||
let key = 'port'
|
||||
let index = 1
|
||||
while (key in selectedNode.data.ports) key = `port_${index++}`
|
||||
onUpdateNode(selectedNode.id, { ports: { ...selectedNode.data.ports, [key]: '' } })
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="right-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">INSPECTOR</span>
|
||||
<h2>{selectedNode ? '节点属性' : '行为树'}</h2>
|
||||
</div>
|
||||
{selectedNode && <span className={`category-chip category-${selectedNode.data.category}`}>{CATEGORY_LABELS[selectedNode.data.category]}</span>}
|
||||
</div>
|
||||
|
||||
<div className="inspector-scroll">
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div className="field-group">
|
||||
<label htmlFor="node-label">显示名称</label>
|
||||
<input id="node-label" value={selectedNode.data.label} onChange={(event) => onUpdateNode(selectedNode.id, { label: event.target.value })} />
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor="registration-name">注册名称 / ID</label>
|
||||
<input id="registration-name" value={selectedNode.data.registrationName} onChange={(event) => onUpdateNode(selectedNode.id, { registrationName: event.target.value })} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field-group">
|
||||
<label>节点类型</label>
|
||||
<div className="read-only-value">{selectedNode.data.nodeType}</div>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label>运行匹配 ID</label>
|
||||
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-divider" />
|
||||
<div className="section-title-row">
|
||||
<div className="section-title"><Braces size={15} />端口与黑板</div>
|
||||
<button className="icon-button compact" onClick={addPort} title="添加端口"><Plus size={15} /></button>
|
||||
</div>
|
||||
<div className="ports-list">
|
||||
{Object.entries(selectedNode.data.ports).map(([key, value]) => (
|
||||
<div className="port-row" key={key}>
|
||||
<input className="port-key" value={key} onChange={(event) => updatePort(key, event.target.value, value)} aria-label="端口名" />
|
||||
<input value={value} onChange={(event) => updatePort(key, key, event.target.value)} placeholder="值或 {blackboard}" aria-label={`${key} 的值`} />
|
||||
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title="删除端口"><X size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
{!Object.keys(selectedNode.data.ports).length && <div className="inline-empty">暂无端口</div>}
|
||||
</div>
|
||||
<div className="section-divider" />
|
||||
<label className="toggle-row">
|
||||
<span><span className="toggle-title">断点</span><span className="toggle-description">运行到此节点时标记</span></span>
|
||||
<input type="checkbox" checked={selectedNode.data.breakpoint} onChange={(event) => onUpdateNode(selectedNode.id, { breakpoint: event.target.checked })} />
|
||||
</label>
|
||||
<div className="field-group notes-field">
|
||||
<label htmlFor="node-notes">备注</label>
|
||||
<textarea id="node-notes" rows={4} value={selectedNode.data.notes} onChange={(event) => onUpdateNode(selectedNode.id, { notes: event.target.value })} />
|
||||
</div>
|
||||
<button className="danger-button" onClick={() => onDeleteNode(selectedNode.id)}><Trash2 size={15} />删除节点</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field-group">
|
||||
<label htmlFor="tree-title">工程名称</label>
|
||||
<input id="tree-title" value={document.title} onChange={(event) => onUpdateDocument({ title: event.target.value })} />
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor="tree-id">主树 ID</label>
|
||||
<input id="tree-id" value={document.mainTreeId} onChange={(event) => onUpdateDocument({ mainTreeId: event.target.value })} />
|
||||
</div>
|
||||
<div className="tree-summary">
|
||||
<div><strong>{document.nodes.length}</strong><span>节点</span></div>
|
||||
<div><strong>{document.edges.length}</strong><span>连接</span></div>
|
||||
<div><strong>{issues.filter((item) => item.severity === 'error').length}</strong><span>错误</span></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="section-divider" />
|
||||
<div className="section-title-row">
|
||||
<div className="section-title"><AlertCircle size={15} />结构检查</div>
|
||||
<span className="count-label">{issues.length}</span>
|
||||
</div>
|
||||
<div className="issue-list">
|
||||
{!issues.length && <div className="validation-ok">结构有效</div>}
|
||||
{issues.map((issue, index) => (
|
||||
<button key={`${issue.message}-${index}`} className={`issue-row ${issue.severity}`} onClick={() => issue.nodeId && onSelectIssue(issue.nodeId)} disabled={!issue.nodeId}>
|
||||
<span className="issue-dot" />
|
||||
<span>{issue.message}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
175
src/components/MonitorPanel.tsx
Normal file
175
src/components/MonitorPanel.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Activity, CircleStop, PlugZap, RotateCw, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { matchRuntimeNodes, parseMonitorMessage } from '../lib/runtime'
|
||||
import type { BehaviorNode, RuntimeEvent, RuntimeStatus } from '../types'
|
||||
|
||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
interface MonitorPanelProps {
|
||||
open: boolean
|
||||
nodes: BehaviorNode[]
|
||||
onClose: () => void
|
||||
onRuntimeUpdate: (nodeIds: string[], status: RuntimeStatus) => void
|
||||
onClearStatuses: () => void
|
||||
}
|
||||
|
||||
const STATE_LABEL: Record<ConnectionState, string> = {
|
||||
disconnected: '未连接', connecting: '连接中', connected: '已连接', error: '连接错误',
|
||||
}
|
||||
|
||||
export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearStatuses }: MonitorPanelProps) {
|
||||
const [endpoint, setEndpoint] = useState(() => localStorage.getItem('arborflow.monitor.endpoint') || 'ws://127.0.0.1:1667')
|
||||
const [rosTopic, setRosTopic] = useState(() => localStorage.getItem('arborflow.monitor.topic') || '/arborflow/status')
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||
const [shouldConnect, setShouldConnect] = useState(false)
|
||||
const [autoReconnect, setAutoReconnect] = useState(true)
|
||||
const [events, setEvents] = useState<RuntimeEvent[]>([])
|
||||
const [messageCount, setMessageCount] = useState(0)
|
||||
const [lastLatency, setLastLatency] = useState<number | null>(null)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const nodesRef = useRef(nodes)
|
||||
const runtimeUpdateRef = useRef(onRuntimeUpdate)
|
||||
|
||||
nodesRef.current = nodes
|
||||
runtimeUpdateRef.current = onRuntimeUpdate
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldConnect) return
|
||||
let disposed = false
|
||||
let reconnectTimer: number | undefined
|
||||
let pingTimer: number | undefined
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return
|
||||
setConnectionState('connecting')
|
||||
localStorage.setItem('arborflow.monitor.endpoint', endpoint)
|
||||
localStorage.setItem('arborflow.monitor.topic', rosTopic)
|
||||
let socket: WebSocket
|
||||
try {
|
||||
socket = new WebSocket(endpoint)
|
||||
} catch {
|
||||
setConnectionState('error')
|
||||
return
|
||||
}
|
||||
socketRef.current = socket
|
||||
socket.onopen = () => {
|
||||
setConnectionState('connected')
|
||||
socket.send(JSON.stringify({ type: 'subscribe', client: 'ArborFlow', protocol: 1 }))
|
||||
socket.send(JSON.stringify({ op: 'subscribe', topic: rosTopic, type: 'std_msgs/msg/String' }))
|
||||
pingTimer = window.setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }))
|
||||
}, 5000)
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
setMessageCount((count) => count + 1)
|
||||
try {
|
||||
const raw = typeof event.data === 'string' ? event.data : ''
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
if (parsed.type === 'pong' && typeof parsed.timestamp === 'number') {
|
||||
setLastLatency(Math.max(0, Date.now() - parsed.timestamp))
|
||||
return
|
||||
}
|
||||
const updates = parseMonitorMessage(raw)
|
||||
updates.forEach((update) => {
|
||||
const matchedNodeIds = matchRuntimeNodes(update.reference, nodesRef.current)
|
||||
runtimeUpdateRef.current(matchedNodeIds, update.status)
|
||||
const runtimeEvent: RuntimeEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
at: update.timestamp || Date.now(),
|
||||
nodeRef: update.reference,
|
||||
status: update.status,
|
||||
matchedNodeIds,
|
||||
}
|
||||
setEvents((current) => [runtimeEvent, ...current].slice(0, 80))
|
||||
})
|
||||
} catch {
|
||||
const invalidEvent: RuntimeEvent = { id: crypto.randomUUID(), at: Date.now(), nodeRef: 'Invalid JSON', status: 'SKIPPED', matchedNodeIds: [] }
|
||||
setEvents((current) => [invalidEvent, ...current].slice(0, 80))
|
||||
}
|
||||
}
|
||||
socket.onerror = () => setConnectionState('error')
|
||||
socket.onclose = () => {
|
||||
window.clearInterval(pingTimer)
|
||||
socketRef.current = null
|
||||
if (disposed) return
|
||||
setConnectionState('disconnected')
|
||||
if (autoReconnect) reconnectTimer = window.setTimeout(connect, 1800)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
return () => {
|
||||
disposed = true
|
||||
window.clearTimeout(reconnectTimer)
|
||||
window.clearInterval(pingTimer)
|
||||
socketRef.current?.close()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [shouldConnect, endpoint, rosTopic, autoReconnect])
|
||||
|
||||
const disconnect = () => {
|
||||
setShouldConnect(false)
|
||||
socketRef.current?.close()
|
||||
setConnectionState('disconnected')
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<section className="monitor-panel">
|
||||
<div className="monitor-header">
|
||||
<div className="monitor-title"><Activity size={17} /><strong>Real-time Monitor</strong><span className={`connection-pill ${connectionState}`}><i />{STATE_LABEL[connectionState]}</span></div>
|
||||
<div className="monitor-actions">
|
||||
<button className="icon-button compact" onClick={() => { setEvents([]); onClearStatuses() }} title="清空运行状态"><Trash2 size={15} /></button>
|
||||
<button className="icon-button compact" onClick={onClose} title="关闭监视器"><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="monitor-body">
|
||||
<div className="monitor-connect">
|
||||
<div className="field-group">
|
||||
<label htmlFor="monitor-endpoint">ROS WebSocket 地址</label>
|
||||
<div className="endpoint-row">
|
||||
<input id="monitor-endpoint" className="mono" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} disabled={shouldConnect} />
|
||||
{shouldConnect ? (
|
||||
<button className="secondary-button" onClick={disconnect}><CircleStop size={15} />断开</button>
|
||||
) : (
|
||||
<button className="primary-button" onClick={() => setShouldConnect(true)}><PlugZap size={15} />连接</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-group topic-field">
|
||||
<label htmlFor="monitor-topic">rosbridge Topic</label>
|
||||
<input id="monitor-topic" className="mono" value={rosTopic} onChange={(event) => setRosTopic(event.target.value)} disabled={shouldConnect} />
|
||||
</div>
|
||||
<label className="toggle-row compact-toggle">
|
||||
<span><span className="toggle-title">自动重连</span></span>
|
||||
<input type="checkbox" checked={autoReconnect} onChange={(event) => setAutoReconnect(event.target.checked)} />
|
||||
</label>
|
||||
<div className="monitor-metrics">
|
||||
<div><span>消息</span><strong>{messageCount}</strong></div>
|
||||
<div><span>延迟</span><strong>{lastLatency === null ? '--' : `${lastLatency} ms`}</strong></div>
|
||||
<div><span>匹配</span><strong>{events.filter((event) => event.matchedNodeIds.length).length}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-legend">
|
||||
{(['RUNNING', 'SUCCESS', 'FAILURE', 'IDLE', 'SKIPPED'] as RuntimeStatus[]).map((status) => <span key={status}><i className={`runtime-${status.toLowerCase()}`} />{status}</span>)}
|
||||
</div>
|
||||
<div className="event-stream">
|
||||
<div className="event-stream-heading"><span>事件流</span>{connectionState === 'connecting' && <RotateCw className="spin" size={14} />}</div>
|
||||
<div className="event-list">
|
||||
{!events.length && <div className="event-empty">等待节点状态</div>}
|
||||
{events.map((event) => (
|
||||
<div className="event-row" key={event.id}>
|
||||
<time>{new Date(event.at).toLocaleTimeString('zh-CN', { hour12: false })}</time>
|
||||
<i className={`runtime-${event.status.toLowerCase()}`} />
|
||||
<span className="event-node" title={event.nodeRef}>{event.nodeRef}</span>
|
||||
<span className={`event-status runtime-${event.status.toLowerCase()}`}>{event.status}</span>
|
||||
{!event.matchedNodeIds.length && <span className="unmatched">未匹配</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
70
src/components/NodePalette.tsx
Normal file
70
src/components/NodePalette.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CATEGORY_LABELS, NODE_DEFINITIONS } from '../lib/catalog'
|
||||
import type { NodeCategory, NodeType } from '../types'
|
||||
|
||||
interface NodePaletteProps {
|
||||
onAddNode: (type: NodeType) => void
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: NodeCategory[] = ['control', 'decorator', 'action', 'condition', 'subtree']
|
||||
|
||||
export function NodePalette({ onAddNode }: NodePaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const groups = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
return CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
items: NODE_DEFINITIONS.filter((item) => item.category === category)
|
||||
.filter((item) => !normalized || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalized)),
|
||||
})).filter((group) => group.items.length)
|
||||
}, [query])
|
||||
|
||||
const beginDrag = (event: React.DragEvent, type: NodeType) => {
|
||||
event.dataTransfer.setData('application/arborflow-node', type)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="left-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">LIBRARY</span>
|
||||
<h2>节点</h2>
|
||||
</div>
|
||||
<span className="count-label">{NODE_DEFINITIONS.length}</span>
|
||||
</div>
|
||||
<div className="palette-search">
|
||||
<Search size={15} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点" aria-label="搜索节点" />
|
||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={14} /></button>}
|
||||
</div>
|
||||
<div className="palette-scroll">
|
||||
{groups.map(({ category, items }) => (
|
||||
<section className="palette-group" key={category}>
|
||||
<h3>{CATEGORY_LABELS[category]}</h3>
|
||||
<div className="palette-items">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
className={`palette-item category-${category}`}
|
||||
draggable
|
||||
key={item.type}
|
||||
onDragStart={(event) => beginDrag(event, item.type)}
|
||||
onClick={() => onAddNode(item.type)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||
<span className="palette-item-name">{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groups.length && <div className="panel-empty">没有匹配节点</div>}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user