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

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules/
dist/
release/
.vite/
.playwright-cli/
output/playwright/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
*.log
*.tmp
*.bak
.DS_Store
Thumbs.db
.idea/
.vscode/
!.vscode/extensions.json
*.arborflow.autosave

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArborFlow Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
# ArborFlow
ArborFlow is a cross-platform visual behavior tree editor for BehaviorTree.CPP. It runs as an Electron desktop app on Windows, Linux, and macOS.
## Features
- Drag-and-drop behavior tree canvas with cycle and child-count validation
- BehaviorTree.CPP control, decorator, action, condition, and subtree nodes
- Editable ports and blackboard expressions such as `{goal}`
- Automatic tree layout, minimap, zoom, undo, and redo
- ArborFlow project files (`.arborflow`) and BehaviorTree.CPP v4 XML import/export
- Real-time node monitoring over a direct WebSocket or `rosbridge_server`
- Runtime states: `IDLE`, `RUNNING`, `SUCCESS`, `FAILURE`, and `SKIPPED`
## Development
Requirements: Node.js 20 or newer and npm.
```bash
npm install
npm run dev
```
Run a local status server in another terminal to exercise the monitor without ROS:
```bash
npm run mock:monitor
```
Connect ArborFlow to `ws://127.0.0.1:1667` and the demo tree will cycle through live states.
Build the web assets and type-check the project:
```bash
npm run build
```
Create desktop packages on the current operating system:
```bash
npm run dist
```
Platform-specific targets are available as `npm run dist:win`, `npm run dist:linux`, and `npm run dist:mac`. Electron applications should normally be packaged on the matching host OS, especially for macOS signing.
## Real-time Monitor
Open **Monitor** in the application toolbar and enter either a direct WebSocket endpoint or a `rosbridge_server` endpoint. Node references are matched in this order:
1. ArborFlow node ID, visible in the Inspector
2. Exact display name or registration name
3. Last segment of a slash-separated path
### Direct WebSocket protocol
ArborFlow accepts one JSON object per WebSocket message. A single status event can use any of `nodeId`, `uid`, `node`, `name`, or `path` as the node reference:
```json
{"type":"status","nodeId":"navigate_action","status":"RUNNING","timestamp":1784635200000}
```
Numeric BehaviorTree.CPP status values are supported: `0=IDLE`, `1=RUNNING`, `2=SUCCESS`, `3=FAILURE`, and `4=SKIPPED`.
A full snapshot can update several nodes at once:
```json
{
"type": "snapshot",
"timestamp": 1784635200000,
"nodes": {
"BatteryOK": "SUCCESS",
"NavigateToPose": "RUNNING",
"ReportResult": "IDLE"
}
}
```
The client sends `{"type":"subscribe","client":"ArborFlow","protocol":1}` after connecting and a ping every five seconds. A server may return the same timestamp using `{"type":"pong","timestamp":1784635200000}` to report round-trip latency.
### ROS 2 with rosbridge
Start rosbridge and use its WebSocket address, usually `ws://ROBOT_IP:9090`:
```bash
ros2 launch rosbridge_server rosbridge_websocket_launch.xml
```
The default topic is `/arborflow/status` with type `std_msgs/msg/String`. Publish the direct-protocol JSON as the string payload:
```bash
ros2 topic pub --once /arborflow/status std_msgs/msg/String \
"{data: '{\"node\":\"NavigateToPose\",\"status\":\"RUNNING\"}'}"
```
ArborFlow sends the rosbridge subscribe request automatically. The topic can be changed in the monitor panel.
## Project format
`.arborflow` files retain visual positions, editor metadata, node ports, and stable monitor IDs. XML exports contain only BehaviorTree.CPP data. Runtime monitor state is transient and is never saved into either format.
## License
MIT

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

118
electron/main.cjs Normal file
View File

@@ -0,0 +1,118 @@
const { app, BrowserWindow, dialog, ipcMain, Menu } = require('electron')
const fs = require('node:fs/promises')
const path = require('node:path')
let mainWindow
function sendCommand(command) {
mainWindow?.webContents.send('menu:command', command)
}
function createMenu() {
const isMac = process.platform === 'darwin'
const template = [
...(isMac ? [{ role: 'appMenu' }] : []),
{
label: '文件',
submenu: [
{ label: '新建', accelerator: 'CmdOrCtrl+N', click: () => sendCommand('new') },
{ label: '打开工程...', accelerator: 'CmdOrCtrl+O', click: () => sendCommand('open') },
{ type: 'separator' },
{ label: '保存', accelerator: 'CmdOrCtrl+S', click: () => sendCommand('save') },
{ label: '另存为...', accelerator: 'CmdOrCtrl+Shift+S', click: () => sendCommand('save-as') },
{ type: 'separator' },
{ label: '导入 XML...', click: () => sendCommand('import-xml') },
{ label: '导出 XML...', accelerator: 'CmdOrCtrl+E', click: () => sendCommand('export-xml') },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit', label: '退出' },
],
},
{
label: '编辑',
submenu: [
{ label: '撤销', accelerator: 'CmdOrCtrl+Z', click: () => sendCommand('undo') },
{ label: '重做', accelerator: 'CmdOrCtrl+Shift+Z', click: () => sendCommand('redo') },
{ type: 'separator' },
{ role: 'cut', label: '剪切' },
{ role: 'copy', label: '复制' },
{ role: 'paste', label: '粘贴' },
{ role: 'selectAll', label: '全选' },
],
},
{
label: '视图',
submenu: [
{ label: '自动布局', accelerator: 'CmdOrCtrl+L', click: () => sendCommand('layout') },
{ label: '适应画布', accelerator: 'F', click: () => sendCommand('fit') },
{ type: 'separator' },
{ role: 'zoomIn', label: '放大' },
{ role: 'zoomOut', label: '缩小' },
{ role: 'resetZoom', label: '重置缩放' },
{ role: 'togglefullscreen', label: '全屏' },
...(!app.isPackaged ? [{ role: 'toggleDevTools', label: '开发者工具' }] : []),
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1500,
height: 940,
minWidth: 1050,
minHeight: 680,
backgroundColor: '#111418',
icon: path.join(__dirname, '..', 'build', 'icon.png'),
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
if (app.isPackaged) {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
} else {
mainWindow.loadURL('http://127.0.0.1:5173')
}
}
ipcMain.handle('file:open-text', async (_event, options = {}) => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: options.filters || [{ name: 'All files', extensions: ['*'] }],
})
if (result.canceled || !result.filePaths[0]) return null
const filePath = result.filePaths[0]
return { path: filePath, content: await fs.readFile(filePath, 'utf8') }
})
ipcMain.handle('file:save-text', async (_event, options = {}) => {
let filePath = options.path
if (!filePath || options.prompt) {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: options.defaultPath,
filters: options.filters || [{ name: 'All files', extensions: ['*'] }],
})
if (result.canceled || !result.filePath) return null
filePath = result.filePath
}
await fs.writeFile(filePath, options.content || '', 'utf8')
return { path: filePath }
})
app.whenReady().then(() => {
createMenu()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

12
electron/preload.cjs Normal file
View File

@@ -0,0 +1,12 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('arborflow', {
platform: process.platform,
openText: (options) => ipcRenderer.invoke('file:open-text', options),
saveText: (options) => ipcRenderer.invoke('file:save-text', options),
onMenuCommand: (callback) => {
const listener = (_event, command) => callback(command)
ipcRenderer.on('menu:command', listener)
return () => ipcRenderer.removeListener('menu:command', listener)
},
})

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<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" />
<title>ArborFlow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7620
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

65
package.json Normal file
View File

@@ -0,0 +1,65 @@
{
"name": "arborflow",
"version": "0.1.0",
"private": true,
"description": "A cross-platform behavior tree editor for BehaviorTree.CPP",
"main": "electron/main.cjs",
"author": "ArborFlow Contributors",
"license": "MIT",
"scripts": {
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1",
"dist": "npm run build && electron-builder",
"dist:linux": "npm run build && electron-builder --linux",
"dist:mac": "npm run build && electron-builder --mac",
"dist:win": "npm run build && electron-builder --win",
"mock:monitor": "node scripts/mock-monitor.cjs",
"icon:render": "electron scripts/render-icon.cjs"
},
"dependencies": {
"@xyflow/react": "^12.8.4",
"@dagrejs/dagre": "^1.1.5",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"electron": "^33.3.1",
"electron-builder": "^25.1.8",
"typescript": "~5.7.2",
"vite": "^6.0.5",
"wait-on": "^8.0.1",
"ws": "^8.18.0"
},
"build": {
"appId": "dev.arborflow.editor",
"productName": "ArborFlow",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"build/icon.png",
"package.json"
],
"icon": "build/icon.png",
"mac": {
"category": "public.app-category.developer-tools",
"target": ["dmg", "zip"]
},
"linux": {
"category": "Development",
"target": ["AppImage", "deb"]
},
"win": {
"target": ["nsis", "portable"]
}
}
}

8
public/arborflow.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#17211d"/>
<path d="M32 11v12M18 29h28M18 29v11M32 29v11M46 29v11" fill="none" stroke="#63ddb0" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="10" r="5" fill="#5a92f7"/>
<circle cx="18" cy="47" r="7" fill="#eeb84a"/>
<circle cx="32" cy="47" r="7" fill="#29c58b"/>
<circle cx="46" cy="47" r="7" fill="#ee6961"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

32
scripts/mock-monitor.cjs Normal file
View File

@@ -0,0 +1,32 @@
const { WebSocketServer } = require('ws')
const port = Number(process.env.ARBORFLOW_MOCK_PORT || 1667)
const server = new WebSocketServer({ port })
const steps = [
{ BatteryOK: 'RUNNING', NavigateToPose: 'IDLE', ReportResult: 'IDLE' },
{ BatteryOK: 'SUCCESS', NavigateToPose: 'RUNNING', ReportResult: 'IDLE' },
{ BatteryOK: 'SUCCESS', NavigateToPose: 'SUCCESS', ReportResult: 'RUNNING' },
{ BatteryOK: 'SUCCESS', NavigateToPose: 'SUCCESS', ReportResult: 'SUCCESS' },
]
server.on('connection', (socket) => {
let step = 0
const publish = () => {
socket.send(JSON.stringify({ type: 'snapshot', timestamp: Date.now(), nodes: steps[step] }))
step = (step + 1) % steps.length
}
const timer = setInterval(publish, 1200)
publish()
socket.on('message', (raw) => {
try {
const message = JSON.parse(String(raw))
if (message.type === 'ping') socket.send(JSON.stringify({ type: 'pong', timestamp: message.timestamp }))
} catch {
// Ignore non-JSON messages in the development mock.
}
})
socket.on('close', () => clearInterval(timer))
})
console.log(`ArborFlow mock monitor listening on ws://127.0.0.1:${port}`)

20
scripts/render-icon.cjs Normal file
View File

@@ -0,0 +1,20 @@
const { app, BrowserWindow } = require('electron')
const fs = require('node:fs/promises')
const path = require('node:path')
app.whenReady().then(async () => {
const svg = await fs.readFile(path.join(__dirname, '..', 'public', 'arborflow.svg'), 'utf8')
const window = new BrowserWindow({
width: 512,
height: 512,
show: false,
transparent: true,
frame: false,
webPreferences: { offscreen: true },
})
await window.loadURL(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`)
const image = await window.webContents.capturePage({ x: 0, y: 0, width: 512, height: 512 })
await fs.writeFile(path.join(__dirname, '..', 'build', 'icon.png'), image.toPNG())
window.destroy()
app.quit()
})

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
}

20
tsconfig.app.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: {
port: 5173,
strictPort: true,
},
})