feat: launch ArborFlow behavior tree studio

This commit is contained in:
TuxMonkey
2026-07-21 20:09:00 +08:00
commit cb6ca3d24b
32 changed files with 9869 additions and 0 deletions

520
src/App.tsx Normal file
View File

@@ -0,0 +1,520 @@
import {
Background,
BackgroundVariant,
Controls,
MarkerType,
MiniMap,
ReactFlow,
ReactFlowProvider,
addEdge,
applyNodeChanges,
useReactFlow,
type Connection,
type NodeChange,
type OnConnect,
} from '@xyflow/react'
import {
Activity,
AlignVerticalSpaceAround,
Check,
ChevronDown,
Code2,
Copy,
FileCode2,
FileDown,
FilePlus2,
FolderOpen,
GitBranch,
MonitorDot,
Redo2,
Save,
TreePine,
Undo2,
Upload,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BehaviorNodeCard } from './components/BehaviorNodeCard'
import { Inspector } from './components/Inspector'
import { MonitorPanel } from './components/MonitorPanel'
import { NodePalette } from './components/NodePalette'
import { createNode, maxChildren } from './lib/catalog'
import { createDemoDocument, createEmptyDocument } from './lib/demo'
import { layoutDocument } from './lib/layout'
import { downloadText, parseProject, pickBrowserFile, serializeProject } from './lib/project'
import { validateDocument, wouldCreateCycle } from './lib/validation'
import { documentToXml, xmlToDocument } from './lib/xml'
import type { BehaviorNode, BehaviorTreeDocument, NodeType, RuntimeStatus } from './types'
const nodeTypes = { behavior: BehaviorNodeCard }
const PROJECT_FILTERS = [{ name: 'ArborFlow Project', extensions: ['arborflow', 'json'] }]
const XML_FILTERS = [{ name: 'BehaviorTree.CPP XML', extensions: ['xml'] }]
type ViewMode = 'canvas' | 'xml'
type DocumentUpdater = BehaviorTreeDocument | ((current: BehaviorTreeDocument) => BehaviorTreeDocument)
function Editor() {
const [treeDocument, setTreeDocument] = useState<BehaviorTreeDocument>(() => createDemoDocument())
const [projectPath, setProjectPath] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('canvas')
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [monitorOpen, setMonitorOpen] = useState(false)
const [runtimeStatuses, setRuntimeStatuses] = useState<Record<string, RuntimeStatus>>({})
const [toast, setToast] = useState<{ kind: 'success' | 'error'; message: string } | null>(null)
const [historyRevision, setHistoryRevision] = useState(0)
const [zoom, setZoom] = useState(100)
const savedContentRef = useRef(serializeProject(treeDocument))
const pastRef = useRef<BehaviorTreeDocument[]>([])
const futureRef = useRef<BehaviorTreeDocument[]>([])
const dragStartRef = useRef<BehaviorTreeDocument | null>(null)
const { fitView, screenToFlowPosition, setCenter } = useReactFlow()
const selectedNode = useMemo(
() => treeDocument.nodes.find((node) => node.id === selectedNodeId) || null,
[treeDocument.nodes, selectedNodeId],
)
const issues = useMemo(() => validateDocument(treeDocument), [treeDocument])
const xmlPreview = useMemo(() => {
try {
return { value: documentToXml(treeDocument), error: '' }
} catch (error) {
return { value: '', error: error instanceof Error ? error.message : '无法生成 XML' }
}
}, [treeDocument])
const showToast = useCallback((message: string, kind: 'success' | 'error' = 'success') => {
setToast({ message, kind })
window.setTimeout(() => setToast(null), 2600)
}, [])
const commit = useCallback((updater: DocumentUpdater) => {
setTreeDocument((current) => {
const next = typeof updater === 'function' ? updater(current) : updater
if (next === current) return current
pastRef.current = [...pastRef.current.slice(-79), current]
futureRef.current = []
setHistoryRevision((value) => value + 1)
setDirty(serializeProject(next) !== savedContentRef.current)
return next
})
}, [])
const replaceDocument = useCallback((next: BehaviorTreeDocument, path: string | null = null) => {
setTreeDocument(next)
setProjectPath(path)
setSelectedNodeId(null)
setSelectedEdgeId(null)
setRuntimeStatuses({})
setDirty(false)
savedContentRef.current = serializeProject(next)
pastRef.current = []
futureRef.current = []
setHistoryRevision((value) => value + 1)
window.setTimeout(() => fitView({ padding: 0.18, duration: 350 }), 60)
}, [fitView])
const undo = useCallback(() => {
const previous = pastRef.current.at(-1)
if (!previous) return
setTreeDocument((current) => {
futureRef.current = [current, ...futureRef.current]
return previous
})
pastRef.current = pastRef.current.slice(0, -1)
setSelectedNodeId(null)
setDirty(serializeProject(previous) !== savedContentRef.current)
setHistoryRevision((value) => value + 1)
}, [])
const redo = useCallback(() => {
const next = futureRef.current[0]
if (!next) return
setTreeDocument((current) => {
pastRef.current = [...pastRef.current, current]
return next
})
futureRef.current = futureRef.current.slice(1)
setSelectedNodeId(null)
setDirty(serializeProject(next) !== savedContentRef.current)
setHistoryRevision((value) => value + 1)
}, [])
const addNodeAt = useCallback((type: NodeType, position?: { x: number; y: number }) => {
const point = position || screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
const node = createNode(type, point)
commit((current) => ({ ...current, nodes: [...current.nodes, node] }))
setSelectedNodeId(node.id)
setViewMode('canvas')
}, [commit, screenToFlowPosition])
const deleteNode = useCallback((nodeId: string) => {
commit((current) => ({
...current,
nodes: current.nodes.filter((node) => node.id !== nodeId),
edges: current.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId),
}))
setSelectedNodeId(null)
}, [commit])
const deleteEdge = useCallback((edgeId: string) => {
commit((current) => ({ ...current, edges: current.edges.filter((edge) => edge.id !== edgeId) }))
setSelectedEdgeId(null)
}, [commit])
const updateNode = useCallback((nodeId: string, data: Partial<BehaviorNode['data']>) => {
commit((current) => ({
...current,
nodes: current.nodes.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node),
}))
}, [commit])
const onNodesChange = useCallback((changes: NodeChange<BehaviorNode>[]) => {
const positionChanges = changes.filter((change) => change.type === 'position')
if (!positionChanges.length) return
setTreeDocument((current) => ({ ...current, nodes: applyNodeChanges(positionChanges, current.nodes) }))
}, [])
const connectNodes: OnConnect = useCallback((connection: Connection) => {
if (!connection.source || !connection.target) return
const source = treeDocument.nodes.find((node) => node.id === connection.source)
if (!source) return
const outgoingCount = treeDocument.edges.filter((edge) => edge.source === source.id).length
const incomingCount = treeDocument.edges.filter((edge) => edge.target === connection.target).length
if (outgoingCount >= maxChildren(source.data.nodeType)) {
showToast(`${source.data.label} 不能再添加子节点`, 'error')
return
}
if (incomingCount > 0) {
showToast('一个节点只能有一个父节点', 'error')
return
}
if (wouldCreateCycle(treeDocument, connection.source, connection.target)) {
showToast('此连接会形成环路', 'error')
return
}
commit((current) => ({
...current,
edges: addEdge({ ...connection, id: `edge_${crypto.randomUUID().slice(0, 8)}`, type: 'smoothstep' }, current.edges),
}))
}, [commit, showToast, treeDocument])
const autoLayout = useCallback(() => {
if (!treeDocument.nodes.length) return
commit((current) => layoutDocument(current))
window.setTimeout(() => fitView({ padding: 0.2, duration: 420 }), 50)
}, [commit, fitView, treeDocument.nodes.length])
const newProject = useCallback(() => {
if (dirty && !window.confirm('当前工程有未保存更改,仍要新建吗?')) return
replaceDocument(createEmptyDocument())
}, [dirty, replaceDocument])
const openProject = useCallback(async () => {
try {
const result = window.arborflow
? await window.arborflow.openText({ filters: PROJECT_FILTERS })
: await pickBrowserFile('.arborflow,.json')
if (!result) return
replaceDocument(parseProject(result.content), window.arborflow ? result.path : null)
showToast('工程已打开')
} catch (error) {
showToast(error instanceof Error ? error.message : '打开工程失败', 'error')
}
}, [replaceDocument, showToast])
const saveProject = useCallback(async (prompt = false) => {
try {
const content = serializeProject(treeDocument)
if (!window.arborflow) {
downloadText(`${treeDocument.title || 'behavior-tree'}.arborflow`, content, 'application/json')
savedContentRef.current = content
setDirty(false)
showToast('工程已下载')
return
}
const result = await window.arborflow.saveText({
path: prompt ? undefined : projectPath || undefined,
prompt: prompt || !projectPath,
defaultPath: projectPath || `${treeDocument.title || 'behavior-tree'}.arborflow`,
content,
filters: PROJECT_FILTERS,
})
if (!result) return
setProjectPath(result.path)
savedContentRef.current = content
setDirty(false)
showToast('工程已保存')
} catch (error) {
showToast(error instanceof Error ? error.message : '保存失败', 'error')
}
}, [projectPath, showToast, treeDocument])
const importXml = useCallback(async () => {
try {
const result = window.arborflow
? await window.arborflow.openText({ filters: XML_FILTERS })
: await pickBrowserFile('.xml')
if (!result) return
replaceDocument(xmlToDocument(result.content))
savedContentRef.current = ''
setDirty(true)
showToast('XML 已导入')
} catch (error) {
showToast(error instanceof Error ? error.message : '导入 XML 失败', 'error')
}
}, [replaceDocument, showToast])
const exportXml = useCallback(async () => {
try {
const content = documentToXml(treeDocument)
const filename = `${treeDocument.mainTreeId || 'MainTree'}.xml`
if (!window.arborflow) {
downloadText(filename, content, 'application/xml')
} else {
const result = await window.arborflow.saveText({ prompt: true, defaultPath: filename, content, filters: XML_FILTERS })
if (!result) return
}
showToast('BehaviorTree.CPP XML 已导出')
} catch (error) {
showToast(error instanceof Error ? error.message : '导出 XML 失败', 'error')
}
}, [showToast, treeDocument])
const selectAndRevealNode = useCallback((nodeId: string) => {
const node = treeDocument.nodes.find((item) => item.id === nodeId)
if (!node) return
setSelectedNodeId(nodeId)
setViewMode('canvas')
setCenter(node.position.x + 105, node.position.y + 42, { zoom: 1.15, duration: 350 })
}, [setCenter, treeDocument.nodes])
const handleDrop = useCallback((event: React.DragEvent) => {
event.preventDefault()
const type = event.dataTransfer.getData('application/arborflow-node') as NodeType
if (!type) return
addNodeAt(type, screenToFlowPosition({ x: event.clientX, y: event.clientY }))
}, [addNodeAt, screenToFlowPosition])
const copyXml = useCallback(async () => {
if (!xmlPreview.value) return
await navigator.clipboard.writeText(xmlPreview.value)
showToast('XML 已复制')
}, [showToast, xmlPreview.value])
useEffect(() => {
const handler = (event: KeyboardEvent) => {
const target = event.target as HTMLElement
if (['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable) return
const command = event.ctrlKey || event.metaKey
if (command && event.key.toLowerCase() === 'z') {
event.preventDefault()
event.shiftKey ? redo() : undo()
} else if (command && event.key.toLowerCase() === 's') {
event.preventDefault()
void saveProject(event.shiftKey)
} else if (command && event.key.toLowerCase() === 'o') {
event.preventDefault()
void openProject()
} else if (event.key === 'Delete' || event.key === 'Backspace') {
if (selectedNodeId) deleteNode(selectedNodeId)
else if (selectedEdgeId) deleteEdge(selectedEdgeId)
} else if (event.key.toLowerCase() === 'f') {
fitView({ padding: 0.2, duration: 300 })
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [deleteEdge, deleteNode, fitView, openProject, redo, saveProject, selectedEdgeId, selectedNodeId, undo])
useEffect(() => {
if (!window.arborflow) return
return window.arborflow.onMenuCommand((command) => {
if (command === 'new') newProject()
if (command === 'open') void openProject()
if (command === 'save') void saveProject(false)
if (command === 'save-as') void saveProject(true)
if (command === 'import-xml') void importXml()
if (command === 'export-xml') void exportXml()
if (command === 'undo') undo()
if (command === 'redo') redo()
if (command === 'layout') autoLayout()
if (command === 'fit') fitView({ padding: 0.2, duration: 300 })
})
}, [autoLayout, exportXml, fitView, importXml, newProject, openProject, redo, saveProject, undo])
useEffect(() => {
document.title = `${dirty ? '● ' : ''}${treeDocument.title} — ArborFlow`
}, [dirty, treeDocument.title])
useEffect(() => {
const timer = window.setTimeout(() => fitView({ padding: monitorOpen ? 0.25 : 0.2, duration: 280 }), 80)
return () => window.clearTimeout(timer)
}, [fitView, monitorOpen])
useEffect(() => {
let timer: number | undefined
const handleResize = () => {
window.clearTimeout(timer)
timer = window.setTimeout(() => fitView({ padding: monitorOpen ? 0.25 : 0.2, duration: 220 }), 140)
}
window.addEventListener('resize', handleResize)
return () => {
window.clearTimeout(timer)
window.removeEventListener('resize', handleResize)
}
}, [fitView, monitorOpen])
const renderedNodes = useMemo(() => treeDocument.nodes.map((node) => ({
...node,
selected: node.id === selectedNodeId,
data: { ...node.data, runtimeStatus: runtimeStatuses[node.id] || 'IDLE' as RuntimeStatus },
})), [runtimeStatuses, selectedNodeId, treeDocument.nodes])
const renderedEdges = useMemo(() => treeDocument.edges.map((edge) => ({
...edge,
selected: edge.id === selectedEdgeId,
animated: runtimeStatuses[edge.source] === 'RUNNING',
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
})), [runtimeStatuses, selectedEdgeId, treeDocument.edges])
const applyRuntimeStatus = useCallback((nodeIds: string[], status: RuntimeStatus) => {
if (!nodeIds.length) return
setRuntimeStatuses((current) => {
const next = { ...current }
nodeIds.forEach((id) => { next[id] = status })
return next
})
}, [])
const errors = issues.filter((issue) => issue.severity === 'error').length
const canUndo = useMemo(() => pastRef.current.length > 0, [historyRevision])
const canRedo = useMemo(() => futureRef.current.length > 0, [historyRevision])
return (
<div className="app-shell">
<header className="app-header">
<div className="brand-block">
<div className="brand-mark"><TreePine size={20} /></div>
<div className="brand-copy"><strong>ArborFlow</strong><span>Behavior Tree Studio</span></div>
</div>
<div className="document-title"><span>{treeDocument.title}</span>{dirty && <i title="未保存" />}</div>
<div className="header-actions">
<button className="icon-button" onClick={newProject} title="新建工程 (Ctrl+N)"><FilePlus2 size={17} /></button>
<button className="icon-button" onClick={() => void openProject()} title="打开工程 (Ctrl+O)"><FolderOpen size={17} /></button>
<button className="icon-button" onClick={() => void saveProject(false)} title="保存工程 (Ctrl+S)"><Save size={17} /></button>
<span className="toolbar-separator" />
<button className="icon-button" onClick={undo} disabled={!canUndo} title="撤销 (Ctrl+Z)"><Undo2 size={17} /></button>
<button className="icon-button" onClick={redo} disabled={!canRedo} title="重做 (Ctrl+Shift+Z)"><Redo2 size={17} /></button>
<span className="toolbar-separator" />
<button className="icon-button" onClick={importXml} title="导入 XML"><Upload size={17} /></button>
<button className="icon-button" onClick={exportXml} title="导出 XML"><FileDown size={17} /></button>
<button className={`monitor-button ${monitorOpen ? 'active' : ''}`} onClick={() => setMonitorOpen((value) => !value)}><MonitorDot size={16} />Monitor</button>
</div>
</header>
<div className="workspace-toolbar">
<div className="tree-tab"><GitBranch size={15} /><span>{treeDocument.mainTreeId}</span><ChevronDown size={13} /></div>
<div className="view-switch" aria-label="视图切换">
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />画布</button>
<button className={viewMode === 'xml' ? 'active' : ''} onClick={() => setViewMode('xml')}><Code2 size={14} />XML</button>
</div>
<div className="workspace-toolbar-actions">
<button className="tool-button" onClick={autoLayout} title="自动布局 (Ctrl+L)"><AlignVerticalSpaceAround size={15} />自动布局</button>
</div>
</div>
<main className="workspace-grid">
<NodePalette onAddNode={addNodeAt} />
<section className="center-stage">
<div className="canvas-region" onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={handleDrop}>
{viewMode === 'canvas' ? (
<ReactFlow
nodes={renderedNodes}
edges={renderedEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onConnect={connectNodes}
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null) }}
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null) }}
onNodeDragStart={() => { dragStartRef.current = treeDocument }}
onNodeDragStop={() => {
if (dragStartRef.current) {
pastRef.current = [...pastRef.current.slice(-79), dragStartRef.current]
futureRef.current = []
dragStartRef.current = null
setDirty(true)
setHistoryRevision((value) => value + 1)
}
}}
onMoveEnd={(_event, viewport) => setZoom(Math.round(viewport.zoom * 100))}
deleteKeyCode={null}
snapToGrid
snapGrid={[12, 12]}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={2.2}
defaultEdgeOptions={{ type: 'smoothstep' }}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="#343a40" />
<Controls showInteractive={false} position="bottom-left" />
<MiniMap
position="bottom-right"
pannable
zoomable
nodeColor={(node) => {
const category = (node.data as BehaviorNode['data']).category
return { control: '#4f8cff', decorator: '#c084fc', action: '#22c98b', condition: '#f2b84b', subtree: '#ec6f66' }[category]
}}
maskColor="rgba(10, 12, 15, 0.72)"
/>
</ReactFlow>
) : (
<div className="xml-view">
<div className="xml-header">
<div><FileCode2 size={16} /><span>{treeDocument.mainTreeId}.xml</span></div>
<button className="icon-button compact" onClick={copyXml} disabled={!xmlPreview.value} title="复制 XML"><Copy size={15} /></button>
</div>
{xmlPreview.error ? (
<div className="xml-error"><Activity size={20} /><strong>无法生成 XML</strong><span>{xmlPreview.error}</span></div>
) : (
<pre><code>{xmlPreview.value}</code></pre>
)}
</div>
)}
</div>
<MonitorPanel
open={monitorOpen}
nodes={treeDocument.nodes}
onClose={() => setMonitorOpen(false)}
onRuntimeUpdate={applyRuntimeStatus}
onClearStatuses={() => setRuntimeStatuses({})}
/>
</section>
<Inspector
document={treeDocument}
selectedNode={selectedNode}
issues={issues}
onUpdateDocument={(patch) => commit((current) => ({ ...current, ...patch }))}
onUpdateNode={updateNode}
onDeleteNode={deleteNode}
onSelectIssue={selectAndRevealNode}
/>
</main>
<footer className="status-bar">
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} 个结构错误` : <><Check size={12} />结构有效</>}</span></div>
<div><span>{treeDocument.nodes.length} 节点</span><span>{treeDocument.edges.length} 连接</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}</span></div>
</footer>
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}
</div>
)
}
export default function App() {
return <ReactFlowProvider><Editor /></ReactFlowProvider>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

80
src/lib/catalog.ts Normal file
View File

@@ -0,0 +1,80 @@
import {
GitBranch,
ListTree,
Repeat2,
RotateCcw,
ShieldCheck,
ShieldX,
Split,
SquareFunction,
TreePine,
Zap,
type LucideIcon,
} from 'lucide-react'
import type { BehaviorNode, NodeCategory, NodeType } from '../types'
export interface NodeDefinition {
type: NodeType
label: string
description: string
category: NodeCategory
icon: LucideIcon
defaultPorts?: Record<string, string>
}
export const CATEGORY_LABELS: Record<NodeCategory, string> = {
control: '控制',
decorator: '装饰',
action: '动作',
condition: '条件',
subtree: '子树',
}
export const NODE_DEFINITIONS: NodeDefinition[] = [
{ type: 'Sequence', label: 'Sequence', description: '依次执行,遇失败停止', category: 'control', icon: ListTree },
{ type: 'Fallback', label: 'Fallback', description: '依次尝试,遇成功停止', category: 'control', icon: GitBranch },
{ type: 'Parallel', label: 'Parallel', description: '并行执行多个子节点', category: 'control', icon: Split, defaultPorts: { success_count: '1', failure_count: '1' } },
{ type: 'ReactiveSequence', label: 'Reactive Sequence', description: '每次 tick 从首节点重评估', category: 'control', icon: RotateCcw },
{ type: 'ReactiveFallback', label: 'Reactive Fallback', description: '响应式回退控制', category: 'control', icon: RotateCcw },
{ type: 'Inverter', label: 'Inverter', description: '反转成功与失败结果', category: 'decorator', icon: Repeat2 },
{ type: 'RetryUntilSuccessful', label: 'Retry', description: '失败后重试指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_attempts: '3' } },
{ type: 'Repeat', label: 'Repeat', description: '重复执行指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_cycles: '3' } },
{ type: 'ForceSuccess', label: 'Force Success', description: '强制返回成功', category: 'decorator', icon: ShieldCheck },
{ type: 'ForceFailure', label: 'Force Failure', description: '强制返回失败', category: 'decorator', icon: ShieldX },
{ type: 'Action', label: 'Action', description: '调用 ROS 动作或业务逻辑', category: 'action', icon: Zap },
{ type: 'Condition', label: 'Condition', description: '检查黑板或环境条件', category: 'condition', icon: SquareFunction },
{ type: 'SubTree', label: 'SubTree', description: '调用另一棵行为树', category: 'subtree', icon: TreePine },
{ type: 'AlwaysSuccess', label: 'Always Success', description: '始终返回成功', category: 'action', icon: ShieldCheck },
{ type: 'AlwaysFailure', label: 'Always Failure', description: '始终返回失败', category: 'action', icon: ShieldX },
]
export const NODE_DEFINITION_MAP = new Map(NODE_DEFINITIONS.map((definition) => [definition.type, definition]))
export function createNode(type: NodeType, position = { x: 80, y: 80 }): BehaviorNode {
const definition = NODE_DEFINITION_MAP.get(type)!
const id = `node_${crypto.randomUUID().replaceAll('-', '').slice(0, 10)}`
const customName = type === 'Action' ? 'MyAction' : type === 'Condition' ? 'MyCondition' : type === 'SubTree' ? 'SubTreeID' : type
return {
id,
type: 'behavior',
position,
data: {
label: customName,
nodeType: type,
category: definition.category,
registrationName: customName,
ports: { ...(definition.defaultPorts || {}) },
notes: '',
breakpoint: false,
},
}
}
export function canHaveChildren(type: NodeType) {
return !['Action', 'Condition', 'SubTree', 'AlwaysSuccess', 'AlwaysFailure'].includes(type)
}
export function maxChildren(type: NodeType) {
if (['Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure'].includes(type)) return 1
return canHaveChildren(type) ? Number.POSITIVE_INFINITY : 0
}

54
src/lib/demo.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { BehaviorTreeDocument } from '../types'
export function createDemoDocument(): BehaviorTreeDocument {
return {
format: 'arborflow/project',
version: 1,
title: 'Navigation Mission',
mainTreeId: 'MainTree',
nodes: [
{
id: 'root_sequence', type: 'behavior', position: { x: 475, y: 60 },
data: { label: 'Mission Sequence', nodeType: 'Sequence', category: 'control', registrationName: 'Sequence', ports: {}, notes: '主任务流程', breakpoint: false },
},
{
id: 'battery_check', type: 'behavior', position: { x: 170, y: 240 },
data: { label: 'Battery OK?', nodeType: 'Condition', category: 'condition', registrationName: 'BatteryOK', ports: { min_level: '{battery_threshold}' }, notes: '', breakpoint: false },
},
{
id: 'navigation_fallback', type: 'behavior', position: { x: 475, y: 240 },
data: { label: 'Navigate Safely', nodeType: 'Fallback', category: 'control', registrationName: 'Fallback', ports: {}, notes: '', breakpoint: false },
},
{
id: 'report_action', type: 'behavior', position: { x: 785, y: 240 },
data: { label: 'Report Result', nodeType: 'Action', category: 'action', registrationName: 'ReportResult', ports: { topic: '/mission/result' }, notes: '', breakpoint: false },
},
{
id: 'at_goal', type: 'behavior', position: { x: 340, y: 430 },
data: { label: 'Already At Goal?', nodeType: 'Condition', category: 'condition', registrationName: 'AtGoal', ports: { target: '{goal}' }, notes: '', breakpoint: false },
},
{
id: 'navigate_action', type: 'behavior', position: { x: 610, y: 430 },
data: { label: 'Navigate To Pose', nodeType: 'Action', category: 'action', registrationName: 'NavigateToPose', ports: { goal: '{goal}', server_timeout: '5000' }, notes: '调用 Nav2 action server', breakpoint: true },
},
],
edges: [
{ id: 'e-root-battery', source: 'root_sequence', target: 'battery_check', type: 'smoothstep' },
{ id: 'e-root-nav', source: 'root_sequence', target: 'navigation_fallback', type: 'smoothstep' },
{ id: 'e-root-report', source: 'root_sequence', target: 'report_action', type: 'smoothstep' },
{ id: 'e-nav-goal', source: 'navigation_fallback', target: 'at_goal', type: 'smoothstep' },
{ id: 'e-nav-action', source: 'navigation_fallback', target: 'navigate_action', type: 'smoothstep' },
],
}
}
export function createEmptyDocument(): BehaviorTreeDocument {
return {
format: 'arborflow/project',
version: 1,
title: 'Untitled Tree',
mainTreeId: 'MainTree',
nodes: [],
edges: [],
}
}

26
src/lib/layout.ts Normal file
View File

@@ -0,0 +1,26 @@
import dagre from '@dagrejs/dagre'
import type { BehaviorTreeDocument } from '../types'
const NODE_WIDTH = 210
const NODE_HEIGHT = 84
export function layoutDocument(document: BehaviorTreeDocument): BehaviorTreeDocument {
const graph = new dagre.graphlib.Graph()
graph.setDefaultEdgeLabel(() => ({}))
graph.setGraph({ rankdir: 'TB', nodesep: 46, ranksep: 88, marginx: 40, marginy: 40 })
document.nodes.forEach((node) => graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }))
document.edges.forEach((edge) => graph.setEdge(edge.source, edge.target))
dagre.layout(graph)
return {
...document,
nodes: document.nodes.map((node) => {
const position = graph.node(node.id)
return {
...node,
position: { x: position.x - NODE_WIDTH / 2, y: position.y - NODE_HEIGHT / 2 },
}
}),
}
}

41
src/lib/project.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { BehaviorTreeDocument } from '../types'
export function serializeProject(document: BehaviorTreeDocument) {
return JSON.stringify(document, null, 2) + '\n'
}
export function parseProject(content: string): BehaviorTreeDocument {
const value: unknown = JSON.parse(content)
if (!value || typeof value !== 'object') throw new Error('工程文件内容无效')
const candidate = value as Partial<BehaviorTreeDocument>
if (candidate.format !== 'arborflow/project' || candidate.version !== 1) {
throw new Error('不是受支持的 ArborFlow 工程文件')
}
if (!Array.isArray(candidate.nodes) || !Array.isArray(candidate.edges)) {
throw new Error('工程文件缺少节点或连接数据')
}
return candidate as BehaviorTreeDocument
}
export function downloadText(filename: string, content: string, type = 'text/plain') {
const blob = new Blob([content], { type })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
export function pickBrowserFile(accept: string) {
return new Promise<{ path: string; content: string } | null>((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.onchange = async () => {
const file = input.files?.[0]
resolve(file ? { path: file.name, content: await file.text() } : null)
}
input.click()
})
}

70
src/lib/runtime.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { BehaviorNode, RuntimeStatus } from '../types'
const STATUS_BY_NUMBER: Record<number, RuntimeStatus> = {
0: 'IDLE',
1: 'RUNNING',
2: 'SUCCESS',
3: 'FAILURE',
4: 'SKIPPED',
}
export function normalizeStatus(value: unknown): RuntimeStatus | null {
if (typeof value === 'number') return STATUS_BY_NUMBER[value] || null
if (typeof value !== 'string') return null
const normalized = value.toUpperCase().trim() as RuntimeStatus
return ['IDLE', 'RUNNING', 'SUCCESS', 'FAILURE', 'SKIPPED'].includes(normalized) ? normalized : null
}
export function matchRuntimeNodes(reference: string, nodes: BehaviorNode[]) {
const cleanRef = reference.trim()
const pathTail = cleanRef.split('/').filter(Boolean).at(-1) || cleanRef
const exactId = nodes.filter((node) => node.id === cleanRef)
if (exactId.length) return exactId.map((node) => node.id)
const exactName = nodes.filter((node) => node.data.label === cleanRef || node.data.registrationName === cleanRef)
if (exactName.length) return exactName.map((node) => node.id)
return nodes
.filter((node) => node.data.label === pathTail || node.data.registrationName === pathTail)
.map((node) => node.id)
}
export interface ParsedMonitorUpdate {
reference: string
status: RuntimeStatus
timestamp?: number
}
export function parseMonitorMessage(raw: string): ParsedMonitorUpdate[] {
const message = JSON.parse(raw) as Record<string, unknown>
if (message.op === 'publish' && message.msg && typeof message.msg === 'object') {
const rosMessage = message.msg as Record<string, unknown>
if (typeof rosMessage.data === 'string') {
try {
return parseMonitorMessage(rosMessage.data)
} catch {
return []
}
}
return parseEvent(rosMessage)
}
if (message.type === 'snapshot' && message.nodes && typeof message.nodes === 'object') {
return Object.entries(message.nodes as Record<string, unknown>).flatMap(([reference, value]) => {
const statusValue = value && typeof value === 'object' ? (value as Record<string, unknown>).status : value
const status = normalizeStatus(statusValue)
return status ? [{ reference, status, timestamp: Number(message.timestamp) || undefined }] : []
})
}
if (Array.isArray(message.events)) {
return message.events.flatMap((event) => {
if (!event || typeof event !== 'object') return []
return parseEvent(event as Record<string, unknown>)
})
}
return parseEvent(message)
}
function parseEvent(message: Record<string, unknown>): ParsedMonitorUpdate[] {
const reference = message.nodeId ?? message.uid ?? message.node ?? message.name ?? message.path
const status = normalizeStatus(message.status)
if ((typeof reference !== 'string' && typeof reference !== 'number') || !status) return []
return [{ reference: String(reference), status, timestamp: Number(message.timestamp) || undefined }]
}

50
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,50 @@
import { maxChildren } from './catalog'
import type { BehaviorTreeDocument, ValidationIssue } from '../types'
export function validateDocument(document: BehaviorTreeDocument): ValidationIssue[] {
const issues: ValidationIssue[] = []
const incoming = new Map(document.nodes.map((node) => [node.id, 0]))
const outgoing = new Map(document.nodes.map((node) => [node.id, 0]))
document.edges.forEach((edge) => {
incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1)
outgoing.set(edge.source, (outgoing.get(edge.source) || 0) + 1)
})
const roots = document.nodes.filter((node) => (incoming.get(node.id) || 0) === 0)
if (document.nodes.length === 0) issues.push({ severity: 'warning', message: '画布为空,请添加根节点' })
if (roots.length > 1) issues.push({ severity: 'error', message: `检测到 ${roots.length} 个根节点,导出 XML 前必须连接为一棵树` })
if (roots.length === 0 && document.nodes.length > 0) issues.push({ severity: 'error', message: '没有根节点,树中可能存在环路' })
document.nodes.forEach((node) => {
const childCount = outgoing.get(node.id) || 0
const limit = maxChildren(node.data.nodeType)
if (childCount > limit) {
issues.push({ severity: 'error', nodeId: node.id, message: `${node.data.label} 最多允许 ${limit} 个子节点` })
}
if (limit > 0 && childCount === 0) {
issues.push({ severity: 'warning', nodeId: node.id, message: `${node.data.label} 尚未连接子节点` })
}
if (!node.data.registrationName.trim()) {
issues.push({ severity: 'error', nodeId: node.id, message: `${node.data.label} 的注册名称不能为空` })
}
})
return issues
}
export function wouldCreateCycle(document: BehaviorTreeDocument, source: string, target: string) {
if (source === target) return true
const children = new Map<string, string[]>()
document.edges.forEach((edge) => children.set(edge.source, [...(children.get(edge.source) || []), edge.target]))
const stack = [target]
const visited = new Set<string>()
while (stack.length) {
const current = stack.pop()!
if (current === source) return true
if (visited.has(current)) continue
visited.add(current)
stack.push(...(children.get(current) || []))
}
return false
}

114
src/lib/xml.ts Normal file
View File

@@ -0,0 +1,114 @@
import { createNode, NODE_DEFINITION_MAP } from './catalog'
import { layoutDocument } from './layout'
import type { BehaviorEdge, BehaviorNode, BehaviorTreeDocument, NodeType } from '../types'
const CONTROL_AND_DECORATOR_TYPES = new Set<NodeType>([
'Sequence', 'Fallback', 'Parallel', 'ReactiveSequence', 'ReactiveFallback',
'Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure',
])
function escapeXml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
function nodeTag(node: BehaviorNode) {
if (node.data.nodeType === 'SubTree') return 'SubTree'
if (node.data.nodeType === 'Action') return 'Action'
if (node.data.nodeType === 'Condition') return 'Condition'
return node.data.nodeType
}
export function documentToXml(document: BehaviorTreeDocument) {
if (document.nodes.length === 0) throw new Error('画布为空,无法导出 XML')
const incoming = new Map(document.nodes.map((node) => [node.id, 0]))
document.edges.forEach((edge) => incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1))
const roots = document.nodes.filter((node) => (incoming.get(node.id) || 0) === 0)
if (roots.length !== 1) throw new Error(`XML 只能导出一棵完整树,当前检测到 ${roots.length} 个根节点`)
const byId = new Map(document.nodes.map((node) => [node.id, node]))
const children = new Map<string, BehaviorNode[]>()
document.edges.forEach((edge) => {
const child = byId.get(edge.target)
if (child) children.set(edge.source, [...(children.get(edge.source) || []), child])
})
children.forEach((items) => items.sort((a, b) => a.position.x - b.position.x))
const visited = new Set<string>()
const renderNode = (node: BehaviorNode, depth: number): string => {
if (visited.has(node.id)) throw new Error('树中存在重复引用或环路,无法导出 XML')
visited.add(node.id)
const indent = ' '.repeat(depth)
const tag = nodeTag(node)
const attributes: Array<[string, string]> = []
if (['Action', 'Condition', 'SubTree'].includes(tag)) attributes.push(['ID', node.data.registrationName])
if (node.data.label !== node.data.registrationName && node.data.label !== node.data.nodeType) attributes.push(['name', node.data.label])
Object.entries(node.data.ports).forEach(([key, value]) => {
if (key.trim()) attributes.push([key.trim(), value])
})
const attrs = attributes.map(([key, value]) => ` ${key}="${escapeXml(value)}"`).join('')
const childNodes = children.get(node.id) || []
if (childNodes.length === 0) return `${indent}<${tag}${attrs}/>`
const content = childNodes.map((child) => renderNode(child, depth + 1)).join('\n')
return `${indent}<${tag}${attrs}>\n${content}\n${indent}</${tag}>`
}
const treeXml = renderNode(roots[0], 2)
return `<?xml version="1.0" encoding="UTF-8"?>\n<root BTCPP_format="4" main_tree_to_execute="${escapeXml(document.mainTreeId)}">\n <BehaviorTree ID="${escapeXml(document.mainTreeId)}">\n${treeXml}\n </BehaviorTree>\n</root>\n`
}
function elementType(element: Element): NodeType {
const tag = element.tagName
if (NODE_DEFINITION_MAP.has(tag as NodeType)) return tag as NodeType
if (tag === 'SubTreePlus') return 'SubTree'
const children = Array.from(element.children)
return children.length ? 'Sequence' : 'Action'
}
export function xmlToDocument(xml: string): BehaviorTreeDocument {
const parsed = new DOMParser().parseFromString(xml, 'application/xml')
const parserError = parsed.querySelector('parsererror')
if (parserError) throw new Error(`XML 解析失败:${parserError.textContent?.split('\n')[0] || '格式错误'}`)
const behaviorTree = parsed.querySelector('BehaviorTree')
if (!behaviorTree) throw new Error('未找到 <BehaviorTree> 元素')
const treeRoot = Array.from(behaviorTree.children).find((child) => child.nodeType === Node.ELEMENT_NODE)
if (!treeRoot) throw new Error('BehaviorTree 中没有根节点')
const nodes: BehaviorNode[] = []
const edges: BehaviorEdge[] = []
let edgeIndex = 0
const visit = (element: Element, parentId?: string) => {
const type = elementType(element)
const node = createNode(type)
const idAttr = element.getAttribute('ID')
const registrationName = idAttr || (CONTROL_AND_DECORATOR_TYPES.has(type) ? type : element.tagName)
const label = element.getAttribute('name') || registrationName
const ports: Record<string, string> = {}
Array.from(element.attributes).forEach((attribute) => {
if (!['ID', 'name', '_autoremap'].includes(attribute.name)) ports[attribute.name] = attribute.value
})
node.data = { ...node.data, label, registrationName, ports }
nodes.push(node)
if (parentId) {
edges.push({ id: `edge_import_${edgeIndex++}`, source: parentId, target: node.id, type: 'smoothstep' })
}
Array.from(element.children).forEach((child) => visit(child, node.id))
}
visit(treeRoot)
const root = parsed.documentElement
const mainTreeId = root.getAttribute('main_tree_to_execute') || behaviorTree.getAttribute('ID') || 'MainTree'
return layoutDocument({
format: 'arborflow/project',
version: 1,
title: behaviorTree.getAttribute('ID') || 'Imported Tree',
mainTreeId,
nodes,
edges,
})
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@xyflow/react/dist/style.css'
import './styles.css'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

340
src/styles.css Normal file
View File

@@ -0,0 +1,340 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e7e9ec;
background: #101215;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #101215;
--surface-1: #15181c;
--surface-2: #1b1f24;
--surface-3: #23282e;
--border: #2d333a;
--border-strong: #3a424a;
--text: #e7e9ec;
--text-muted: #929aa3;
--text-dim: #68717b;
--accent: #29c58b;
--accent-strong: #52d6a6;
--blue: #5a92f7;
--amber: #eeb84a;
--red: #ee6961;
--purple: #bb7ee4;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { background: var(--bg); }
button, input, textarea { font: inherit; }
button { color: inherit; }
.app-shell {
display: grid;
grid-template-rows: 56px 42px minmax(0, 1fr) 24px;
width: 100%;
height: 100%;
min-width: 980px;
background: var(--bg);
}
.app-header {
display: grid;
grid-template-columns: 230px minmax(140px, 1fr) auto;
align-items: center;
border-bottom: 1px solid var(--border);
background: #15181b;
padding: 0 12px;
user-select: none;
}
.brand-block, .brand-copy, .header-actions, .monitor-title, .monitor-actions,
.tree-tab, .view-switch, .workspace-toolbar-actions, .section-title,
.endpoint-row, .runtime-legend, .event-stream-heading, .xml-header > div {
display: flex;
align-items: center;
}
.brand-block { gap: 9px; }
.brand-mark {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid #357f64;
border-radius: 7px;
color: var(--accent-strong);
background: #17251f;
}
.brand-copy { align-items: flex-start; flex-direction: column; line-height: 1.1; }
.brand-copy strong { font-size: 14px; font-weight: 680; }
.brand-copy span { margin-top: 3px; color: var(--text-dim); font-size: 9px; text-transform: uppercase; }
.document-title { min-width: 0; overflow: hidden; text-align: center; color: #c5c9ce; font-size: 12px; }
.document-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.document-title i { display: inline-block; width: 6px; height: 6px; margin-left: 7px; border-radius: 50%; background: var(--amber); vertical-align: 1px; }
.header-actions { justify-content: flex-end; gap: 4px; }
.toolbar-separator { width: 1px; height: 22px; margin: 0 5px; background: var(--border); }
button { border: 0; cursor: pointer; }
button:disabled { cursor: default; opacity: .38; }
.icon-button, .bare-icon {
display: inline-grid;
place-items: center;
padding: 0;
border: 1px solid transparent;
background: transparent;
}
.icon-button { width: 32px; height: 32px; border-radius: 6px; color: #abb2ba; }
.icon-button:hover:not(:disabled) { border-color: var(--border); color: var(--text); background: var(--surface-3); }
.icon-button.compact { width: 27px; height: 27px; }
.bare-icon { width: 24px; height: 24px; color: var(--text-dim); }
.bare-icon:hover { color: var(--text); }
.bare-icon.danger-hover:hover { color: var(--red); }
.monitor-button, .tool-button, .primary-button, .secondary-button, .danger-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 31px;
border-radius: 6px;
padding: 0 10px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.monitor-button { margin-left: 5px; border: 1px solid #345b4c; color: #9fdac3; background: #17241f; }
.monitor-button:hover, .monitor-button.active { border-color: #4b9c79; color: #d2f4e6; background: #1c342a; }
.workspace-toolbar {
display: grid;
grid-template-columns: 230px 1fr auto;
align-items: center;
border-bottom: 1px solid var(--border);
background: #121518;
padding-right: 12px;
}
.tree-tab { align-self: stretch; gap: 7px; padding: 0 13px; border-right: 1px solid var(--border); color: #bac0c6; font-size: 11px; }
.tree-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-tab svg:first-child { color: var(--accent); }
.tree-tab svg:last-child { margin-left: auto; color: var(--text-dim); }
.view-switch { justify-self: center; height: 29px; padding: 2px; border: 1px solid var(--border); border-radius: 6px; background: #0f1114; }
.view-switch button { display: inline-flex; align-items: center; gap: 5px; height: 23px; padding: 0 10px; border-radius: 4px; color: var(--text-dim); background: transparent; font-size: 11px; }
.view-switch button.active { color: #e2e5e8; background: var(--surface-3); box-shadow: 0 0 0 1px #303740; }
.workspace-toolbar-actions { justify-content: flex-end; }
.tool-button { min-height: 28px; border: 1px solid var(--border); color: #aab1b8; background: var(--surface-2); }
.tool-button:hover { border-color: var(--border-strong); color: var(--text); background: var(--surface-3); }
.workspace-grid { display: grid; grid-template-columns: 230px minmax(0, 1fr) 292px; min-height: 0; }
.left-panel, .right-panel { min-height: 0; background: var(--surface-1); }
.left-panel { border-right: 1px solid var(--border); }
.right-panel { border-left: 1px solid var(--border); }
.panel-heading { display: flex; align-items: center; justify-content: space-between; height: 63px; padding: 0 14px; border-bottom: 1px solid var(--border); }
.panel-heading h2 { margin: 3px 0 0; font-size: 13px; font-weight: 650; letter-spacing: 0; }
.eyebrow { color: var(--text-dim); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; }
.count-label { min-width: 20px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 8px; color: var(--text-dim); background: var(--surface-2); font-size: 9px; text-align: center; }
.palette-search { display: flex; align-items: center; gap: 7px; height: 48px; margin: 0 10px; border-bottom: 1px solid var(--border); color: var(--text-dim); }
.palette-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 11px; }
.palette-search input::placeholder, input::placeholder, textarea::placeholder { color: #5f6770; }
.palette-scroll, .inspector-scroll { height: calc(100% - 63px); overflow: auto; scrollbar-color: #353c44 transparent; scrollbar-width: thin; }
.palette-scroll { height: calc(100% - 111px); padding: 8px 10px 20px; }
.palette-group h3 { margin: 11px 4px 6px; color: #727b84; font-size: 9px; font-weight: 650; text-transform: uppercase; }
.palette-items { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.palette-item {
display: flex;
align-items: center;
min-width: 0;
height: 35px;
padding: 0 7px;
border: 1px solid var(--border);
border-radius: 5px;
color: #bfc4c9;
background: #1a1e22;
text-align: left;
}
.palette-item:hover { border-color: var(--category-color); color: #f0f2f3; background: #20252a; }
.palette-item-icon { display: grid; place-items: center; flex: 0 0 23px; color: var(--category-color); }
.palette-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 9px; white-space: nowrap; }
.category-control { --category-color: var(--blue); }
.category-decorator { --category-color: var(--purple); }
.category-action { --category-color: var(--accent); }
.category-condition { --category-color: var(--amber); }
.category-subtree { --category-color: #ed756d; }
.panel-empty { padding: 35px 10px; color: var(--text-dim); font-size: 11px; text-align: center; }
.center-stage { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: #101215; }
.canvas-region { position: relative; flex: 1 1 auto; min-height: 180px; }
.react-flow { background: #101215; }
.react-flow__pane { cursor: default; }
.react-flow__edge-path { stroke: #59636d; stroke-width: 1.5; }
.react-flow__edge.selected .react-flow__edge-path { stroke: #71a2fa; stroke-width: 2; }
.react-flow__edge.animated path { stroke: var(--accent); }
.react-flow__arrowhead polyline { stroke: #69737d; fill: #69737d; }
.react-flow__controls { overflow: hidden; border: 1px solid var(--border) !important; border-radius: 6px !important; box-shadow: 0 8px 20px rgba(0, 0, 0, .28) !important; }
.react-flow__controls-button { width: 27px !important; height: 27px !important; border-bottom-color: var(--border) !important; color: #b5bbc2 !important; fill: #b5bbc2 !important; background: #1d2227 !important; }
.react-flow__controls-button:hover { background: #282e34 !important; }
.react-flow__minimap { width: 142px !important; height: 90px !important; border: 1px solid var(--border); border-radius: 5px; background: #161a1e !important; box-shadow: 0 8px 20px rgba(0, 0, 0, .25); }
.behavior-node {
position: relative;
display: flex;
align-items: center;
width: 210px;
height: 84px;
border: 1px solid #384049;
border-radius: 7px;
color: var(--text);
background: #1b2025;
box-shadow: 0 7px 16px rgba(0, 0, 0, .25);
transition: border-color .14s, box-shadow .14s;
}
.behavior-node:hover { border-color: #525d68; }
.behavior-node.is-selected { border-color: var(--category-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--category-color) 26%, transparent), 0 10px 24px rgba(0, 0, 0, .32); }
.node-accent { align-self: stretch; flex: 0 0 4px; border-radius: 6px 0 0 6px; background: var(--category-color); }
.node-icon { display: grid; place-items: center; flex: 0 0 33px; height: 33px; margin-left: 10px; border: 1px solid color-mix(in srgb, var(--category-color) 35%, #30363d); border-radius: 6px; color: var(--category-color); background: color-mix(in srgb, var(--category-color) 9%, #171b1f); }
.node-copy { min-width: 0; margin: 0 11px 0 9px; }
.node-title { max-width: 135px; overflow: hidden; color: #eef0f1; font-size: 12px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.node-type { max-width: 132px; margin-top: 6px; overflow: hidden; color: #747e87; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.node-handle { width: 9px !important; height: 9px !important; border: 2px solid #a7b0b9 !important; background: #20252a !important; }
.node-handle:hover { border-color: var(--category-color) !important; background: var(--category-color) !important; }
.node-handle-target { top: -5px !important; }
.node-handle-source { bottom: -5px !important; }
.breakpoint-icon { position: absolute; top: 7px; right: 7px; color: var(--red); }
.runtime-badge { position: absolute; right: 7px; bottom: 6px; padding: 2px 5px; border: 1px solid currentColor; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 7px; background: #161a1e; }
.behavior-node.runtime-running { border-color: #5a9cf5; box-shadow: 0 0 0 2px rgba(90, 156, 245, .25), 0 0 20px rgba(90, 156, 245, .18); }
.behavior-node.runtime-success { border-color: #39c98f; }
.behavior-node.runtime-failure { border-color: #ee6961; }
.behavior-node.runtime-skipped { opacity: .58; }
.runtime-badge.runtime-running, .event-status.runtime-running { color: #68a8ff !important; background: #15263a !important; }
.runtime-badge.runtime-success, .event-status.runtime-success { color: #49d49d !important; background: #153026 !important; }
.runtime-badge.runtime-failure, .event-status.runtime-failure { color: #f37770 !important; background: #321c1b !important; }
.runtime-badge.runtime-idle, .event-status.runtime-idle { color: #929aa3 !important; background: #20252a !important; }
.runtime-badge.runtime-skipped, .event-status.runtime-skipped { color: #bd82dd !important; background: #2b1d33 !important; }
.runtime-legend i.runtime-running, .event-row > i.runtime-running { background-color: #68a8ff; }
.runtime-legend i.runtime-success, .event-row > i.runtime-success { background-color: #49d49d; }
.runtime-legend i.runtime-failure, .event-row > i.runtime-failure { background-color: #f37770; }
.runtime-legend i.runtime-idle, .event-row > i.runtime-idle { background-color: #808891; }
.runtime-legend i.runtime-skipped, .event-row > i.runtime-skipped { background-color: #bd82dd; }
.inspector-scroll { padding: 15px 14px 30px; }
.category-chip { max-width: 92px; overflow: hidden; padding: 3px 7px; border: 1px solid color-mix(in srgb, var(--category-color) 45%, #30363d); border-radius: 8px; color: var(--category-color); background: color-mix(in srgb, var(--category-color) 8%, #171a1e); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.field-group { min-width: 0; margin-bottom: 13px; }
.field-group label, .monitor-connect label { display: block; margin: 0 0 6px; color: #838c95; font-size: 9px; font-weight: 600; }
.field-group input, .field-group textarea, .port-row input, .endpoint-row input {
width: 100%;
min-width: 0;
border: 1px solid var(--border);
border-radius: 5px;
outline: 0;
color: #dce0e3;
background: #111418;
font-size: 11px;
}
.field-group input, .port-row input, .endpoint-row input { height: 31px; padding: 0 8px; }
.field-group textarea { padding: 8px; resize: vertical; line-height: 1.5; }
.field-group input:focus, .field-group textarea:focus, .port-row input:focus, .endpoint-row input:focus { border-color: #547ca7; box-shadow: 0 0 0 2px rgba(84, 124, 167, .14); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.read-only-value { height: 31px; overflow: hidden; padding: 8px; border: 1px solid #282e34; border-radius: 5px; color: #7f8891; background: #171a1e; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; }
.section-divider { height: 1px; margin: 16px 0; background: var(--border); }
.section-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.section-title { gap: 6px; color: #b9bec4; font-size: 10px; font-weight: 650; }
.section-title svg { color: var(--text-dim); }
.ports-list { display: flex; flex-direction: column; gap: 5px; }
.port-row { display: grid; grid-template-columns: 78px minmax(0, 1fr) 22px; align-items: center; gap: 5px; }
.port-row input { height: 29px; font-size: 9px; }
.port-key { color: #9fc6e5 !important; font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; }
.inline-empty { padding: 10px; border: 1px dashed #2d333a; border-radius: 5px; color: var(--text-dim); font-size: 9px; text-align: center; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; cursor: pointer; }
.toggle-row > span { display: flex; flex-direction: column; min-width: 0; }
.toggle-title { color: #c0c5ca; font-size: 10px; font-weight: 600; }
.toggle-description { margin-top: 3px; color: var(--text-dim); font-size: 8px; }
.toggle-row input { position: relative; width: 28px; height: 16px; margin: 0; appearance: none; border: 1px solid #424a53; border-radius: 8px; background: #24292f; transition: .15s; }
.toggle-row input::after { position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 50%; background: #818a94; content: ''; transition: .15s; }
.toggle-row input:checked { border-color: #318963; background: #245d49; }
.toggle-row input:checked::after { left: 14px; background: #6fe1b2; }
.notes-field { margin-top: 15px; }
.danger-button { width: 100%; border: 1px solid #55312f; color: #db7b75; background: #261a1a; }
.danger-button:hover { border-color: #8a423e; color: #ff9b94; background: #321d1c; }
.tree-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 18px; border: 1px solid var(--border); border-radius: 6px; background: #171b1f; }
.tree-summary div { display: flex; flex-direction: column; align-items: center; padding: 12px 2px; border-right: 1px solid var(--border); }
.tree-summary div:last-child { border-right: 0; }
.tree-summary strong { font-size: 15px; font-weight: 650; }
.tree-summary span { margin-top: 4px; color: var(--text-dim); font-size: 8px; }
.issue-list { display: flex; flex-direction: column; gap: 5px; }
.issue-row { display: grid; grid-template-columns: 7px 1fr; gap: 7px; width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 5px; color: #a7aeb5; background: #191d21; font-size: 9px; line-height: 1.45; text-align: left; }
.issue-row:not(:disabled):hover { border-color: #4a535d; background: #20252a; }
.issue-row:disabled { opacity: 1; cursor: default; }
.issue-dot { width: 6px; height: 6px; margin-top: 3px; border-radius: 50%; background: var(--amber); }
.issue-row.error .issue-dot { background: var(--red); }
.validation-ok { padding: 9px; border: 1px solid #285441; border-radius: 5px; color: #74cda9; background: #14231d; font-size: 9px; }
.monitor-panel { flex: 0 0 250px; min-height: 0; border-top: 1px solid #384047; background: #15191d; box-shadow: 0 -9px 22px rgba(0, 0, 0, .18); }
.monitor-header { display: flex; align-items: center; justify-content: space-between; height: 39px; padding: 0 11px 0 14px; border-bottom: 1px solid var(--border); }
.monitor-title { gap: 7px; font-size: 10px; }
.monitor-title > svg { color: var(--accent); }
.connection-pill { display: inline-flex; align-items: center; gap: 5px; margin-left: 4px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 8px; color: var(--text-dim); font-size: 8px; font-weight: 500; }
.connection-pill i { width: 5px; height: 5px; border-radius: 50%; background: #69727c; }
.connection-pill.connected { border-color: #2c674e; color: #78d6af; }
.connection-pill.connected i { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.connection-pill.connecting i { background: var(--amber); }
.connection-pill.error { border-color: #683a37; color: #e77a73; }
.connection-pill.error i { background: var(--red); }
.monitor-body { display: grid; grid-template-columns: minmax(270px, 35%) 110px minmax(250px, 1fr); height: calc(100% - 39px); min-height: 0; }
.monitor-connect { padding: 12px 14px; border-right: 1px solid var(--border); }
.monitor-connect .field-group { margin-bottom: 9px; }
.monitor-connect .topic-field { display: grid; grid-template-columns: 95px minmax(0, 1fr); align-items: center; gap: 7px; }
.monitor-connect .topic-field label { margin: 0; }
.monitor-connect .topic-field input { height: 27px; }
.endpoint-row { gap: 6px; }
.endpoint-row input { flex: 1; height: 30px; }
.primary-button, .secondary-button { flex: 0 0 auto; min-height: 30px; padding: 0 9px; }
.primary-button { border: 1px solid #347b5e; color: #d5f4e7; background: #266448; }
.primary-button:hover { background: #2f7656; }
.secondary-button { border: 1px solid var(--border-strong); color: #c2c7cc; background: #24292e; }
.secondary-button:hover { background: #2c3238; }
.compact-toggle { max-width: 160px; }
.monitor-metrics { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 12px; }
.monitor-metrics div { display: flex; flex-direction: column; border-right: 1px solid var(--border); }
.monitor-metrics div + div { padding-left: 10px; }
.monitor-metrics div:last-child { border-right: 0; }
.monitor-metrics span { color: var(--text-dim); font-size: 8px; }
.monitor-metrics strong { margin-top: 4px; color: #c6cbd0; font-size: 10px; }
.runtime-legend { flex-direction: column; align-items: flex-start; justify-content: center; gap: 11px; padding: 10px 13px; border-right: 1px solid var(--border); }
.runtime-legend span { display: flex; align-items: center; gap: 7px; color: #818a93; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; }
.runtime-legend i, .event-row > i { width: 7px; height: 7px; border-radius: 50%; }
.event-stream { flex-direction: column; align-items: stretch; min-width: 0; min-height: 0; }
.event-stream-heading { justify-content: space-between; flex: 0 0 30px; padding: 0 12px; border-bottom: 1px solid var(--border); color: #777f88; font-size: 8px; font-weight: 650; text-transform: uppercase; }
.event-list { overflow: auto; min-height: 0; padding: 4px 8px; scrollbar-width: thin; scrollbar-color: #353c44 transparent; }
.event-row { display: grid; grid-template-columns: 54px 7px minmax(70px, 1fr) 58px 42px; align-items: center; gap: 7px; min-height: 25px; border-bottom: 1px solid #24292e; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; }
.event-row time { color: #626b74; }
.event-node { overflow: hidden; color: #bec4ca; text-overflow: ellipsis; white-space: nowrap; }
.event-status { background: transparent !important; font-size: 7px; }
.unmatched { color: #bb716c; }
.event-empty { padding: 55px 10px; color: #5f6770; font-size: 9px; text-align: center; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.xml-view { position: absolute; inset: 0; display: flex; flex-direction: column; padding: 18px; background: #101215; }
.xml-header { display: flex; align-items: center; justify-content: space-between; flex: 0 0 38px; padding: 0 9px 0 12px; border: 1px solid var(--border); border-bottom: 0; border-radius: 6px 6px 0 0; color: #aab1b8; background: #1b1f23; font-size: 10px; }
.xml-header > div { gap: 7px; }
.xml-header svg { color: var(--accent); }
.xml-view pre { flex: 1; min-height: 0; margin: 0; overflow: auto; padding: 18px; border: 1px solid var(--border); border-radius: 0 0 6px 6px; color: #bbd5c9; background: #0d1012; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.65; tab-size: 4; }
.xml-error { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--border); color: var(--red); background: #141213; }
.xml-error strong { margin-top: 10px; font-size: 12px; }
.xml-error span { max-width: 460px; margin-top: 7px; color: #9b7774; font-size: 10px; text-align: center; }
.status-bar { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #242a30; padding: 0 10px; color: #626b75; background: #111417; font-size: 8px; user-select: none; }
.status-bar > div { display: flex; align-items: center; gap: 13px; }
.status-valid, .status-error { display: inline-flex; align-items: center; gap: 4px; }
.status-valid { color: #5fbf98; }
.status-error { color: #df746e; }
.toast { position: fixed; z-index: 100; left: 50%; bottom: 38px; max-width: min(440px, 70vw); transform: translateX(-50%); padding: 9px 13px; border: 1px solid #356e57; border-radius: 6px; color: #c7eddd; background: #193127; box-shadow: 0 9px 25px rgba(0, 0, 0, .35); font-size: 10px; }
.toast.error { border-color: #763b37; color: #f4b5b0; background: #351d1b; }
@media (max-width: 1200px) {
.workspace-grid { grid-template-columns: 205px minmax(0, 1fr) 265px; }
.workspace-toolbar, .app-header { grid-template-columns: 205px minmax(120px, 1fr) auto; }
.brand-copy span { display: none; }
.palette-items { grid-template-columns: 1fr; }
.monitor-body { grid-template-columns: minmax(260px, 42%) 92px minmax(220px, 1fr); }
}

60
src/types.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { Edge, Node } from '@xyflow/react'
export type NodeCategory = 'control' | 'decorator' | 'action' | 'condition' | 'subtree'
export type NodeType =
| 'Sequence'
| 'Fallback'
| 'Parallel'
| 'ReactiveSequence'
| 'ReactiveFallback'
| 'Inverter'
| 'RetryUntilSuccessful'
| 'Repeat'
| 'ForceSuccess'
| 'ForceFailure'
| 'Action'
| 'Condition'
| 'SubTree'
| 'AlwaysSuccess'
| 'AlwaysFailure'
export type RuntimeStatus = 'IDLE' | 'RUNNING' | 'SUCCESS' | 'FAILURE' | 'SKIPPED'
export type BehaviorNodeData = {
label: string
nodeType: NodeType
category: NodeCategory
registrationName: string
ports: Record<string, string>
notes: string
breakpoint: boolean
runtimeStatus?: RuntimeStatus
[key: string]: unknown
}
export type BehaviorNode = Node<BehaviorNodeData, 'behavior'>
export type BehaviorEdge = Edge
export interface BehaviorTreeDocument {
format: 'arborflow/project'
version: 1
title: string
mainTreeId: string
nodes: BehaviorNode[]
edges: BehaviorEdge[]
}
export interface ValidationIssue {
severity: 'error' | 'warning'
message: string
nodeId?: string
}
export interface RuntimeEvent {
id: string
at: number
nodeRef: string
status: RuntimeStatus
matchedNodeIds: string[]
}

20
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
/// <reference types="vite/client" />
interface ArborFlowDesktopApi {
platform: string
openText: (options: {
filters: Array<{ name: string; extensions: string[] }>
}) => Promise<{ path: string; content: string } | null>
saveText: (options: {
path?: string
prompt?: boolean
defaultPath?: string
content: string
filters: Array<{ name: string; extensions: string[] }>
}) => Promise<{ path: string } | null>
onMenuCommand: (callback: (command: string) => void) => () => void
}
interface Window {
arborflow?: ArborFlowDesktopApi
}