botserver/ui/suite/designer.html
Rodrigo Rodriguez (Pragmatismo) 36d5f3838c Add Suite user manual and HTMX architecture documentation
- Add comprehensive user manual covering all Suite applications
- Document HTMX architecture patterns used throughout the UI
- Complete designer.html JavaScript implementation
- Complete sources/index.html with remaining UI and event handlers
- Update SUMMARY.md with new documentation entries
2025-11-30 19:50:47 -03:00

2131 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dialog Designer - General Bots</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/ws.js"></script>
<link rel="stylesheet" href="css/app.css">
<style>
:root {
--bg-primary: #1e1e2e;
--bg-secondary: #181825;
--bg-tertiary: #313244;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--accent-blue: #89b4fa;
--accent-green: #a6e3a1;
--accent-yellow: #f9e2af;
--accent-red: #f38ba8;
--accent-purple: #cba6f7;
--accent-teal: #94e2d5;
--border-color: #45475a;
--node-talk: #89b4fa;
--node-hear: #a6e3a1;
--node-set: #f9e2af;
--node-if: #cba6f7;
--node-for: #f38ba8;
--node-call: #94e2d5;
--node-send: #fab387;
--grid-size: 20px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.designer-container {
display: grid;
grid-template-columns: 240px 1fr 280px;
grid-template-rows: 48px 1fr 32px;
height: 100vh;
}
/* Header */
.designer-header {
grid-column: 1 / -1;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
}
.designer-logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--accent-blue);
}
.designer-logo svg {
width: 24px;
height: 24px;
}
.designer-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.toolbar-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
transition: all 0.15s;
}
.toolbar-btn:hover {
background: var(--border-color);
}
.toolbar-btn.primary {
background: var(--accent-blue);
color: var(--bg-primary);
border-color: var(--accent-blue);
}
.toolbar-btn.primary:hover {
background: #7aa8f0;
}
.toolbar-btn svg {
width: 16px;
height: 16px;
}
.toolbar-separator {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 8px;
}
/* Toolbox Panel */
.toolbox-panel {
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
padding: 12px;
}
.toolbox-section {
margin-bottom: 16px;
}
.toolbox-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
padding: 0 4px;
}
.toolbox-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
cursor: grab;
transition: all 0.15s;
margin-bottom: 4px;
border: 1px solid transparent;
}
.toolbox-item:hover {
background: var(--bg-tertiary);
}
.toolbox-item:active {
cursor: grabbing;
}
.toolbox-item.dragging {
opacity: 0.5;
}
.toolbox-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: var(--bg-primary);
}
.toolbox-icon.talk { background: var(--node-talk); }
.toolbox-icon.hear { background: var(--node-hear); }
.toolbox-icon.set { background: var(--node-set); }
.toolbox-icon.if { background: var(--node-if); }
.toolbox-icon.for { background: var(--node-for); }
.toolbox-icon.call { background: var(--node-call); }
.toolbox-icon.send { background: var(--node-send); }
.toolbox-icon.get { background: #74c7ec; }
.toolbox-icon.wait { background: #f5c2e7; }
.toolbox-icon.switch { background: #b4befe; }
.toolbox-info {
flex: 1;
min-width: 0;
}
.toolbox-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.toolbox-desc {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Canvas Area */
.canvas-container {
position: relative;
overflow: hidden;
background: var(--bg-primary);
}
.canvas-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border-color) 1px, transparent 1px),
linear-gradient(90deg, var(--border-color) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
opacity: 0.3;
}
.canvas {
position: absolute;
inset: 0;
overflow: auto;
}
.canvas-inner {
position: relative;
width: 3000px;
height: 2000px;
min-width: 100%;
min-height: 100%;
}
/* Nodes */
.node {
position: absolute;
min-width: 180px;
max-width: 280px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 8px;
cursor: move;
user-select: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: box-shadow 0.15s, border-color 0.15s;
}
.node:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.node.selected {
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px rgba(137, 180, 250, 0.3);
}
.node-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px 6px 0 0;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.node-header.talk { background: var(--node-talk); color: var(--bg-primary); }
.node-header.hear { background: var(--node-hear); color: var(--bg-primary); }
.node-header.set { background: var(--node-set); color: var(--bg-primary); }
.node-header.if { background: var(--node-if); color: var(--bg-primary); }
.node-header.for { background: var(--node-for); color: var(--bg-primary); }
.node-header.call { background: var(--node-call); color: var(--bg-primary); }
.node-header.send { background: var(--node-send); color: var(--bg-primary); }
.node-header.get { background: #74c7ec; color: var(--bg-primary); }
.node-header.wait { background: #f5c2e7; color: var(--bg-primary); }
.node-header.switch { background: #b4befe; color: var(--bg-primary); }
.node-header svg {
width: 14px;
height: 14px;
}
.node-body {
padding: 12px;
}
.node-field {
margin-bottom: 8px;
}
.node-field:last-child {
margin-bottom: 0;
}
.node-field-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 4px;
}
.node-field-input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
}
.node-field-input:focus {
outline: none;
border-color: var(--accent-blue);
}
.node-field-select {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
/* Connection Ports */
.node-port {
position: absolute;
width: 12px;
height: 12px;
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
border-radius: 50%;
cursor: crosshair;
transition: all 0.15s;
}
.node-port:hover {
background: var(--accent-blue);
border-color: var(--accent-blue);
transform: scale(1.3);
}
.node-port.input {
left: -6px;
top: 50%;
transform: translateY(-50%);
}
.node-port.output {
right: -6px;
top: 50%;
transform: translateY(-50%);
}
.node-port.output-true {
right: -6px;
top: 35%;
background: var(--accent-green);
border-color: var(--accent-green);
}
.node-port.output-false {
right: -6px;
top: 65%;
background: var(--accent-red);
border-color: var(--accent-red);
}
/* Connections SVG */
.connections-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
}
.connection {
fill: none;
stroke: var(--border-color);
stroke-width: 2;
pointer-events: stroke;
cursor: pointer;
}
.connection:hover {
stroke: var(--accent-blue);
stroke-width: 3;
}
.connection.drawing {
stroke: var(--accent-blue);
stroke-dasharray: 5, 5;
}
/* Properties Panel */
.properties-panel {
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow-y: auto;
}
.properties-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.properties-header svg {
width: 16px;
height: 16px;
color: var(--accent-blue);
}
.properties-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
.properties-content {
padding: 16px;
}
.property-group {
margin-bottom: 16px;
}
.property-group-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
}
.property-field {
margin-bottom: 12px;
}
.property-label {
font-size: 12px;
margin-bottom: 4px;
color: var(--text-primary);
}
.property-input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px 10px;
color: var(--text-primary);
font-size: 13px;
}
.property-input:focus {
outline: none;
border-color: var(--accent-blue);
}
.property-textarea {
min-height: 80px;
resize: vertical;
font-family: 'Consolas', 'Monaco', monospace;
}
.property-checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.property-checkbox input {
width: 16px;
height: 16px;
accent-color: var(--accent-blue);
}
/* Status Bar */
.status-bar {
grid-column: 1 / -1;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 12px;
font-size: 12px;
color: var(--text-secondary);
gap: 16px;
}
.status-item {
display: flex;
align-items: center;
gap: 6px;
}
.status-item svg {
width: 14px;
height: 14px;
}
.status-spacer {
flex: 1;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 4px;
}
.zoom-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-btn:hover {
background: var(--border-color);
}
.zoom-value {
min-width: 40px;
text-align: center;
}
/* Context Menu */
.context-menu {
position: fixed;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 4px;
min-width: 160px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 1000;
display: none;
}
.context-menu.visible {
display: block;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.context-menu-item:hover {
background: var(--bg-tertiary);
}
.context-menu-item.danger {
color: var(--accent-red);
}
.context-menu-separator {
height: 1px;
background: var(--border-color);
margin: 4px 8px;
}
/* File Browser Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.visible {
display: flex;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.modal-title {
font-weight: 600;
font-size: 16px;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 20px;
overflow-y: auto;
max-height: 400px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
}
.file-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.file-item:hover {
background: var(--bg-tertiary);
}
.file-item.selected {
background: var(--accent-blue);
color: var(--bg-primary);
}
.file-icon {
width: 20px;
height: 20px;
}
.file-name {
flex: 1;
font-size: 14px;
}
.file-path {
font-size: 11px;
color: var(--text-secondary);
}
.file-item.selected .file-path {
color: var(--bg-tertiary);
}
/* HTMX indicators */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request.htmx-indicator {
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Minimap */
.minimap {
position: absolute;
bottom: 16px;
right: 16px;
width: 160px;
height: 100px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
opacity: 0.9;
}
.minimap-viewport {
position: absolute;
border: 1px solid var(--accent-blue);
background: rgba(137, 180, 250, 0.1);
}
.minimap-node {
position: absolute;
background: var(--accent-blue);
border-radius: 1px;
}
</style>
</head>
<body hx-ext="ws" ws-connect="/ws/designer">
<div class="designer-container">
<!-- Header -->
<header class="designer-header">
<div class="designer-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<span>Dialog Designer</span>
</div>
<div class="designer-toolbar">
<button class="toolbar-btn" id="btn-new" title="New Dialog (Ctrl+N)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
New
</button>
<button class="toolbar-btn" id="btn-open"
hx-get="/api/v1/designer/files"
hx-target="#file-list-content"
hx-trigger="click"
onclick="showModal('open-modal')"
title="Open Dialog (Ctrl+O)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Open
</button>
<button class="toolbar-btn primary" id="btn-save"
hx-post="/api/v1/designer/save"
hx-include="#designer-data"
hx-indicator="#save-spinner"
title="Save (Ctrl+S)">
<span class="htmx-indicator spinner" id="save-spinner"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save
</button>
<div class="toolbar-separator"></div>
<button class="toolbar-btn" id="btn-undo" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v6h6"/>
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
</svg>
</button>
<button class="toolbar-btn" id="btn-redo" title="Redo (Ctrl+Y)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 7v6h-6"/>
<path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/>
</svg>
</button>
<div class="toolbar-separator"></div>
<button class="toolbar-btn" id="btn-run"
hx-post="/api/v1/designer/validate"
hx-include="#designer-data"
title="Validate Dialog">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Validate
</button>
<button class="toolbar-btn" id="btn-export"
hx-get="/api/v1/designer/export"
hx-include="#designer-data"
title="Export as .bas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export
</button>
</div>
</header>
<!-- Toolbox Panel -->
<aside class="toolbox-panel">
<div class="toolbox-section">
<div class="toolbox-section-title">Conversation</div>
<div class="toolbox-item" draggable="true" data-node-type="TALK">
<div class="toolbox-icon talk">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">TALK</div>
<div class="toolbox-desc">Send message to user</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="HEAR">
<div class="toolbox-icon hear">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">HEAR</div>
<div class="toolbox-desc">Wait for user input</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="WAIT">
<div class="toolbox-icon wait">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">WAIT</div>
<div class="toolbox-desc">Pause execution</div>
</div>
</div>
</div>
<div class="toolbox-section">
<div class="toolbox-section-title">Logic</div>
<div class="toolbox-item" draggable="true" data-node-type="IF">
<div class="toolbox-icon if">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="9 18 15 12 9 6"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">IF</div>
<div class="toolbox-desc">Conditional branch</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="SWITCH">
<div class="toolbox-icon switch">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M18 8L22 12L18 16"/>
<path d="M2 12H22"/>
<path d="M6 8L2 12L6 16"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">SWITCH</div>
<div class="toolbox-desc">Multiple conditions</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="FOR">
<div class="toolbox-icon for">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">FOR EACH</div>
<div class="toolbox-desc">Loop through items</div>
</div>
</div>
</div>
<div class="toolbox-section">
<div class="toolbox-section-title">Data</div>
<div class="toolbox-item" draggable="true" data-node-type="SET">
<div class="toolbox-icon set">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="4 7 4 4 20 4 20 7"/>
<line x1="9" y1="20" x2="15" y2="20"/>
<line x1="12" y1="4" x2="12" y2="20"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">SET</div>
<div class="toolbox-desc">Assign variable</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="GET">
<div class="toolbox-icon get">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">GET</div>
<div class="toolbox-desc">Fetch data</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="SAVE">
<div class="toolbox-icon send">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">SAVE</div>
<div class="toolbox-desc">Save to storage</div>
</div>
</div>
</div>
<div class="toolbox-section">
<div class="toolbox-section-title">Actions</div>
<div class="toolbox-item" draggable="true" data-node-type="SEND MAIL">
<div class="toolbox-icon send">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">SEND MAIL</div>
<div class="toolbox-desc">Send email</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="CALL">
<div class="toolbox-icon call">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="9" y1="9" x2="15" y2="15"/>
<line x1="15" y1="9" x2="9" y2="15"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">CALL</div>
<div class="toolbox-desc">Call procedure</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="POST">
<div class="toolbox-icon call">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">POST</div>
<div class="toolbox-desc">HTTP POST request</div>
</div>
</div>
</div>
<div class="toolbox-section">
<div class="toolbox-section-title">Memory</div>
<div class="toolbox-item" draggable="true" data-node-type="SET BOT MEMORY">
<div class="toolbox-icon set">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">SET BOT MEMORY</div>
<div class="toolbox-desc">Store bot data</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="GET BOT MEMORY">
<div class="toolbox-icon get">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">GET BOT MEMORY</div>
<div class="toolbox-desc">Retrieve bot data</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="SET USER MEMORY">
<div class="toolbox-icon set">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">SET USER MEMORY</div>
<div class="toolbox-desc">Store user data</div>
</div>
</div>
<div class="toolbox-item" draggable="true" data-node-type="GET USER MEMORY">
<div class="toolbox-icon get">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
<div class="toolbox-info">
<div class="toolbox-name">GET USER MEMORY</div>
<div class="toolbox-desc">Retrieve user data</div>
</div>
</div>
</div>
</aside>
<!-- Canvas Area -->
<div class="canvas-container" id="canvas-container">
<div class="canvas-grid"></div>
<div class="canvas" id="canvas">
<svg class="connections-layer" id="connections-layer">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="var(--border-color)"/>
</marker>
</defs>
</svg>
<div class="canvas-inner" id="canvas-inner">
<!-- Nodes are rendered here dynamically -->
</div>
</div>
<!-- Minimap -->
<div class="minimap" id="minimap">
<div class="minimap-viewport" id="minimap-viewport"></div>
</div>
</div>
<!-- Properties Panel -->
<aside class="properties-panel" id="properties-panel">
<div class="properties-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Properties
</div>
<div class="properties-content" id="properties-content">
<div class="properties-empty">
Select a node to edit its properties
</div>
</div>
</aside>
<!-- Status Bar -->
<footer class="status-bar">
<div class="status-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span id="file-name">Untitled.bas</span>
</div>
<div class="status-item">
<span id="node-count">0 nodes</span>
</div>
<div class="status-item">
<span id="connection-count">0 connections</span>
</div>
<div class="status-spacer"></div>
<div class="status-item" id="save-status">
<span>Not saved</span>
</div>
<div class="zoom-controls">
<button class="zoom-btn" id="zoom-out" title="Zoom Out"></button>
<span class="zoom-value" id="zoom-value">100%</span>
<button</span> class="zoom-btn" id="zoom-in" title="Zoom In">+</button>
</div>
</footer>
</div>
<!-- Context Menu -->
<div class="context-menu" id="context-menu">
<div class="context-menu-item" data-action="duplicate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Duplicate
</div>
<div class="context-menu-item" data-action="copy">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Copy
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="bring-front">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="17 11 12 6 7 11"/>
<polyline points="17 18 12 13 7 18"/>
</svg>
Bring to Front
</div>
<div class="context-menu-item" data-action="send-back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="7 13 12 18 17 13"/>
<polyline points="7 6 12 11 17 6"/>
</svg>
Send to Back
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item danger" data-action="delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Delete
</div>
</div>
<!-- Open File Modal -->
<div class="modal-overlay" id="open-modal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Open Dialog File</span>
<button class="modal-close" onclick="hideModal('open-modal')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="file-list" id="file-list-content">
<div class="htmx-indicator" style="text-align: center; padding: 20px;">
<div class="spinner" style="margin: 0 auto;"></div>
<div style="margin-top: 8px; color: var(--text-secondary);">Loading files...</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="toolbar-btn" onclick="hideModal('open-modal')">Cancel</button>
<button class="toolbar-btn primary" id="btn-open-selected"
hx-get="/api/v1/designer/load"
hx-include="#selected-file"
hx-target="#canvas-inner"
hx-swap="innerHTML"
onclick="hideModal('open-modal')">
Open
</button>
</div>
</div>
</div>
<!-- Hidden form for designer data -->
<form id="designer-data" style="display: none;">
<input type="hidden" name="filename" id="current-filename" value="">
<textarea name="nodes" id="nodes-data"></textarea>
<textarea name="connections" id="connections-data"></textarea>
</form>
<input type="hidden" id="selected-file" name="path" value="">
<script>
// Designer State
const state = {
nodes: new Map(),
connections: [],
selectedNode: null,
selectedConnection: null,
isDragging: false,
isConnecting: false,
connectionStart: null,
zoom: 1,
pan: { x: 0, y: 0 },
history: [],
historyIndex: -1,
clipboard: null,
nextNodeId: 1
};
// Node Templates
const nodeTemplates = {
'TALK': {
fields: [
{ name: 'message', label: 'Message', type: 'textarea', default: 'Hello!' }
],
hasInput: true,
hasOutput: true
},
'HEAR': {
fields: [
{ name: 'variable', label: 'Variable', type: 'text', default: 'response' },
{ name: 'type', label: 'Type', type: 'select', options: ['STRING', 'NUMBER', 'DATE', 'EMAIL', 'PHONE'], default: 'STRING' }
],
hasInput: true,
hasOutput: true
},
'SET': {
fields: [
{ name: 'variable', label: 'Variable', type: 'text', default: 'value' },
{ name: 'expression', label: 'Expression', type: 'text', default: '' }
],
hasInput: true,
hasOutput: true
},
'IF': {
fields: [
{ name: 'condition', label: 'Condition', type: 'text', default: 'value = 1' }
],
hasInput: true,
hasOutput: false,
hasOutputTrue: true,
hasOutputFalse: true
},
'FOR': {
fields: [
{ name: 'variable', label: 'Item Variable', type: 'text', default: 'item' },
{ name: 'collection', label: 'Collection', type: 'text', default: 'items' }
],
hasInput: true,
hasOutput: true,
hasLoopOutput: true
},
'SWITCH': {
fields: [
{ name: 'expression', label: 'Expression', type: 'text', default: 'value' }
],
hasInput: true,
hasOutput: true
},
'CALL': {
fields: [
{ name: 'procedure', label: 'Procedure', type: 'text', default: '' },
{ name: 'arguments', label: 'Arguments', type: 'text', default: '' }
],
hasInput: true,
hasOutput: true
},
'SEND MAIL': {
fields: [
{ name: 'to', label: 'To', type: 'text', default: '' },
{ name: 'subject', label: 'Subject', type: 'text', default: '' },
{ name: 'body', label: 'Body', type: 'textarea', default: '' }
],
hasInput: true,
hasOutput: true
},
'GET': {
fields: [
{ name: 'url', label: 'URL', type: 'text', default: '' },
{ name: 'variable', label: 'Result Variable', type: 'text', default: 'result' }
],
hasInput: true,
hasOutput: true
},
'POST': {
fields: [
{ name: 'url', label: 'URL', type: 'text', default: '' },
{ name: 'body', label: 'Body', type: 'textarea', default: '' },
{ name: 'variable', label: 'Result Variable', type: 'text', default: 'result' }
],
hasInput: true,
hasOutput: true
},
'SAVE': {
fields: [
{ name: 'filename', label: 'Filename', type: 'text', default: 'data.csv' },
{ name: 'data', label: 'Data', type: 'text', default: '' }
],
hasInput: true,
hasOutput: true
},
'WAIT': {
fields: [
{ name: 'duration', label: 'Duration (seconds)', type: 'text', default: '5' }
],
hasInput: true,
hasOutput: true
},
'SET BOT MEMORY': {
fields: [
{ name: 'key', label: 'Key', type: 'text', default: '' },
{ name: 'value', label: 'Value', type: 'text', default: '' }
],
hasInput: true,
hasOutput: true
},
'GET BOT MEMORY': {
fields: [
{ name: 'key', label: 'Key', type: 'text', default: '' },
{ name: 'variable', label: 'Variable', type: 'text', default: 'value' }
],
hasInput: true,
hasOutput: true
},
'SET USER MEMORY': {
fields: [
{ name: 'key', label: 'Key', type: 'text', default: '' },
{ name: 'value', label: 'Value', type: 'text', default: '' }
],
hasInput: true,
hasOutput: true
},
'GET USER MEMORY': {
fields: [
{ name: 'key', label: 'Key', type: 'text', default: '' },
{ name: 'variable', label: 'Variable', type: 'text', default: 'value' }
],
hasInput: true,
hasOutput: true
}
};
// Initialize
document.addEventListener('DOMContentLoaded', () => {
initDragAndDrop();
initCanvasInteraction();
initKeyboardShortcuts();
initContextMenu();
updateStatusBar();
});
// Drag and Drop from Toolbox
function initDragAndDrop() {
const toolboxItems = document.querySelectorAll('.toolbox-item');
const canvas = document.getElementById('canvas-inner');
toolboxItems.forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('nodeType', item.dataset.nodeType);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
});
});
canvas.addEventListener('dragover', (e) => {
e.preventDefault();
});
canvas.addEventListener('drop', (e) => {
e.preventDefault();
const nodeType = e.dataTransfer.getData('nodeType');
if (nodeType) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / state.zoom;
const y = (e.clientY - rect.top) / state.zoom;
createNode(nodeType, snapToGrid(x), snapToGrid(y));
}
});
}
// Canvas Interaction
function initCanvasInteraction() {
const canvas = document.getElementById('canvas');
const container = document.getElementById('canvas-container');
// Pan with middle mouse or space+drag
let isPanning = false;
let panStart = { x: 0, y: 0 };
canvas.addEventListener('mousedown', (e) => {
if (e.button === 1 || (e.button === 0 && e.target === canvas)) {
isPanning = true;
panStart = { x: e.clientX - state.pan.x, y: e.clientY - state.pan.y };
canvas.style.cursor = 'grabbing';
}
});
document.addEventListener('mousemove', (e) => {
if (isPanning) {
state.pan.x = e.clientX - panStart.x;
state.pan.y = e.clientY - panStart.y;
updateCanvasTransform();
}
});
document.addEventListener('mouseup', () => {
isPanning = false;
canvas.style.cursor = 'default';
});
// Zoom with scroll
container.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newZoom = Math.min(Math.max(state.zoom + delta, 0.25), 2);
state.zoom = newZoom;
updateCanvasTransform();
updateZoomDisplay();
});
}
function updateCanvasTransform() {
const inner = document.getElementById('canvas-inner');
inner.style.transform = `translate(${state.pan.x}px, ${state.pan.y}px) scale(${state.zoom})`;
}
function updateZoomDisplay() {
document.getElementById('zoom-value').textContent = Math.round(state.zoom * 100) + '%';
}
// Grid snapping
function snapToGrid(value, gridSize = 20) {
return Math.round(value / gridSize) * gridSize;
}
// Create Node
function createNode(type, x, y) {
const template = nodeTemplates[type];
if (!template) return;
const id = 'node-' + state.nextNodeId++;
const node = {
id,
type,
x,
y,
fields: {}
};
// Initialize field values
template.fields.forEach(field => {
node.fields[field.name] = field.default;
});
state.nodes.set(id, node);
renderNode(node);
saveToHistory();
updateStatusBar();
}
// Render Node
function renderNode(node) {
const template = nodeTemplates[node.type];
const typeClass = node.type.toLowerCase().replace(/\s+/g, '-');
let fieldsHtml = '';
template.fields.forEach(field => {
const value = node.fields[field.name] || '';
if (field.type === 'textarea') {
fieldsHtml += `
<div class="node-field">
<label class="node-field-label">${field.label}</label>
<textarea class="node-field-input" data-field="${field.name}" rows="2">${value}</textarea>
</div>
`;
} else if (field.type === 'select') {
const options = field.options.map(opt =>
`<option value="${opt}" ${opt === value ? 'selected' : ''}>${opt}</option>`
).join('');
fieldsHtml += `
<div class="node-field">
<label class="node-field-label">${field.label}</label>
<select class="node-field-select" data-field="${field.name}">${options}</select>
</div>
`;
} else {
fieldsHtml += `
<div class="node-field">
<label class="node-field-label">${field.label}</label>
<input type="text" class="node-field-input" data-field="${field.name}" value="${value}">
</div>
`;
}
});
let portsHtml = '';
if (template.hasInput) {
portsHtml += `<div class="node-port input" data-port="input"></div>`;
}
if (template.hasOutput) {
portsHtml += `<div class="node-port output" data-port="output"></div>`;
}
if (template.hasOutputTrue) {
portsHtml += `<div class="node-port output-true" data-port="true" title="True"></div>`;
}
if (template.hasOutputFalse) {
portsHtml += `<div class="node-port output-false" data-port="false" title="False"></div>`;
}
const nodeEl = document.createElement('div');
nodeEl.className = 'node';
nodeEl.id = node.id;
nodeEl.style.left = node.x + 'px';
nodeEl.style.top = node.y + 'px';
nodeEl.innerHTML = `
<div class="node-header ${typeClass}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
${getNodeIcon(node.type)}
</svg>
${node.type}
</div>
<div class="node-body">
${fieldsHtml}
</div>
${portsHtml}
`;
// Make draggable
nodeEl.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('node-port')) return;
selectNode(node.id);
startNodeDrag(e, node);
});
// Field change handlers
nodeEl.querySelectorAll('.node-field-input, .node-field-select').forEach(input => {
input.addEventListener('change', (e) => {
node.fields[e.target.dataset.field] = e.target.value;
saveToHistory();
});
});
// Port handlers for connections
nodeEl.querySelectorAll('.node-port').forEach(port => {
port.addEventListener('mousedown', (e) => {
e.stopPropagation();
startConnection(node.id, port.dataset.port);
});
port.addEventListener('mouseup', (e) => {
e.stopPropagation();
endConnection(node.id, port.dataset.port);
});
});
document.getElementById('canvas-inner').appendChild(nodeEl);
}
function getNodeIcon(type) {
const icons = {
'TALK': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'HEAR': '<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>',
'SET': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
'IF': '<path d="M16 3h5v5"/><path d="M8 3H3v5"/><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"/><path d="m15 9 6-6"/>',
'FOR': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
'SWITCH': '<path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/>',
'CALL': '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.362 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.338 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>',
'SEND MAIL': '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
'GET': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'POST': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'SAVE': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'WAIT': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'SET BOT MEMORY': '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>',
'GET BOT MEMORY': '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="7 10 12 15 17 10"/>',
'SET USER MEMORY': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
'GET USER MEMORY': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
return icons[type] || '<circle cx="12" cy="12" r="10"/>';
}
// Node dragging
function startNodeDrag(e, node) {
state.isDragging = true;
const nodeEl = document.getElementById(node.id);
const startX = e.clientX;
const startY = e.clientY;
const origX = node.x;
const origY = node.y;
function onMove(e) {
const dx = (e.clientX - startX) / state.zoom;
const dy = (e.clientY - startY) / state.zoom;
node.x = snapToGrid(origX + dx);
node.y = snapToGrid(origY + dy);
nodeEl.style.left = node.x + 'px';
nodeEl.style.top = node.y + 'px';
updateConnections();
}
function onUp() {
state.isDragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveToHistory();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// Node selection
function selectNode(id) {
if (state.selectedNode) {
const prevEl = document.getElementById(state.selectedNode);
if (prevEl) prevEl.classList.remove('selected');
}
state.selectedNode = id;
const nodeEl = document.getElementById(id);
if (nodeEl) nodeEl.classList.add('selected');
updatePropertiesPanel();
}
function deselectAll() {
if (state.selectedNode) {
const el = document.getElementById(state.selectedNode);
if (el) el.classList.remove('selected');
}
state.selectedNode = null;
state.selectedConnection = null;
updatePropertiesPanel();
}
// Connections
function startConnection(nodeId, portType) {
state.isConnecting = true;
state.connectionStart = { nodeId, portType };
}
function endConnection(nodeId, portType) {
if (!state.isConnecting || !state.connectionStart) return;
if (state.connectionStart.nodeId === nodeId) {
state.isConnecting = false;
state.connectionStart = null;
return;
}
// Only allow output to input connections
if (state.connectionStart.portType === 'input' || portType !== 'input') {
state.isConnecting = false;
state.connectionStart = null;
return;
}
const connection = {
from: state.connectionStart.nodeId,
fromPort: state.connectionStart.portType,
to: nodeId,
toPort: portType
};
state.connections.push(connection);
state.isConnecting = false;
state.connectionStart = null;
updateConnections();
saveToHistory();
}
function updateConnections() {
const svg = document.getElementById('connections-svg');
let paths = '';
state.connections.forEach((conn, index) => {
const fromEl = document.getElementById(conn.from);
const toEl = document.getElementById(conn.to);
if (!fromEl || !toEl) return;
const fromPort = fromEl.querySelector(`[data-port="${conn.fromPort}"]`);
const toPort = toEl.querySelector(`[data-port="${conn.toPort}"]`);
if (!fromPort || !toPort) return;
const fromRect = fromPort.getBoundingClientRect();
const toRect = toPort.getBoundingClientRect();
const canvasRect = document.getElementById('canvas-inner').getBoundingClientRect();
const x1 = (fromRect.left + fromRect.width / 2 - canvasRect.left) / state.zoom;
const y1 = (fromRect.top + fromRect.height / 2 - canvasRect.top) / state.zoom;
const x2 = (toRect.left + toRect.width / 2 - canvasRect.left) / state.zoom;
const y2 = (toRect.top + toRect.height / 2 - canvasRect.top) / state.zoom;
const midX = (x1 + x2) / 2;
const d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
paths += `<path class="connection" d="${d}" data-index="${index}"/>`;
});
svg.innerHTML = `
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary)"/>
</marker>
</defs>
${paths}
`;
}
// Properties Panel
function updatePropertiesPanel() {
const content = document.getElementById('properties-content');
const empty = document.getElementById('properties-empty');
if (!state.selectedNode) {
content.style.display = 'none';
empty.style.display = 'block';
return;
}
const node = state.nodes.get(state.selectedNode);
if (!node) return;
const template = nodeTemplates[node.type];
empty.style.display = 'none';
content.style.display = 'block';
let html = `
<div class="property-group">
<div class="property-group-title">Node Info</div>
<div class="property-field">
<label class="property-label">Type</label>
<input type="text" class="property-input" value="${node.type}" readonly>
</div>
<div class="property-field">
<label class="property-label">ID</label>
<input type="text" class="property-input" value="${node.id}" readonly>
</div>
</div>
<div class="property-group">
<div class="property-group-title">Properties</div>
`;
template.fields.forEach(field => {
const value = node.fields[field.name] || '';
if (field.type === 'textarea') {
html += `
<div class="property-field">
<label class="property-label">${field.label}</label>
<textarea class="property-textarea" data-node="${node.id}" data-field="${field.name}">${value}</textarea>
</div>
`;
} else {
html += `
<div class="property-field">
<label class="property-label">${field.label}</label>
<input type="text" class="property-input" data-node="${node.id}" data-field="${field.name}" value="${value}">
</div>
`;
}
});
html += '</div>';
content.innerHTML = html;
// Add change handlers
content.querySelectorAll('.property-input:not([readonly]), .property-textarea').forEach(input => {
input.addEventListener('change', (e) => {
const n = state.nodes.get(e.target.dataset.node);
if (n) {
n.fields[e.target.dataset.field] = e.target.value;
// Update node view
const nodeInput = document.querySelector(`#${n.id} [data-field="${e.target.dataset.field}"]`);
if (nodeInput) nodeInput.value = e.target.value;
saveToHistory();
}
});
});
}
// History (Undo/Redo)
function saveToHistory() {
const snapshot = {
nodes: Array.from(state.nodes.entries()).map(([id, node]) => ({...node})),
connections: [...state.connections]
};
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(snapshot);
state.historyIndex = state.history.length - 1;
}
function undo() {
if (state.historyIndex > 0) {
state.historyIndex--;
restoreSnapshot(state.history[state.historyIndex]);
}
}
function redo() {
if (state.historyIndex < state.history.length - 1) {
state.historyIndex++;
restoreSnapshot(state.history[state.historyIndex]);
}
}
function restoreSnapshot(snapshot) {
// Clear canvas
document.getElementById('canvas-inner').innerHTML = '';
state.nodes.clear();
state.connections = [];
// Restore nodes
snapshot.nodes.forEach(node => {
state.nodes.set(node.id, {...node});
renderNode(node);
});
// Restore connections
state.connections = [...snapshot.connections];
updateConnections();
updateStatusBar();
}
// Keyboard Shortcuts
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 's':
e.preventDefault();
saveDesign();
break;
case 'o':
e.preventDefault();
showModal('open-modal');
break;
case 'z':
e.preventDefault();
if (e.shiftKey) redo();
else undo();
break;
case 'y':
e.preventDefault();
redo();
break;
case 'c':
if (state.selectedNode) {
e.preventDefault();
state.clipboard = {...state.nodes.get(state.selectedNode)};
}
break;
case 'v':
if (state.clipboard) {
e.preventDefault();
const newNode = {...state.clipboard};
newNode.id = 'node-' + state.nextNodeId++;
newNode.x += 40;
newNode.y += 40;
state.nodes.set(newNode.id, newNode);
renderNode(newNode);
selectNode(newNode.id);
saveToHistory();
}
break;
}
}
if (e.key === 'Delete' && state.selectedNode) {
deleteSelectedNode();
}
if (e.key === 'Escape') {
deselectAll();
hideContextMenu();
}
});
}
function deleteSelectedNode() {
if (!state.selectedNode) return;
const nodeEl = document.getElementById(state.selectedNode);
if (nodeEl) nodeEl.remove();
// Remove connections
state.connections = state.connections.filter(
conn => conn.from !== state.selectedNode && conn.to !== state.selectedNode
);
state.nodes.delete(state.selectedNode);
state.selectedNode = null;
updateConnections();
updatePropertiesPanel();
updateStatusBar();
saveToHistory();
}
// Context Menu
function initContextMenu() {
const canvas = document.getElementById('canvas');
const contextMenu = document.getElementById('context-menu');
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
const nodeEl = e.target.closest('.node');
if (nodeEl) {
selectNode(nodeEl.id);
}
contextMenu.style.left = e.clientX + 'px';
contextMenu.style.top = e.clientY + 'px';
contextMenu.classList.add('visible');
});
document.addEventListener('click', () => {
hideContextMenu();
});
}
function hideContextMenu() {
document.getElementById('context-menu').classList.remove('visible');
}
// Context Menu Actions
function duplicateNode() {
if (!state.selectedNode) return;
const node = state.nodes.get(state.selectedNode);
if (!node) return;
const newNode = {...node, fields: {...node.fields}};
newNode.id = 'node-' + state.nextNodeId++;
newNode.x += 40;
newNode.y += 40;
state.nodes.set(newNode.id, newNode);
renderNode(newNode);
selectNode(newNode.id);
saveToHistory();
hideContextMenu();
}
// Status Bar
function updateStatusBar() {
document.getElementById('node-count').textContent = state.nodes.size + ' nodes';
document.getElementById('connection-count').textContent = state.connections.length + ' connections';
}
// Zoom Controls
function zoomIn() {
state.zoom = Math.min(state.zoom + 0.1, 2);
updateCanvasTransform();
updateZoomDisplay();
}
function zoomOut() {
state.zoom = Math.max(state.zoom - 0.1, 0.25);
updateCanvasTransform();
updateZoomDisplay();
}
// Modal Management
function showModal(id) {
document.getElementById(id).classList.add('visible');
if (id === 'open-modal') {
htmx.trigger('#file-list-content', 'load');
}
}
function hideModal(id) {
document.getElementById(id).classList.remove('visible');
}
// Save Design
function saveDesign() {
const nodesData = Array.from(state.nodes.values());
document.getElementById('nodes-data').value = JSON.stringify(nodesData);
document.getElementById('connections-data').value = JSON.stringify(state.connections);
// Trigger HTMX save
htmx.ajax('POST', '/api/v1/designer/save', {
source: document.getElementById('designer-data'),
target: '#status-message'
});
}
// Export to .bas
function exportToBas() {
let basCode = "' Generated by General Bots Designer\n";
basCode += "' " + new Date().toISOString() + "\n\n";
// Sort nodes by position (top to bottom, left to right)
const sortedNodes = Array.from(state.nodes.values()).sort((a, b) => {
if (Math.abs(a.y - b.y) < 30) return a.x - b.x;
return a.y - b.y;
});
sortedNodes.forEach(node => {
const template = nodeTemplates[node.type];
switch (node.type) {
case 'TALK':
basCode += `TALK "${node.fields.message}"\n`;
break;
case 'HEAR':
basCode += `HEAR ${node.fields.variable} AS ${node.fields.type}\n`;
break;
case 'SET':
basCode += `SET ${node.fields.variable} = ${node.fields.expression}\n`;
break;
case 'IF':
basCode += `IF ${node.fields.condition} THEN\n`;
break;
case 'FOR':
basCode += `FOR EACH ${node.fields.variable} IN ${node.fields.collection}\n`;
break;
case 'CALL':
basCode += `CALL ${node.fields.procedure}(${node.fields.arguments})\n`;
break;
case 'SEND MAIL':
basCode += `SEND MAIL TO "${node.fields.to}" SUBJECT "${node.fields.subject}" BODY "${node.fields.body}"\n`;
break;
case 'GET':
basCode += `GET ${node.fields.url} TO ${node.fields.variable}\n`;
break;
case 'POST':
basCode += `POST ${node.fields.url} WITH ${node.fields.body} TO ${node.fields.variable}\n`;
break;
case 'SAVE':
basCode += `SAVE ${node.fields.data} TO "${node.fields.filename}"\n`;
break;
case 'WAIT':
basCode += `WAIT ${node.fields.duration}\n`;
break;
case 'SET BOT MEMORY':
basCode += `SET BOT MEMORY "${node.fields.key}", ${node.fields.value}\n`;
break;
case 'GET BOT MEMORY':
basCode += `GET BOT MEMORY "${node.fields.key}" AS ${node.fields.variable}\n`;
break;
case 'SET USER MEMORY':
basCode += `SET USER MEMORY "${node.fields.key}", ${node.fields.value}\n`;
break;
case 'GET USER MEMORY':
basCode += `GET USER MEMORY "${node.fields.key}" AS ${node.fields.variable}\n`;
break;
}
});
// Download as file
const blob = new Blob([basCode], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (document.getElementById('current-filename').value || 'dialog') + '.bas';
a.click();
URL.revokeObjectURL(url);
}
// New Design
function newDesign() {
if (state.nodes.size > 0) {
if (!confirm('Clear current design? Unsaved changes will be lost.')) return;
}
document.getElementById('canvas-inner').innerHTML = '';
state.nodes.clear();
state.connections = [];
state.selectedNode = null;
state.history = [];
state.historyIndex = -1;
state.nextNodeId = 1;
document.getElementById('current-filename').value = '';
document.getElementById('file-name').textContent = 'Untitled';
updateConnections();
updatePropertiesPanel();
updateStatusBar();
}
// File selection in open modal
document.addEventListener('click', (e) => {
const fileItem = e.target.closest('.file-item');
if (fileItem) {
document.querySelectorAll('.file-item').forEach(f => f.classList.remove('selected'));
fileItem.classList.add('selected');
document.getElementById('selected-file').value = fileItem.dataset.path;
}
});
</script>
</body>
</html>