3 Commits
v0.1.0 ... main

20 changed files with 2101 additions and 294 deletions

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111418" />
<link rel="icon" type="image/svg+xml" href="/arborflow.svg" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<title>ArborFlow</title>
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "arborflow",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"description": "A cross-platform behavior tree editor for BehaviorTree.CPP",
"main": "electron/main.cjs",

View File

@@ -26,16 +26,20 @@ import {
FolderOpen,
GitBranch,
MonitorDot,
Moon,
Redo2,
Save,
Settings,
Sun,
TreePine,
Undo2,
Upload,
FileUp,
} 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 { NodeDetailModal } from './components/NodeDetailModal'
import { NodePalette } from './components/NodePalette'
import { createNode, createNodeFromModel, maxChildren } from './lib/catalog'
import { createDemoDocument, createEmptyDocument } from './lib/demo'
@@ -44,7 +48,9 @@ import { downloadText, parseProject, pickBrowserFile, serializeProject } from '.
import { activeTreeId, switchDocumentTree, syncedTreeGraphs } from './lib/trees'
import { validateDocument, wouldCreateCycle } from './lib/validation'
import { documentToXml, xmlToDocument } from './lib/xml'
import type { BehaviorNode, BehaviorTreeDocument, NodeType, RuntimeStatus } from './types'
import { useTheme } from './context/ThemeContext'
import { useI18n } from './context/I18nContext'
import type { BehaviorNode, BehaviorTreeDocument, CustomNodeModel, NodeType, RuntimeStatus } from './types'
const nodeTypes = { behavior: BehaviorNodeCard }
const PROJECT_FILTERS = [{ name: 'ArborFlow Project', extensions: ['arborflow', 'json'] }]
@@ -54,14 +60,18 @@ type ViewMode = 'canvas' | 'xml'
type DocumentUpdater = BehaviorTreeDocument | ((current: BehaviorTreeDocument) => BehaviorTreeDocument)
function Editor() {
const { theme, toggleTheme } = useTheme()
const { t, lang, setLang } = useI18n()
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 [treeMenuOpen, setTreeMenuOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [monitorOpen, setMonitorOpen] = useState(false)
const [modalNodeId, setModalNodeId] = useState<string | null>(null)
const [runtimeStatuses, setRuntimeStatuses] = useState<Record<string, RuntimeStatus>>({})
const [toast, setToast] = useState<{ kind: 'success' | 'error'; message: string } | null>(null)
const [historyRevision, setHistoryRevision] = useState(0)
@@ -84,7 +94,7 @@ function Editor() {
try {
return { value: documentToXml(treeDocument), error: '' }
} catch (error) {
return { value: '', error: error instanceof Error ? error.message : '无法生成 XML' }
return { value: '', error: error instanceof Error ? error.message : 'Cannot generate XML' }
}
}, [treeDocument])
@@ -148,7 +158,17 @@ function Editor() {
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] }))
commit((current) => {
const next = { ...current, nodes: [...current.nodes, node] }
// Auto-create a tree for new SubTree references
if (type === 'SubTree') {
const treeId = node.data.registrationName
if (treeId && !next.trees?.some((t) => t.id === treeId)) {
next.trees = [...(next.trees || []), { id: treeId, nodes: [], edges: [] }]
}
}
return next
})
setSelectedNodeId(node.id)
setViewMode('canvas')
}, [commit, screenToFlowPosition])
@@ -163,6 +183,13 @@ function Editor() {
setViewMode('canvas')
}, [commit, screenToFlowPosition, treeDocument.nodeModels])
const addCustomModel = useCallback((model: CustomNodeModel) => {
commit((current) => ({
...current,
nodeModels: [...(current.nodeModels || []), model],
}))
}, [commit])
const switchTree = useCallback((treeId: string) => {
if (treeId === currentTreeId) {
setTreeMenuOpen(false)
@@ -179,6 +206,16 @@ function Editor() {
window.setTimeout(() => fitView({ padding: 0.2, duration: 320 }), 60)
}, [currentTreeId, fitView])
const navigateToTree = useCallback((treeId: string) => {
commit((current) => {
if (!current.trees?.some((t) => t.id === treeId)) {
return { ...current, trees: [...(current.trees || []), { id: treeId, nodes: [], edges: [] }] }
}
return current
})
switchTree(treeId)
}, [commit, switchTree])
const deleteNode = useCallback((nodeId: string) => {
commit((current) => ({
...current,
@@ -194,16 +231,25 @@ function Editor() {
}, [commit])
const updateNode = useCallback((nodeId: string, data: Partial<BehaviorNode['data']>) => {
commit((current) => ({
commit((current) => {
const node = current.nodes.find((n) => n.id === nodeId)
const next = {
...current,
nodes: current.nodes.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node),
}))
nodes: current.nodes.map((n) => n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n),
}
// Auto-create tree when SubTree registrationName changes
if (node && node.data.nodeType === 'SubTree' && data.registrationName) {
const treeId = data.registrationName
if (!next.trees?.some((t) => t.id === treeId)) {
next.trees = [...(next.trees || []), { id: treeId, nodes: [], edges: [] }]
}
}
return next
})
}, [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) }))
setTreeDocument((current) => ({ ...current, nodes: applyNodeChanges(changes, current.nodes) }))
}, [])
const connectNodes: OnConnect = useCallback((connection: Connection) => {
@@ -213,15 +259,15 @@ function Editor() {
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')
showToast(`${source.data.label} ${t('validation.cannotAddChild')}`, 'error')
return
}
if (incomingCount > 0) {
showToast('一个节点只能有一个父节点', 'error')
showToast(t('validation.singleParent'), 'error')
return
}
if (wouldCreateCycle(treeDocument, connection.source, connection.target)) {
showToast('此连接会形成环路', 'error')
showToast(t('validation.wouldCycle'), 'error')
return
}
commit((current) => ({
@@ -237,7 +283,7 @@ function Editor() {
}, [commit, fitView, treeDocument.nodes.length])
const newProject = useCallback(() => {
if (dirty && !window.confirm('当前工程有未保存更改,仍要新建吗?')) return
if (dirty && !window.confirm(t('confirm.unsavedNew'))) return
replaceDocument(createEmptyDocument())
}, [dirty, replaceDocument])
@@ -248,9 +294,9 @@ function Editor() {
: await pickBrowserFile('.arborflow,.json')
if (!result) return
replaceDocument(parseProject(result.content), window.arborflow ? result.path : null)
showToast('工程已打开')
showToast(t('toast.projectOpened'))
} catch (error) {
showToast(error instanceof Error ? error.message : '打开工程失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.openFailed'), 'error')
}
}, [replaceDocument, showToast])
@@ -261,7 +307,7 @@ function Editor() {
downloadText(`${treeDocument.title || 'behavior-tree'}.arborflow`, content, 'application/json')
savedContentRef.current = content
setDirty(false)
showToast('工程已下载')
showToast(t('toast.projectDownloaded'))
return
}
const result = await window.arborflow.saveText({
@@ -275,9 +321,9 @@ function Editor() {
setProjectPath(result.path)
savedContentRef.current = content
setDirty(false)
showToast('工程已保存')
showToast(t('toast.projectSaved'))
} catch (error) {
showToast(error instanceof Error ? error.message : '保存失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.saveFailed'), 'error')
}
}, [projectPath, showToast, treeDocument])
@@ -290,9 +336,9 @@ function Editor() {
replaceDocument(xmlToDocument(result.content))
savedContentRef.current = ''
setDirty(true)
showToast('XML 已导入')
showToast(t('toast.xmlImported'))
} catch (error) {
showToast(error instanceof Error ? error.message : '导入 XML 失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.importFailed'), 'error')
}
}, [replaceDocument, showToast])
@@ -306,9 +352,9 @@ function Editor() {
const result = await window.arborflow.saveText({ prompt: true, defaultPath: filename, content, filters: XML_FILTERS })
if (!result) return
}
showToast('BehaviorTree.CPP XML 已导出')
showToast(t('toast.xmlExported'))
} catch (error) {
showToast(error instanceof Error ? error.message : '导出 XML 失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.exportFailed'), 'error')
}
}, [showToast, treeDocument])
@@ -335,7 +381,7 @@ function Editor() {
const copyXml = useCallback(async () => {
if (!xmlPreview.value) return
await navigator.clipboard.writeText(xmlPreview.value)
showToast('XML 已复制')
showToast(t('toast.xmlCopied'))
}, [showToast, xmlPreview.value])
useEffect(() => {
@@ -434,21 +480,43 @@ function Editor() {
<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="document-title"><span>{treeDocument.title}</span>{dirty && <i title={t('action.unsaved')} />}</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>
<button className="icon-button" onClick={newProject} title={`${t('action.newProject')} (Ctrl+N)`}><FilePlus2 size={17} /></button>
<button className="icon-button" onClick={() => void openProject()} title={`${t('action.openProject')} (Ctrl+O)`}><FolderOpen size={17} /></button>
<button className="icon-button" onClick={() => void saveProject(false)} title={`${t('action.saveProject')} (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>
<button className="icon-button" onClick={undo} disabled={!canUndo} title={`${t('action.undo')} (Ctrl+Z)`}><Undo2 size={17} /></button>
<button className="icon-button" onClick={redo} disabled={!canRedo} title={`${t('action.redo')} (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="icon-button" onClick={importXml} title={t('action.importXml')}><FileUp size={17} /></button>
<button className="icon-button" onClick={exportXml} title={t('action.exportXml')}><FileDown size={17} /></button>
<span className="toolbar-separator" />
<button className={`icon-button ${settingsOpen ? 'active' : ''}`} onClick={() => setSettingsOpen((v) => !v)} title={t('action.settings')}><Settings size={17} /></button>
<button className={`monitor-button ${monitorOpen ? 'active' : ''}`} onClick={() => setMonitorOpen((value) => !value)}><MonitorDot size={16} />Monitor</button>
</div>
</header>
{settingsOpen && (
<div className="settings-dropdown">
<div className="settings-heading">{t('settings.title')}</div>
<div className="settings-heading" style={{ marginTop: 4 }}>{t('settings.theme')}</div>
<button className="settings-row" onClick={toggleTheme}>
<span>{theme === 'dark' ? <><Moon size={14} /> {t('settings.dark')}</> : <><Sun size={14} /> {t('settings.light')}</>}</span>
<small>{theme === 'dark' ? 'Dark' : 'Light'}</small>
</button>
<div className="settings-heading" style={{ marginTop: 4 }}>{t('settings.language')}</div>
<button className={`settings-row ${lang === 'zh' ? 'active' : ''}`} onClick={() => setLang('zh')}>
<span>{t('settings.langZh')}</span>
{lang === 'zh' && <small>✓</small>}
</button>
<button className={`settings-row ${lang === 'en' ? 'active' : ''}`} onClick={() => setLang('en')}>
<span>{t('settings.langEn')}</span>
{lang === 'en' && <small>✓</small>}
</button>
</div>
)}
<div className="workspace-toolbar">
<div className="tree-selector">
<button className={`tree-tab ${treeMenuOpen ? 'active' : ''}`} onClick={() => setTreeMenuOpen((value) => !value)}>
@@ -456,27 +524,27 @@ function Editor() {
</button>
{treeMenuOpen && (
<div className="tree-menu">
<div className="tree-menu-heading">行为树 · {treeGraphs.length}</div>
<div className="tree-menu-heading">{t('tree.heading')} · {treeGraphs.length}</div>
{treeGraphs.map((tree) => (
<button className={tree.id === currentTreeId ? 'active' : ''} key={tree.id} onClick={() => switchTree(tree.id)}>
<span>{tree.id}</span>
<small>{tree.nodes.length} 节点{tree.id === treeDocument.mainTreeId ? ' · MAIN' : ''}</small>
<small>{tree.nodes.length} {t('status.nodes')}{tree.id === treeDocument.mainTreeId ? ' · MAIN' : ''}</small>
</button>
))}
</div>
)}
</div>
<div className="view-switch" aria-label="视图切换">
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />画布</button>
<div className="view-switch" aria-label={t('toolbar.viewSwitch')}>
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />{t('toolbar.canvas')}</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>
<button className="tool-button" onClick={autoLayout} title={`${t('toolbar.autoLayout')} (Ctrl+L)`}><AlignVerticalSpaceAround size={15} />{t('toolbar.autoLayout')}</button>
</div>
</div>
<main className="workspace-grid">
<NodePalette customModels={treeDocument.nodeModels || []} onAddNode={addNodeAt} onAddCustomNode={addCustomNodeAt} />
<NodePalette customModels={treeDocument.nodeModels || []} onAddNode={addNodeAt} onAddCustomNode={addCustomNodeAt} onAddCustomModel={addCustomModel} />
<section className="center-stage">
<div className="canvas-region" onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={handleDrop}>
{viewMode === 'canvas' ? (
@@ -487,8 +555,22 @@ function Editor() {
onNodesChange={onNodesChange}
onConnect={connectNodes}
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
onNodeDoubleClick={(_event, node) => {
if (node.data.nodeType === 'SubTree') {
const targetId = node.data.registrationName
commit((current) => {
if (!current.trees?.some((t) => t.id === targetId)) {
return { ...current, trees: [...(current.trees || []), { id: targetId, nodes: [], edges: [] }] }
}
return current
})
if (targetId) switchTree(targetId)
return
}
setModalNodeId(node.id); setSelectedNodeId(node.id)
}}
onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null) }}
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false) }}
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false); setSettingsOpen(false) }}
onNodeDragStart={() => { dragStartRef.current = treeDocument }}
onNodeDragStop={() => {
if (dragStartRef.current) {
@@ -510,7 +592,7 @@ function Editor() {
defaultEdgeOptions={{ type: 'smoothstep' }}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="#343a40" />
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color={theme === 'dark' ? '#343a40' : '#c4c8ce'} />
<Controls showInteractive={false} position="bottom-left" />
<MiniMap
position="bottom-right"
@@ -518,19 +600,22 @@ function Editor() {
zoomable
nodeColor={(node) => {
const category = (node.data as BehaviorNode['data']).category
return { control: '#4f8cff', decorator: '#c084fc', action: '#22c98b', condition: '#f2b84b', subtree: '#ec6f66' }[category]
const colors = theme === 'dark'
? { control: '#4f8cff', decorator: '#c084fc', action: '#22c98b', condition: '#f2b84b', subtree: '#ec6f66', root: '#eeb84a' }
: { control: '#3a72e0', decorator: '#8b5fd4', action: '#1aad6f', condition: '#d99a20', subtree: '#d9605a', root: '#d99a20' }
return colors[category] || (theme === 'dark' ? '#68717b' : '#929aa3')
}}
maskColor="rgba(10, 12, 15, 0.72)"
maskColor={theme === 'dark' ? 'rgba(10, 12, 15, 0.72)' : 'rgba(200, 205, 212, 0.45)'}
/>
</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>
<button className="icon-button compact" onClick={copyXml} disabled={!xmlPreview.value} title={t('xml.copyButton')}><Copy size={15} /></button>
</div>
{xmlPreview.error ? (
<div className="xml-error"><Activity size={20} /><strong>无法生成 XML</strong><span>{xmlPreview.error}</span></div>
<div className="xml-error"><Activity size={20} /><strong>{t('xml.errorHeading')}</strong><span>{xmlPreview.error}</span></div>
) : (
<pre><code>{xmlPreview.value}</code></pre>
)}
@@ -553,14 +638,28 @@ function Editor() {
onUpdateNode={updateNode}
onDeleteNode={deleteNode}
onSelectIssue={selectAndRevealNode}
onNavigateToTree={navigateToTree}
/>
</main>
<footer className="status-bar">
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} 个结构错误` : <><Check size={12} />结构有效</>}</span></div>
<div><span>{treeGraphs.length} 棵树</span><span>{treeDocument.nodes.length} 节点</span><span>{treeDocument.edges.length} 连接</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}</span></div>
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} ${t('status.errors')}` : <><Check size={12} />{t('status.valid')}</>}</span></div>
<div><span>{treeGraphs.length} {t('status.trees')}</span><span>{treeDocument.nodes.length} {t('status.nodes')}</span><span>{treeDocument.edges.length} {t('status.edges')}</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : t('status.webPreview')}</span></div>
</footer>
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}
{modalNodeId && (() => {
const modalNode = treeDocument.nodes.find((n) => n.id === modalNodeId)
if (!modalNode) return null
return (
<NodeDetailModal
node={modalNode}
onSave={updateNode}
onDelete={(nodeId) => { deleteNode(nodeId); setModalNodeId(null) }}
onClose={() => setModalNodeId(null)}
/>
)
})()}
</div>
)
}

View File

@@ -1,24 +1,90 @@
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'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, PortDirection } from '../types'
const MAX_INLINE_PORTS = 5
function getPortDirection(
portName: string,
portDirections: Record<string, PortDirection> | undefined,
): PortDirection | null {
if (!portDirections) return null
return portDirections[portName] || null
}
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'
const { lang } = useI18n()
// Bilingual display: show Chinese as primary when zh, English as secondary
const isZh = lang === 'zh'
const primaryLabel = isZh ? (definition?.labelZh || data.label) : data.label
const secondaryLabel = isZh ? data.label : (definition?.labelZh || '')
// Port entries for inline display
const portEntries = Object.entries(data.ports || {})
const visiblePorts = portEntries.slice(0, MAX_INLINE_PORTS)
const hiddenCount = portEntries.length - MAX_INLINE_PORTS
const isRoot = data.nodeType === 'Root'
return (
<div className={`behavior-node category-${data.category} runtime-${status.toLowerCase()} ${selected ? 'is-selected' : ''}`}>
{/* Top handle — skip for Root node since nothing connects above it */}
{!isRoot && (
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
)}
<div className="node-accent" />
<div className="node-top-row">
<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-title" title={primaryLabel}>{primaryLabel}</div>
{secondaryLabel && secondaryLabel !== primaryLabel && (
<div className="node-subtitle">{secondaryLabel}</div>
)}
{!secondaryLabel || secondaryLabel === primaryLabel ? (
<div className="node-type">{data.registrationName}</div>
) : (
<div className="node-type">{data.registrationName}</div>
)}
</div>
</div>
{/* Inline port parameters */}
{portEntries.length > 0 && (
<div className="node-ports-row">
{visiblePorts.map(([key, value]) => {
const dir = getPortDirection(key, data.portDirections)
return (
<div className="node-port-param" key={key}>
{dir && (
<span className={`port-dir-badge dir-${dir === 'input' ? 'in' : 'out'}`}>
{dir === 'input' ? 'IN' : 'OUT'}
</span>
)}
<span className="port-key" title={key}>{key}</span>
{value && <span className="port-value">: {value}</span>}
</div>
)
})}
{hiddenCount > 0 && (
<div className="node-ports-more">...+{hiddenCount} more</div>
)}
</div>
)}
{data.breakpoint && <OctagonAlert className="breakpoint-icon" size={15} aria-label="Breakpoint" />}
{status !== 'IDLE' && <span className={`runtime-badge runtime-${status.toLowerCase()}`}>{status}</span>}
{/* Root badge */}
{isRoot && <span className="runtime-badge" style={{ color: 'var(--amber)', borderColor: 'var(--amber)', bottom: 'auto', top: 6, right: 7 }}>ROOT</span>}
{/* Bottom handle — only if node can have children */}
{canHaveChildren(data.nodeType) && (
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
)}

View File

@@ -1,6 +1,6 @@
import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react'
import { CATEGORY_LABELS } from '../lib/catalog'
import type { BehaviorNode, BehaviorTreeDocument, ValidationIssue } from '../types'
import { AlertCircle, Braces, ExternalLink, Plus, Trash2, X } from 'lucide-react'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
interface InspectorProps {
document: BehaviorTreeDocument
@@ -10,9 +10,12 @@ interface InspectorProps {
onUpdateNode: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
onDeleteNode: (nodeId: string) => void
onSelectIssue: (nodeId: string) => void
onNavigateToTree: (treeId: string) => void
}
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) {
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue, onNavigateToTree }: InspectorProps) {
const { t } = useI18n()
const updatePort = (oldKey: string, key: string, value: string) => {
if (!selectedNode) return
const ports = { ...selectedNode.data.ports }
@@ -21,11 +24,27 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
onUpdateNode(selectedNode.id, { ports })
}
const updatePortDirection = (key: string, direction: PortDirection | '') => {
if (!selectedNode) return
const portDirections = { ...(selectedNode.data.portDirections || {}) }
if (direction) {
portDirections[key] = direction
} else {
delete portDirections[key]
}
onUpdateNode(selectedNode.id, { portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined })
}
const removePort = (key: string) => {
if (!selectedNode) return
const ports = { ...selectedNode.data.ports }
delete ports[key]
onUpdateNode(selectedNode.id, { ports })
const portDirections = selectedNode.data.portDirections ? { ...selectedNode.data.portDirections } : {}
delete portDirections[key]
onUpdateNode(selectedNode.id, {
ports,
portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined,
})
}
const addPort = () => {
@@ -36,88 +55,111 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
onUpdateNode(selectedNode.id, { ports: { ...selectedNode.data.ports, [key]: '' } })
}
const getPortDir = (key: string): PortDirection | '' => {
if (!selectedNode?.data.portDirections) return ''
return selectedNode.data.portDirections[key] || ''
}
return (
<aside className="right-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">INSPECTOR</span>
<h2>{selectedNode ? '节点属性' : '行为树'}</h2>
<span className="eyebrow">{t('inspector.title')}</span>
<h2>{selectedNode ? t('inspector.nodeProps') : t('inspector.treeProps')}</h2>
</div>
{selectedNode && <span className={`category-chip category-${selectedNode.data.category}`}>{CATEGORY_LABELS[selectedNode.data.category]}</span>}
{selectedNode && (
<span className={`category-chip category-${selectedNode.data.category}`}>
{t(`cat.${selectedNode.data.category}`)}
</span>
)}
</div>
<div className="inspector-scroll">
{selectedNode ? (
<>
<div className="field-group">
<label htmlFor="node-label">显示名称</label>
<label htmlFor="node-label">{t('inspector.displayName')}</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>
<label htmlFor="registration-name">{t('inspector.registrationName')}</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>
<label>{t('inspector.nodeType')}</label>
<div className="read-only-value">{selectedNode.data.nodeType}</div>
</div>
<div className="field-group">
<label>运行匹配 ID</label>
<label>{t('inspector.nodeId')}</label>
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
</div>
</div>
{selectedNode.data.nodeType === 'SubTree' && selectedNode.data.registrationName && (
<button className="secondary-button" style={{ width: '100%', marginBottom: 10 }} onClick={() => onNavigateToTree(selectedNode.data.registrationName)}>
<ExternalLink size={14} /> {t('inspector.enterSubtree')}
</button>
)}
<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 className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
<button className="icon-button compact" onClick={addPort} title={t('inspector.addPort')}><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>
<input className="port-key" value={key} onChange={(event) => updatePort(key, event.target.value, value)} aria-label={t('inspector.portKey')} />
<select
value={getPortDir(key)}
onChange={(event) => updatePortDirection(key, event.target.value as PortDirection | '')}
style={{ gridColumn: '1', gridRow: '2', height: 22, fontSize: 9, border: '1px solid var(--border-input)', borderRadius: 4, color: 'var(--text-primary)', background: 'var(--bg-input)' }}
>
<option value="">--</option>
<option value="input">IN</option>
<option value="output">OUT</option>
</select>
<input value={value} onChange={(event) => updatePort(key, key, event.target.value)} placeholder={t('inspector.portValue')} aria-label={`${key} ${t('inspector.portValue')}`} />
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title={t('inspector.deleteNode')}><X size={14} /></button>
</div>
))}
{!Object.keys(selectedNode.data.ports).length && <div className="inline-empty">暂无端口</div>}
{!Object.keys(selectedNode.data.ports).length && <div className="inline-empty">{t('inspector.noPorts')}</div>}
</div>
<div className="section-divider" />
<label className="toggle-row">
<span><span className="toggle-title">断点</span><span className="toggle-description">运行到此节点时标记</span></span>
<span><span className="toggle-title">{t('inspector.breakpoint')}</span><span className="toggle-description">{t('inspector.breakpointDesc')}</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>
<label htmlFor="node-notes">{t('inspector.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>
<button className="danger-button" onClick={() => onDeleteNode(selectedNode.id)}><Trash2 size={15} />{t('inspector.deleteNode')}</button>
</>
) : (
<>
<div className="field-group">
<label htmlFor="tree-title">工程名称</label>
<label htmlFor="tree-title">{t('inspector.projectName')}</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>
<label htmlFor="tree-id">{t('inspector.mainTreeId')}</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><strong>{document.nodes.length}</strong><span>{t('inspector.nodeCount')}</span></div>
<div><strong>{document.edges.length}</strong><span>{t('inspector.edgeCount')}</span></div>
<div><strong>{issues.filter((item) => item.severity === 'error').length}</strong><span>{t('inspector.errorCount')}</span></div>
</div>
</>
)}
<div className="section-divider" />
<div className="section-title-row">
<div className="section-title"><AlertCircle size={15} />结构检查</div>
<div className="section-title"><AlertCircle size={15} />{t('inspector.structureCheck')}</div>
<span className="count-label">{issues.length}</span>
</div>
<div className="issue-list">
{!issues.length && <div className="validation-ok">结构有效</div>}
{!issues.length && <div className="validation-ok">{t('validation.valid')}</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" />

View File

@@ -1,6 +1,7 @@
import { Activity, CircleStop, PlugZap, RotateCw, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { matchRuntimeNodes, parseMonitorMessage } from '../lib/runtime'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, RuntimeEvent, RuntimeStatus } from '../types'
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -13,11 +14,11 @@ interface MonitorPanelProps {
onClearStatuses: () => void
}
const STATE_LABEL: Record<ConnectionState, string> = {
disconnected: '未连接', connecting: '连接中', connected: '已连接', error: '连接错误',
}
export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearStatuses }: MonitorPanelProps) {
const { t, lang } = useI18n()
const STATE_LABEL: Record<ConnectionState, string> = {
disconnected: t('monitor.disconnected'), connecting: t('monitor.connecting'), connected: t('monitor.connected'), error: t('monitor.error'),
}
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')
@@ -120,20 +121,20 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta
<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>
<button className="icon-button compact" onClick={() => { setEvents([]); onClearStatuses() }} title={t('monitor.clearRuntime')}><Trash2 size={15} /></button>
<button className="icon-button compact" onClick={onClose} title={t('monitor.closeMonitor')}><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>
<label htmlFor="monitor-endpoint">{t('monitor.endpoint')}</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="secondary-button" onClick={disconnect}><CircleStop size={15} />{t('monitor.disconnect')}</button>
) : (
<button className="primary-button" onClick={() => setShouldConnect(true)}><PlugZap size={15} />连接</button>
<button className="primary-button" onClick={() => setShouldConnect(true)}><PlugZap size={15} />{t('monitor.connect')}</button>
)}
</div>
</div>
@@ -142,29 +143,29 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta
<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>
<span><span className="toggle-title">{t('monitor.autoReconnect')}</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><span>{t('monitor.messages')}</span><strong>{messageCount}</strong></div>
<div><span>{t('monitor.latency')}</span><strong>{lastLatency === null ? '--' : `${lastLatency} ms`}</strong></div>
<div><span>{t('monitor.matched')}</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-stream-heading"><span>{t('monitor.eventStream')}</span>{connectionState === 'connecting' && <RotateCw className="spin" size={14} />}</div>
<div className="event-list">
{!events.length && <div className="event-empty">等待节点状态</div>}
{!events.length && <div className="event-empty">{t('monitor.waiting')}</div>}
{events.map((event) => (
<div className="event-row" key={event.id}>
<time>{new Date(event.at).toLocaleTimeString('zh-CN', { hour12: false })}</time>
<time>{new Date(event.at).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en-US', { 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>}
{!event.matchedNodeIds.length && <span className="unmatched">{t('monitor.unmatched')}</span>}
</div>
))}
</div>

View File

@@ -0,0 +1,202 @@
import { Braces, Plus, Trash2, X } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { NODE_DEFINITION_MAP } from '../lib/catalog'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, PortDirection } from '../types'
interface NodeDetailModalProps {
node: BehaviorNode
onSave: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
onDelete: (nodeId: string) => void
onClose: () => void
}
export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailModalProps) {
const { t, lang } = useI18n()
const definition = NODE_DEFINITION_MAP.get(node.data.nodeType)
// Local working copy
const [label, setLabel] = useState(node.data.label)
const [registrationName, setRegistrationName] = useState(node.data.registrationName)
const [ports, setPorts] = useState<Record<string, string>>({ ...node.data.ports })
const [portDirections, setPortDirections] = useState<Record<string, PortDirection>>(
node.data.portDirections ? { ...node.data.portDirections } : {},
)
const [breakpoint, setBreakpoint] = useState(node.data.breakpoint)
const [notes, setNotes] = useState(node.data.notes)
const handleSave = useCallback(() => {
onSave(node.id, {
label,
registrationName,
ports,
portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined,
breakpoint,
notes,
})
onClose()
}, [node.id, label, registrationName, ports, portDirections, breakpoint, notes, onSave, onClose])
const addPort = useCallback(() => {
let key = 'port'
let idx = 1
while (key in ports) key = `port_${idx++}`
setPorts((prev) => ({ ...prev, [key]: '' }))
}, [ports])
const updatePortKey = useCallback((oldKey: string, newKey: string) => {
setPorts((prev) => {
const next = { ...prev }
const value = next[oldKey] || ''
delete next[oldKey]
next[newKey] = value
return next
})
setPortDirections((prev) => {
const next = { ...prev }
if (oldKey in next) {
next[newKey] = next[oldKey]
delete next[oldKey]
}
return next
})
}, [])
const updatePortValue = useCallback((key: string, value: string) => {
setPorts((prev) => ({ ...prev, [key]: value }))
}, [])
const updatePortDirection = useCallback((key: string, direction: PortDirection) => {
setPortDirections((prev) => ({ ...prev, [key]: direction }))
}, [])
const removePort = useCallback((key: string) => {
setPorts((prev) => {
const next = { ...prev }
delete next[key]
return next
})
setPortDirections((prev) => {
const next = { ...prev }
delete next[key]
return next
})
}, [])
// ESC to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
const portEntries = Object.entries(ports)
return (
<div className="modal-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal-card">
<div className="modal-header">
<h2>
<span style={{ color: 'var(--category-color)' }}>
{lang === 'zh' && definition?.labelZh ? definition.labelZh : definition?.label}
</span>
{t('modal.editNode')}
</h2>
<button className="bare-icon" onClick={onClose}><X size={18} /></button>
</div>
<div className="modal-body">
{/* Basic Info */}
<div className="field-group">
<label>{t('inspector.displayName')}</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="field-group">
<label>{t('inspector.registrationName')}</label>
<input value={registrationName} onChange={(e) => setRegistrationName(e.target.value)} />
</div>
<div className="field-row">
<div className="field-group">
<label>{t('inspector.nodeType')}</label>
<div className="read-only-value">{node.data.nodeType}</div>
</div>
<div className="field-group">
<label>{t('inspector.nodeId')}</label>
<div className="read-only-value mono" title={node.id}>{node.id.slice(0, 14)}</div>
</div>
</div>
<div className="section-divider" />
{/* Ports */}
<div className="section-title-row">
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
<button className="icon-button compact" onClick={addPort} title={t('inspector.addPort')}><Plus size={15} /></button>
</div>
<div className="ports-list">
{portEntries.map(([key, value]) => (
<div className="port-row" key={key}>
<input
className="port-key"
value={key}
onChange={(e) => updatePortKey(key, e.target.value)}
aria-label={t('inspector.portKey')}
/>
<select
value={portDirections[key] || ''}
onChange={(e) => updatePortDirection(key, e.target.value as PortDirection || 'input')}
aria-label={t('inspector.portDirection')}
>
<option value="">--</option>
<option value="input">IN</option>
<option value="output">OUT</option>
</select>
<input
value={value}
onChange={(e) => updatePortValue(key, e.target.value)}
placeholder={t('inspector.portValue')}
aria-label={`${key} ${t('inspector.portValue')}`}
/>
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title={t('inspector.deleteNode')}>
<X size={14} />
</button>
</div>
))}
{!portEntries.length && <div className="inline-empty">{t('inspector.noPorts')}</div>}
</div>
<div className="section-divider" />
{/* Breakpoint */}
<label className="toggle-row">
<span>
<span className="toggle-title">{t('inspector.breakpoint')}</span>
<span className="toggle-description">{t('inspector.breakpointDesc')}</span>
</span>
<input type="checkbox" checked={breakpoint} onChange={(e) => setBreakpoint(e.target.checked)} />
</label>
{/* Notes */}
<div className="field-group notes-field">
<label>{t('inspector.notes')}</label>
<textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
<div className="modal-footer">
<div className="footer-left">
<button className="danger-button" onClick={() => { onDelete(node.id); onClose() }}>
<Trash2 size={15} />{t('modal.delete')}
</button>
</div>
<div className="footer-right">
<button className="secondary-button" onClick={onClose}>{t('modal.cancel')}</button>
<button className="primary-button" onClick={handleSave}>{t('modal.save')}</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,24 +1,41 @@
import { Search, SquareFunction, X, Zap } from 'lucide-react'
import { Plus, Search, SquareFunction, TreePine, X, Zap } from 'lucide-react'
import { useMemo, useState } from 'react'
import { CATEGORY_LABELS, NODE_DEFINITIONS } from '../lib/catalog'
import type { CustomNodeModel, NodeCategory, NodeType } from '../types'
import { NODE_DEFINITIONS, NODE_DEFINITION_MAP } from '../lib/catalog'
import { useI18n } from '../context/I18nContext'
import type { CustomNodeModel, NodeType } from '../types'
interface NodePaletteProps {
customModels: CustomNodeModel[]
onAddNode: (type: NodeType) => void
onAddCustomNode: (modelId: string) => void
onAddCustomModel: (model: CustomNodeModel) => void
}
const CATEGORY_ORDER: NodeCategory[] = ['control', 'decorator', 'action', 'condition', 'subtree']
const CATEGORY_ORDER = ['root', 'control', 'decorator', 'action', 'condition', 'subtree'] as const
export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePaletteProps) {
export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCustomModel }: NodePaletteProps) {
const { t, lang } = useI18n()
const isZh = lang === 'zh'
const [query, setQuery] = useState('')
const normalizedQuery = query.trim().toLowerCase()
const groups = useMemo(() => CATEGORY_ORDER.map((category) => ({
category,
items: NODE_DEFINITIONS.filter((item) => item.category === category)
.filter((item) => !normalizedQuery || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalizedQuery)),
})).filter((group) => group.items.length), [normalizedQuery])
const [creatingModel, setCreatingModel] = useState(false)
const [newModelName, setNewModelName] = useState('')
const [newModelType, setNewModelType] = useState<NodeType>('Action')
const groupedDefinitions = useMemo(() => {
const defs = NODE_DEFINITIONS.filter((d) => {
if (!normalizedQuery) return true
const label = isZh ? d.labelZh : d.label
const desc = isZh ? d.descriptionZh : d.description
return `${label} ${desc} ${d.type}`.toLowerCase().includes(normalizedQuery)
})
const groups = new Map<string, typeof defs>()
CATEGORY_ORDER.forEach((cat) => {
const items = defs.filter((d) => d.category === cat)
if (items.length) groups.set(cat, items)
})
return groups
}, [normalizedQuery, isZh])
const visibleModels = useMemo(() => customModels.filter((model) => {
if (!normalizedQuery) return true
@@ -35,24 +52,61 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
event.dataTransfer.effectAllowed = 'copy'
}
const submitNewModel = () => {
const trimmed = newModelName.trim()
if (!trimmed) return
const def = NODE_DEFINITION_MAP.get(newModelType)!
const model: CustomNodeModel = {
id: trimmed,
nodeType: newModelType,
category: def.category,
ports: [],
}
if (def.defaultPorts) {
model.ports = Object.entries(def.defaultPorts).map(([name, defaultValue]) => ({
name,
direction: 'input_port' as const,
type: '',
defaultValue,
description: '',
}))
}
onAddCustomModel(model)
setNewModelName('')
setNewModelType('Action')
setCreatingModel(false)
}
const catLabel = (cat: string) => {
return t(`cat.${cat}`)
}
const nodeLabel = (d: typeof NODE_DEFINITIONS[number]) => {
return isZh ? d.labelZh : d.label
}
const nodeDesc = (d: typeof NODE_DEFINITIONS[number]) => {
return isZh ? d.descriptionZh : d.description
}
return (
<aside className="left-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">LIBRARY</span>
<h2>节点</h2>
<span className="eyebrow">{t('palette.eyebrow')}</span>
<h2>{t('palette.title')}</h2>
</div>
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
</div>
<div className="palette-search">
<Search size={16} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点" aria-label="搜索节点" />
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={15} /></button>}
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('palette.searchPlaceholder')} aria-label={t('palette.searchPlaceholder')} />
{query && <button className="bare-icon" onClick={() => setQuery('')} title={t('palette.clearSearch')}><X size={15} /></button>}
</div>
<div className="palette-scroll">
{visibleModels.length > 0 && (
<section className="palette-group custom-model-group">
<h3>XML 自定义节点</h3>
<h3>{t('palette.customNodes')}</h3>
<div className="palette-items">
{visibleModels.map((model) => {
const Icon = model.category === 'condition' ? SquareFunction : Zap
@@ -63,7 +117,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
key={model.id}
onDragStart={(event) => beginCustomDrag(event, model.id)}
onClick={() => onAddCustomNode(model.id)}
title={`${CATEGORY_LABELS[model.category]} · ${model.ports.length} 个端口`}
title={`${catLabel(model.category)} · ${t('palette.portCount').replace('{count}', String(model.ports.length))}`}
>
<span className="palette-item-icon"><Icon size={16} /></span>
<span className="palette-item-name">{model.id}</span>
@@ -73,12 +127,42 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
</div>
</section>
)}
{groups.map(({ category, items }) => (
{creatingModel ? (
<div className="palette-custom-form">
<div className="palette-custom-form-row">
<label>{t('palette.customModelName')}</label>
<input
value={newModelName}
onChange={(e) => setNewModelName(e.target.value)}
placeholder="MyNode"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') submitNewModel(); if (e.key === 'Escape') setCreatingModel(false); }}
/>
</div>
<div className="palette-custom-form-row">
<label>{t('palette.customModelType')}</label>
<select value={newModelType} onChange={(e) => setNewModelType(e.target.value as NodeType)}>
{NODE_DEFINITIONS.filter((d) => d.type !== 'Root').map((d) => (
<option key={d.type} value={d.type}>{isZh ? d.labelZh : d.label} ({d.type})</option>
))}
</select>
</div>
<div className="palette-custom-form-actions">
<button className="primary-button compact" onClick={submitNewModel}>{t('palette.customModelCreate')}</button>
<button className="secondary-button compact" onClick={() => setCreatingModel(false)}>{t('modal.cancel')}</button>
</div>
</div>
) : (
<button className="palette-add-custom" onClick={() => setCreatingModel(true)}>
<Plus size={14} /> {t('palette.customModelAdd')}
</button>
)}
{Array.from(groupedDefinitions.entries()).map(([category, items]) => (
<section className="palette-group" key={category}>
<h3>{CATEGORY_LABELS[category]}</h3>
<h3>{catLabel(category)}</h3>
<div className="palette-items">
{items.map((item) => {
const Icon = item.icon
const Icon = item.icon || TreePine
return (
<button
className={`palette-item category-${category}`}
@@ -86,17 +170,17 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
key={item.type}
onDragStart={(event) => beginDrag(event, item.type)}
onClick={() => onAddNode(item.type)}
title={item.description}
title={`${nodeLabel(item)} — ${nodeDesc(item)}`}
>
<span className="palette-item-icon"><Icon size={16} /></span>
<span className="palette-item-name">{item.label}</span>
<span className="palette-item-name">{nodeLabel(item)}</span>
</button>
)
})}
</div>
</section>
))}
{!groups.length && !visibleModels.length && <div className="panel-empty">没有匹配节点</div>}
{!groupedDefinitions.size && !visibleModels.length && <div className="panel-empty">{t('palette.noMatch')}</div>}
</div>
</aside>
)

View File

@@ -0,0 +1,52 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import en from '../i18n/en'
import zh from '../i18n/zh'
export type Language = 'en' | 'zh'
const DICTS: Record<Language, Record<string, string>> = { en, zh }
interface I18nContextValue {
lang: Language
setLang: (lang: Language) => void
t: (key: string, fallback?: string) => string
}
const I18nContext = createContext<I18nContextValue | null>(null)
function readLanguage(): Language {
try {
const stored = localStorage.getItem('arborflow-lang')
if (stored === 'zh' || stored === 'en') return stored
} catch { /* localStorage unavailable */ }
return 'en'
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Language>(readLanguage)
const setLang = useCallback((next: Language) => {
setLangState(next)
try { localStorage.setItem('arborflow-lang', next) } catch { /* noop */ }
}, [])
useEffect(() => {
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
}, [lang])
const t = useCallback((key: string, fallback?: string): string => {
return DICTS[lang]?.[key] || fallback || key
}, [lang])
return (
<I18nContext.Provider value={{ lang, setLang, t }}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}

View File

@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
type Theme = 'dark' | 'light'
interface ThemeContextValue {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function readTheme(): Theme {
try {
const stored = localStorage.getItem('arborflow-theme')
if (stored === 'light' || stored === 'dark') return stored
} catch { /* localStorage unavailable */ }
return 'dark'
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(readTheme)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
try { localStorage.setItem('arborflow-theme', theme) } catch { /* noop */ }
}, [theme])
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

189
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,189 @@
const en: Record<string, string> = {
/* ── App Chrome ── */
'app.subtitle': 'Behavior Tree Studio',
/* ── Toolbar ── */
'toolbar.canvas': 'Canvas',
'toolbar.xml': 'XML',
'toolbar.autoLayout': 'Auto Layout',
'toolbar.viewSwitch': 'View Switch',
'toolbar.monitor': 'Monitor',
/* ── Header Actions ── */
'action.newProject': 'New Project',
'action.openProject': 'Open Project',
'action.saveProject': 'Save Project',
'action.undo': 'Undo',
'action.redo': 'Redo',
'action.importXml': 'Import XML',
'action.exportXml': 'Export XML',
'action.unsaved': 'Unsaved',
'action.settings': 'Settings',
/* ── Settings ── */
'settings.title': 'Settings',
'settings.theme': 'Theme',
'settings.language': 'Language',
'settings.dark': 'Dark',
'settings.light': 'Light',
'settings.langZh': '中文',
'settings.langEn': 'English',
/* ── Tree Menu ── */
'tree.heading': 'Behavior Trees',
'tree.main': 'MAIN',
/* ── Node Palette ── */
'palette.title': 'Nodes',
'palette.searchPlaceholder': 'Search nodes',
'palette.customNodes': 'XML Custom Nodes',
'palette.eyebrow': 'LIBRARY',
'palette.noMatch': 'No matching nodes',
'palette.clearSearch': 'Clear search',
'palette.portCount': '{count} port(s)',
'palette.customModelAdd': 'Add Custom Node',
'palette.customModelName': 'Node Name / ID',
'palette.customModelType': 'Base Type',
'palette.customModelCreate': 'Create',
/* ── Node Category Labels ── */
'cat.control': 'Control',
'cat.decorator': 'Decorator',
'cat.action': 'Action',
'cat.condition': 'Condition',
'cat.subtree': 'SubTree',
'cat.root': 'Root',
/* ── Node Type Names ── */
'node.Sequence': 'Sequence',
'node.Fallback': 'Fallback',
'node.Parallel': 'Parallel',
'node.ReactiveSequence': 'Reactive Sequence',
'node.ReactiveFallback': 'Reactive Fallback',
'node.Inverter': 'Inverter',
'node.RetryUntilSuccessful': 'Retry',
'node.Repeat': 'Repeat',
'node.ForceSuccess': 'Force Success',
'node.ForceFailure': 'Force Failure',
'node.Action': 'Action',
'node.Condition': 'Condition',
'node.SubTree': 'SubTree',
'node.AlwaysSuccess': 'Always Success',
'node.AlwaysFailure': 'Always Failure',
'node.Root': 'Root',
/* ── Node Descriptions ── */
'desc.Sequence': 'Execute children in order, stop on failure',
'desc.Fallback': 'Try children in order, stop on success',
'desc.Parallel': 'Execute multiple children in parallel',
'desc.ReactiveSequence': 'Re-evaluate from first child each tick',
'desc.ReactiveFallback': 'Reactive fallback control node',
'desc.Inverter': 'Invert success/failure of child',
'desc.RetryUntilSuccessful': 'Retry up to N times on failure',
'desc.Repeat': 'Repeat execution up to N cycles',
'desc.ForceSuccess': 'Force return success',
'desc.ForceFailure': 'Force return failure',
'desc.Action': 'Invoke a ROS action or business logic',
'desc.Condition': 'Check blackboard or environment condition',
'desc.SubTree': 'Call another behavior tree',
'desc.AlwaysSuccess': 'Always return success',
'desc.AlwaysFailure': 'Always return failure',
'desc.Root': 'Root node of the behavior tree',
/* ── Inspector ── */
'inspector.title': 'INSPECTOR',
'inspector.treeProps': 'Behavior Tree',
'inspector.nodeProps': 'Node Properties',
'inspector.displayName': 'Display Name',
'inspector.registrationName': 'Registration Name / ID',
'inspector.nodeType': 'Node Type',
'inspector.nodeId': 'Runtime Match ID',
'inspector.ports': 'Ports & Blackboard',
'inspector.addPort': 'Add Port',
'inspector.noPorts': 'No ports',
'inspector.portKey': 'Port name',
'inspector.portValue': 'Value or {blackboard}',
'inspector.portDirection': 'Direction',
'inspector.breakpoint': 'Breakpoint',
'inspector.breakpointDesc': 'Flag when execution reaches this node',
'inspector.enterSubtree': 'Enter Subtree',
'inspector.notes': 'Notes',
'inspector.deleteNode': 'Delete Node',
'inspector.structureCheck': 'Structure Check',
'inspector.projectName': 'Project Name',
'inspector.mainTreeId': 'Main Tree ID',
'inspector.nodeCount': 'nodes',
'inspector.edgeCount': 'edges',
'inspector.errorCount': 'errors',
/* ── Modal ── */
'modal.editNode': 'Edit Node',
'modal.save': 'Save',
'modal.cancel': 'Cancel',
'modal.delete': 'Delete',
/* ── Validation ── */
'validation.valid': 'Structure valid',
'validation.emptyCanvas': 'Canvas is empty, add a root node',
'validation.missingRoot': 'No root node — tree may contain cycles',
'validation.tooManyRoots': 'root nodes detected — connect into a single tree before exporting XML',
'validation.cannotAddChild': 'Cannot add more children',
'validation.singleParent': 'A node can only have one parent',
'validation.wouldCycle': 'This connection would create a cycle',
'validation.xmlEmpty': 'Canvas is empty, cannot export XML',
'validation.xmlMultiRoot': 'Must have exactly one root node',
'validation.xmlParseError': 'XML parse error',
'validation.xmlNoTree': 'No <BehaviorTree> element found',
'validation.tooManyChildren': 'allowed children',
/* ── Toast ── */
'toast.projectOpened': 'Project opened',
'toast.projectSaved': 'Project saved',
'toast.projectDownloaded': 'Project downloaded',
'toast.xmlImported': 'XML imported',
'toast.xmlExported': 'BehaviorTree.CPP XML exported',
'toast.xmlCopied': 'XML copied',
'toast.saveFailed': 'Save failed',
'toast.openFailed': 'Open project failed',
'toast.importFailed': 'XML import failed',
'toast.exportFailed': 'XML export failed',
'toast.xmlGenFailed': 'Cannot generate XML',
/* ── Confirm ── */
'confirm.unsavedNew': 'Unsaved changes. Create new project anyway?',
/* ── Status Bar ── */
'status.trees': 'trees',
'status.nodes': 'nodes',
'status.edges': 'edges',
'status.valid': 'Structure valid',
'status.errors': 'structure errors',
'status.unsaved': 'Unsaved',
'status.webPreview': 'Web Preview',
'status.desktop': 'Desktop',
/* ── XML View ── */
'xml.copyButton': 'Copy XML',
'xml.errorHeading': 'Cannot Generate XML',
/* ── Monitor ── */
'monitor.disconnected': 'Disconnected',
'monitor.connecting': 'Connecting',
'monitor.connected': 'Connected',
'monitor.error': 'Connection Error',
'monitor.eventStream': 'Event Stream',
'monitor.waiting': 'Waiting for node status',
'monitor.unmatched': 'Unmatched',
'monitor.endpoint': 'ROS WebSocket Address',
'monitor.topic': 'rosbridge Topic',
'monitor.autoReconnect': 'Auto Reconnect',
'monitor.disconnect': 'Disconnect',
'monitor.connect': 'Connect',
'monitor.messages': 'Messages',
'monitor.latency': 'Latency',
'monitor.clearRuntime': 'Clear Runtime Status',
'monitor.closeMonitor': 'Close Monitor',
'monitor.matched': 'Matched',
}
export default en

189
src/i18n/zh.ts Normal file
View File

@@ -0,0 +1,189 @@
const zh: Record<string, string> = {
/* ── App Chrome ── */
'app.subtitle': '行为树编辑器',
/* ── Toolbar ── */
'toolbar.canvas': '画布',
'toolbar.xml': 'XML',
'toolbar.autoLayout': '自动布局',
'toolbar.viewSwitch': '视图切换',
'toolbar.monitor': 'Monitor',
/* ── Header Actions ── */
'action.newProject': '新建工程',
'action.openProject': '打开工程',
'action.saveProject': '保存工程',
'action.undo': '撤销',
'action.redo': '重做',
'action.importXml': '导入 XML',
'action.exportXml': '导出 XML',
'action.unsaved': '未保存',
'action.settings': '设置',
/* ── Settings ── */
'settings.title': '设置',
'settings.theme': '主题',
'settings.language': '语言',
'settings.dark': '深色模式',
'settings.light': '浅色模式',
'settings.langZh': '中文',
'settings.langEn': 'English',
/* ── Tree Menu ── */
'tree.heading': '行为树',
'tree.main': 'MAIN',
/* ── Node Palette ── */
'palette.title': '节点',
'palette.searchPlaceholder': '搜索节点',
'palette.customNodes': 'XML 自定义节点',
'palette.eyebrow': '节点库',
'palette.noMatch': '没有匹配节点',
'palette.clearSearch': '清除搜索',
'palette.portCount': '{count} 个端口',
'palette.customModelAdd': '添加自定义节点',
'palette.customModelName': '节点名称 / ID',
'palette.customModelType': '基础类型',
'palette.customModelCreate': '创建',
/* ── Node Category Labels ── */
'cat.control': '控制',
'cat.decorator': '装饰',
'cat.action': '动作',
'cat.condition': '条件',
'cat.subtree': '子树',
'cat.root': '根',
/* ── Node Type Names — Bilingual Display ── */
'node.Sequence': '顺序执行',
'node.Fallback': '选择回退',
'node.Parallel': '并行执行',
'node.ReactiveSequence': '响应序列',
'node.ReactiveFallback': '响应回退',
'node.Inverter': '反转',
'node.RetryUntilSuccessful': '重试',
'node.Repeat': '重复',
'node.ForceSuccess': '强制成功',
'node.ForceFailure': '强制失败',
'node.Action': '动作',
'node.Condition': '条件',
'node.SubTree': '子树',
'node.AlwaysSuccess': '始终成功',
'node.AlwaysFailure': '始终失败',
'node.Root': '根节点',
/* ── Node Descriptions ── */
'desc.Sequence': '依次执行子节点,遇失败停止',
'desc.Fallback': '依次尝试子节点,遇成功停止',
'desc.Parallel': '并行执行多个子节点',
'desc.ReactiveSequence': '每次 tick 从头重新评估',
'desc.ReactiveFallback': '响应式回退控制节点',
'desc.Inverter': '反转子节点的成功/失败结果',
'desc.RetryUntilSuccessful': '失败后重试指定次数',
'desc.Repeat': '重复执行指定次数',
'desc.ForceSuccess': '强制返回成功状态',
'desc.ForceFailure': '强制返回失败状态',
'desc.Action': '调用 ROS 动作或业务逻辑',
'desc.Condition': '检查黑板变量或环境条件',
'desc.SubTree': '调用另一棵行为树',
'desc.AlwaysSuccess': '始终返回成功',
'desc.AlwaysFailure': '始终返回失败',
'desc.Root': '行为树的根节点',
/* ── Inspector ── */
'inspector.title': 'INSPECTOR',
'inspector.treeProps': '行为树',
'inspector.nodeProps': '节点属性',
'inspector.displayName': '显示名称',
'inspector.registrationName': '注册名称 / ID',
'inspector.nodeType': '节点类型',
'inspector.nodeId': '运行匹配 ID',
'inspector.ports': '端口与黑板',
'inspector.addPort': '添加端口',
'inspector.noPorts': '暂无端口',
'inspector.portKey': '端口名',
'inspector.portValue': '值或 {blackboard}',
'inspector.portDirection': '方向',
'inspector.breakpoint': '断点',
'inspector.breakpointDesc': '运行到此节点时标记',
'inspector.enterSubtree': '进入子树',
'inspector.notes': '备注',
'inspector.deleteNode': '删除节点',
'inspector.structureCheck': '结构检查',
'inspector.projectName': '工程名称',
'inspector.mainTreeId': '主树 ID',
'inspector.nodeCount': '节点',
'inspector.edgeCount': '连接',
'inspector.errorCount': '错误',
/* ── Modal ── */
'modal.editNode': '编辑节点',
'modal.save': '保存',
'modal.cancel': '取消',
'modal.delete': '删除',
/* ── Validation ── */
'validation.valid': '结构有效',
'validation.emptyCanvas': '画布为空,请添加根节点',
'validation.missingRoot': '没有根节点,树中可能存在环路',
'validation.tooManyRoots': '个根节点,导出 XML 前必须连接为一棵树',
'validation.cannotAddChild': '不能再添加子节点',
'validation.singleParent': '一个节点只能有一个父节点',
'validation.wouldCycle': '此连接会形成环路',
'validation.xmlEmpty': '画布为空,无法导出 XML',
'validation.xmlMultiRoot': '必须只有一个根节点',
'validation.xmlParseError': 'XML 解析失败',
'validation.xmlNoTree': '未找到 <BehaviorTree> 元素',
'validation.tooManyChildren': '个允许的子节点',
/* ── Toast ── */
'toast.projectOpened': '工程已打开',
'toast.projectSaved': '工程已保存',
'toast.projectDownloaded': '工程已下载',
'toast.xmlImported': 'XML 已导入',
'toast.xmlExported': 'BehaviorTree.CPP XML 已导出',
'toast.xmlCopied': 'XML 已复制',
'toast.saveFailed': '保存失败',
'toast.openFailed': '打开工程失败',
'toast.importFailed': '导入 XML 失败',
'toast.exportFailed': '导出 XML 失败',
'toast.xmlGenFailed': '无法生成 XML',
/* ── Confirm ── */
'confirm.unsavedNew': '当前工程有未保存更改,仍要新建吗?',
/* ── Status Bar ── */
'status.trees': '棵树',
'status.nodes': '节点',
'status.edges': '连接',
'status.valid': '结构有效',
'status.errors': '个结构错误',
'status.unsaved': '未保存',
'status.webPreview': 'Web Preview',
'status.desktop': 'Desktop',
/* ── XML View ── */
'xml.copyButton': '复制 XML',
'xml.errorHeading': '无法生成 XML',
/* ── Monitor ── */
'monitor.disconnected': '未连接',
'monitor.connecting': '连接中',
'monitor.connected': '已连接',
'monitor.error': '连接错误',
'monitor.eventStream': '事件流',
'monitor.waiting': '等待节点状态',
'monitor.unmatched': '未匹配',
'monitor.endpoint': 'ROS WebSocket 地址',
'monitor.topic': 'rosbridge Topic',
'monitor.autoReconnect': '自动重连',
'monitor.disconnect': '断开',
'monitor.connect': '连接',
'monitor.messages': '消息',
'monitor.latency': '延迟',
'monitor.clearRuntime': '清空运行状态',
'monitor.closeMonitor': '关闭监视器',
'monitor.matched': '匹配',
}
export default zh

View File

@@ -16,10 +16,13 @@ import type { BehaviorNode, CustomNodeModel, NodeCategory, NodeType } from '../t
export interface NodeDefinition {
type: NodeType
label: string
labelZh: string
description: string
descriptionZh: string
category: NodeCategory
icon: LucideIcon
defaultPorts?: Record<string, string>
defaultPortDirections?: Record<string, 'input' | 'output'>
}
export const CATEGORY_LABELS: Record<NodeCategory, string> = {
@@ -28,24 +31,26 @@ export const CATEGORY_LABELS: Record<NodeCategory, string> = {
action: '动作',
condition: '条件',
subtree: '子树',
root: '根',
}
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 },
{ type: 'Sequence', label: 'Sequence', labelZh: '顺序执行', description: 'Execute children in order, stop on failure', descriptionZh: '依次执行子节点,遇失败停止', category: 'control', icon: ListTree },
{ type: 'Fallback', label: 'Fallback', labelZh: '选择回退', description: 'Try children in order, stop on success', descriptionZh: '依次尝试子节点,遇成功停止', category: 'control', icon: GitBranch },
{ type: 'Parallel', label: 'Parallel', labelZh: '并行执行', description: 'Execute multiple children in parallel', descriptionZh: '并行执行多个子节点', category: 'control', icon: Split, defaultPorts: { success_count: '1', failure_count: '1' }, defaultPortDirections: { success_count: 'input', failure_count: 'input' } },
{ type: 'ReactiveSequence', label: 'Reactive Sequence', labelZh: '响应序列', description: 'Re-evaluate from first child each tick', descriptionZh: '每次 tick 从头重新评估', category: 'control', icon: RotateCcw },
{ type: 'ReactiveFallback', label: 'Reactive Fallback', labelZh: '响应回退', description: 'Reactive fallback control node', descriptionZh: '响应式回退控制节点', category: 'control', icon: RotateCcw },
{ type: 'Inverter', label: 'Inverter', labelZh: '反转', description: 'Invert success/failure of child', descriptionZh: '反转子节点的成功/失败结果', category: 'decorator', icon: Repeat2 },
{ type: 'RetryUntilSuccessful', label: 'Retry', labelZh: '重试', description: 'Retry up to N times on failure', descriptionZh: '失败后重试指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_attempts: '3' }, defaultPortDirections: { num_attempts: 'input' } },
{ type: 'Repeat', label: 'Repeat', labelZh: '重复', description: 'Repeat execution up to N cycles', descriptionZh: '重复执行指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_cycles: '3' }, defaultPortDirections: { num_cycles: 'input' } },
{ type: 'ForceSuccess', label: 'Force Success', labelZh: '强制成功', description: 'Force return success', descriptionZh: '强制返回成功状态', category: 'decorator', icon: ShieldCheck },
{ type: 'ForceFailure', label: 'Force Failure', labelZh: '强制失败', description: 'Force return failure', descriptionZh: '强制返回失败状态', category: 'decorator', icon: ShieldX },
{ type: 'Action', label: 'Action', labelZh: '动作', description: 'Invoke a ROS action or business logic', descriptionZh: '调用 ROS 动作或业务逻辑', category: 'action', icon: Zap },
{ type: 'Condition', label: 'Condition', labelZh: '条件', description: 'Check blackboard or environment condition', descriptionZh: '检查黑板变量或环境条件', category: 'condition', icon: SquareFunction },
{ type: 'SubTree', label: 'SubTree', labelZh: '子树', description: 'Call another behavior tree', descriptionZh: '调用另一棵行为树', category: 'subtree', icon: TreePine },
{ type: 'AlwaysSuccess', label: 'Always Success', labelZh: '始终成功', description: 'Always return success', descriptionZh: '始终返回成功', category: 'action', icon: ShieldCheck },
{ type: 'AlwaysFailure', label: 'Always Failure', labelZh: '始终失败', description: 'Always return failure', descriptionZh: '始终返回失败', category: 'action', icon: ShieldX },
{ type: 'Root', label: 'Root', labelZh: '根节点', description: 'Root node of the behavior tree', descriptionZh: '行为树的根节点', category: 'control', icon: TreePine },
]
export const NODE_DEFINITION_MAP = new Map(NODE_DEFINITIONS.map((definition) => [definition.type, definition]))
@@ -53,7 +58,7 @@ export const NODE_DEFINITION_MAP = new Map(NODE_DEFINITIONS.map((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
const customName = type === 'Root' ? 'Root' : type === 'Action' ? 'MyAction' : type === 'Condition' ? 'MyCondition' : type === 'SubTree' ? 'SubTreeID' : type
return {
id,
type: 'behavior',
@@ -64,6 +69,7 @@ export function createNode(type: NodeType, position = { x: 80, y: 80 }): Behavio
category: definition.category,
registrationName: customName,
ports: { ...(definition.defaultPorts || {}) },
portDirections: definition.defaultPortDirections ? { ...definition.defaultPortDirections } : undefined,
notes: '',
breakpoint: false,
},
@@ -91,3 +97,7 @@ export function maxChildren(type: NodeType) {
if (['Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure'].includes(type)) return 1
return canHaveChildren(type) ? Number.POSITIVE_INFINITY : 0
}
export function isRoot(type: NodeType) {
return type === 'Root'
}

View File

@@ -8,31 +8,36 @@ export function createDemoDocument(): BehaviorTreeDocument {
mainTreeId: 'MainTree',
nodes: [
{
id: 'root_sequence', type: 'behavior', position: { x: 475, y: 60 },
id: 'root_node', type: 'behavior', position: { x: 475, y: 20 },
data: { label: 'Root', nodeType: 'Root', category: 'root', registrationName: 'Root', ports: {}, notes: '', breakpoint: false },
},
{
id: 'root_sequence', type: 'behavior', position: { x: 475, y: 140 },
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: 'battery_check', type: 'behavior', position: { x: 170, y: 320 },
data: { label: 'Battery OK?', nodeType: 'Condition', category: 'condition', registrationName: 'BatteryOK', ports: { min_level: '{battery_threshold}' }, portDirections: { min_level: 'input' }, notes: '', breakpoint: false },
},
{
id: 'navigation_fallback', type: 'behavior', position: { x: 475, y: 240 },
id: 'navigation_fallback', type: 'behavior', position: { x: 475, y: 320 },
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: 'report_action', type: 'behavior', position: { x: 785, y: 320 },
data: { label: 'Report Result', nodeType: 'Action', category: 'action', registrationName: 'ReportResult', ports: { topic: '/mission/result' }, portDirections: { topic: 'input' }, 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: 'at_goal', type: 'behavior', position: { x: 340, y: 520 },
data: { label: 'Already At Goal?', nodeType: 'Condition', category: 'condition', registrationName: 'AtGoal', ports: { target: '{goal}' }, portDirections: { target: 'input' }, 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 },
id: 'navigate_action', type: 'behavior', position: { x: 610, y: 520 },
data: { label: 'Navigate To Pose', nodeType: 'Action', category: 'action', registrationName: 'NavigateToPose', ports: { goal: '{goal}', server_timeout: '5000' }, portDirections: { goal: 'input', server_timeout: 'input' }, notes: '调用 Nav2 action server', breakpoint: true },
},
],
edges: [
{ id: 'e-root-seq', source: 'root_node', target: 'root_sequence', type: 'smoothstep' },
{ 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' },

View File

@@ -1,10 +1,33 @@
import { createNode } from './catalog'
import { syncActiveTree, switchDocumentTree } from './trees'
import type { BehaviorTreeDocument } from '../types'
import type { BehaviorEdge, BehaviorNode, BehaviorTreeDocument } from '../types'
export function serializeProject(document: BehaviorTreeDocument) {
return JSON.stringify(syncActiveTree(document), null, 2) + '\n'
}
/** Ensure the active tree has a Root node at the top. If not, add one. */
export function ensureRootNode(nodes: BehaviorNode[], edges: BehaviorEdge[]): { nodes: BehaviorNode[]; edges: BehaviorEdge[] } {
const hasRoot = nodes.some((n) => n.data.nodeType === 'Root')
if (hasRoot) return { nodes, edges }
const incoming = new Map<string, number>()
edges.forEach((e) => incoming.set(e.target, (incoming.get(e.target) || 0) + 1))
const roots = nodes.filter((n) => (incoming.get(n.id) || 0) === 0)
if (roots.length === 0) return { nodes, edges }
const rootNode = createNode('Root')
rootNode.data = { ...rootNode.data, label: 'Root', registrationName: 'Root' }
let edgeIndex = edges.length
const newEdges: BehaviorEdge[] = [...edges]
roots.forEach((r) => {
newEdges.push({ id: `edge_root_${edgeIndex++}`, source: rootNode.id, target: r.id, type: 'smoothstep' })
})
return { nodes: [rootNode, ...nodes], edges: newEdges }
}
export function parseProject(content: string): BehaviorTreeDocument {
const value: unknown = JSON.parse(content)
if (!value || typeof value !== 'object') throw new Error('工程文件内容无效')
@@ -16,6 +39,9 @@ export function parseProject(content: string): BehaviorTreeDocument {
throw new Error('工程文件缺少节点或连接数据')
}
const document = candidate as BehaviorTreeDocument
const { nodes, edges } = ensureRootNode(document.nodes, document.edges)
document.nodes = nodes
document.edges = edges
const requestedTree = document.activeTreeId || document.mainTreeId
return document.trees?.length ? switchDocumentTree(document, requestedTree) : document
}

View File

@@ -51,10 +51,23 @@ function renderTree(graph: BehaviorTreeGraph) {
})
children.forEach((items) => items.sort((a, b) => a.position.x - b.position.x))
// Skip Root nodes — their children become the XML root(s)
let entryRoot = roots[0]
if (entryRoot.data.nodeType === 'Root') {
const rootChildren = children.get(entryRoot.id) || []
if (rootChildren.length === 0) throw new Error(`${graph.id} 的根节点没有子节点`)
if (rootChildren.length > 1) throw new Error(`${graph.id} 的 Root 节点下有多个子节点,只能有一个`)
entryRoot = rootChildren[0]
}
const visited = new Set<string>()
const renderNode = (node: BehaviorNode, depth: number): string => {
if (visited.has(node.id)) throw new Error(`${graph.id} 中存在重复引用或环路`)
visited.add(node.id)
if (node.data.nodeType === 'Root') {
const rootChildren = children.get(node.id) || []
return rootChildren.map((child) => renderNode(child, depth)).join('\n')
}
const indent = ' '.repeat(depth)
const tag = nodeTag(node)
const attributes: Array<[string, string]> = []
@@ -64,13 +77,13 @@ function renderTree(graph: BehaviorTreeGraph) {
if (key.trim()) attributes.push([key.trim(), value])
})
const attrs = attributes.map(([key, value]) => ` ${key}="${escapeXml(value)}"`).join('')
const childNodes = children.get(node.id) || []
const childNodes = (children.get(node.id) || []).filter((c) => c.data.nodeType !== 'Root')
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}>`
}
return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(roots[0], 2)}\n </BehaviorTree>`
return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(entryRoot, 2)}\n </BehaviorTree>`
}
function renderNodeModels(models: CustomNodeModel[]) {
@@ -187,6 +200,26 @@ function parseBehaviorTree(element: Element, models: Map<string, CustomNodeModel
Array.from(nodeElement.children).forEach((child) => visit(child, node.id))
}
visit(treeRoot)
// Ensure a Root node sits at the top of every imported tree
const incoming = new Map(nodes.map((n) => [n.id, 0]))
edges.forEach((e) => incoming.set(e.target, (incoming.get(e.target) || 0) + 1))
const roots = nodes.filter((n) => (incoming.get(n.id) || 0) === 0)
const hasRootNode = roots.some((n) => n.data.nodeType === 'Root')
if (!hasRootNode && roots.length > 0) {
const rootNode = createNode('Root')
rootNode.data = {
...rootNode.data,
label: treeId,
registrationName: treeId,
}
nodes.push(rootNode)
roots.forEach((r) => {
edges.push({ id: `edge_${treeId}_${edgeIndex++}`, source: rootNode.id, target: r.id, type: 'smoothstep' })
})
}
return layoutGraph({ id: treeId, nodes, edges })
}

View File

@@ -3,9 +3,15 @@ import ReactDOM from 'react-dom/client'
import '@xyflow/react/dist/style.css'
import './styles.css'
import App from './App'
import { ThemeProvider } from './context/ThemeContext'
import { I18nProvider } from './context/I18nContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<I18nProvider>
<App />
</I18nProvider>
</ThemeProvider>
</React.StrictMode>,
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
import type { Edge, Node } from '@xyflow/react'
export type NodeCategory = 'control' | 'decorator' | 'action' | 'condition' | 'subtree'
export type NodeCategory = 'control' | 'decorator' | 'action' | 'condition' | 'subtree' | 'root'
export type PortDirection = 'input' | 'output' | 'inout'
export type NodeType =
| 'Sequence'
@@ -18,6 +20,7 @@ export type NodeType =
| 'SubTree'
| 'AlwaysSuccess'
| 'AlwaysFailure'
| 'Root'
export type RuntimeStatus = 'IDLE' | 'RUNNING' | 'SUCCESS' | 'FAILURE' | 'SKIPPED'
@@ -27,6 +30,7 @@ export type BehaviorNodeData = {
category: NodeCategory
registrationName: string
ports: Record<string, string>
portDirections?: Record<string, PortDirection>
notes: string
breakpoint: boolean
xmlTag?: string

144
test_rmul_2025.xml Normal file
View File

@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<root BTCPP_format="4">
<BehaviorTree ID="check_game_start">
<Fallback>
<IsGameStatus name="IsGameStart"
max_remain_time="420"
min_remain_time="0"
expected_game_progress="4"
key_port="{@referee_gameStatus}"/>
<ForceFailure>
<Sequence>
<PublishSpinSpeed spin_speed="0.0"
duration="0.0"
topic_name="cmd_spin"/>
<PublishTwist v_y="0.0"
v_yaw="0.0"
v_x="0.0"
duration="0.0"
topic_name="cmd_vel"/>
</Sequence>
</ForceFailure>
</Fallback>
</BehaviorTree>
<BehaviorTree ID="check_robot_status">
<Fallback>
<IsStatusOK ammo_min="0"
heat_max="350"
hp_min="200"
key_port="{@referee_robotStatus}"/>
<ForceFailure>
<Fallback>
<IsRfidDetected friendly_supply_zone_exchange="false"
friendly_supply_zone_non_exchange="true"
center_gain_point="false"
friendly_fortress_gain_point="false"
key_port="{@referee_rfidStatus}"/>
<PubNav2Goal goal="0;0;0"
topic_name="goal_pose"/>
</Fallback>
</ForceFailure>
</Fallback>
</BehaviorTree>
<BehaviorTree ID="rmul_2025">
<KeepRunningUntilFailure>
<ForceSuccess>
<ReactiveSequence>
<SubTree ID="check_game_start"/>
<PublishSpinSpeed spin_speed="7.0"
duration="0.0"
topic_name="cmd_spin"/>
<SubTree ID="check_robot_status"/>
<PubNav2Goal goal="4.65;-3.5;0"
topic_name="goal_pose"/>
</ReactiveSequence>
</ForceSuccess>
</KeepRunningUntilFailure>
</BehaviorTree>
<!-- Description of Node Models (used by Groot) -->
<TreeNodesModel>
<Condition ID="IsGameStatus">
<input_port name="max_remain_time"
default="420"
type="int">Maximum remaining time (s)</input_port>
<input_port name="min_remain_time"
default="0"
type="int">Minimum remaining time (s)</input_port>
<input_port name="expected_game_progress"
default="4"
type="int">Expected game progress stage</input_port>
<input_port name="key_port"
default="{@referee_gameStatus}"
type="pb_rm_interfaces::msg::GameStatus_&lt;std::allocator&lt;void&gt; &gt;">GameStatus port on blackboard</input_port>
</Condition>
<Condition ID="IsRfidDetected">
<input_port name="friendly_supply_zone_exchange"
default="false"
type="bool">己方与兑换区重叠的补给区</input_port>
<input_port name="friendly_supply_zone_non_exchange"
default="false"
type="bool">己方与兑换区不重叠的补给区 / RMUL 补给区</input_port>
<input_port name="center_gain_point"
default="false"
type="bool">中心增益点(仅 RMUL 适用)</input_port>
<input_port name="friendly_fortress_gain_point"
default="false"
type="bool">己方堡垒增益点</input_port>
<input_port name="key_port"
default="{@referee_rfidStatus}"
type="pb_rm_interfaces::msg::RfidStatus_&lt;std::allocator&lt;void&gt; &gt;">RfidStatus port on blackboard</input_port>
</Condition>
<Condition ID="IsStatusOK">
<input_port name="ammo_min"
default="0"
type="int">Lower then minimum ammo will return FAILURE</input_port>
<input_port name="heat_max"
default="350"
type="int">Maximum heat. NOTE: Sentry heat limit is 400</input_port>
<input_port name="hp_min"
default="300"
type="int">Minimum HP. NOTE: Sentry init/max HP is 400</input_port>
<input_port name="key_port"
default="{@referee_robotStatus}"
type="pb_rm_interfaces::msg::RobotStatus_&lt;std::allocator&lt;void&gt; &gt;">RobotStatus port on blackboard</input_port>
</Condition>
<Condition ID="PubNav2Goal">
<input_port name="goal"
default="0;0;0"
type="geometry_msgs::msg::PoseStamped_&lt;std::allocator&lt;void&gt; &gt;">Expected goal pose that send to nav2. Fill with format `x;y;yaw`</input_port>
<input_port name="topic_name"
default="__default__placeholder__"
type="std::string">Topic name</input_port>
</Condition>
<Action ID="PublishSpinSpeed">
<input_port name="spin_speed"
default="0.000000"
type="double">Angular Z velocity (rad/s)</input_port>
<input_port name="duration"
type="std::chrono::milliseconds">Publish then sleep duration in milliseconds</input_port>
<input_port name="topic_name"
default="__default__placeholder__"
type="std::string">Topic name</input_port>
</Action>
<Action ID="PublishTwist">
<input_port name="v_y"
default="0.000000"
type="double">Linear Y velocity (m/s)</input_port>
<input_port name="v_yaw"
default="0.000000"
type="double">Angular Z velocity (rad/s)</input_port>
<input_port name="v_x"
default="0.000000"
type="double">Linear X velocity (m/s)</input_port>
<input_port name="duration"
type="std::chrono::milliseconds">Publish then sleep duration in milliseconds</input_port>
<input_port name="topic_name"
default="__default__placeholder__"
type="std::string">Topic name</input_port>
</Action>
</TreeNodesModel>
</root>