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