botui/ui/suite/designer.html
Rodrigo Rodriguez (Pragmatismo) 955568c8e4 fix(i18n): fix DOM timing issue in i18n.js
- Wait for document.body before attaching event listeners
- Prevents TypeError when script loads before body exists
2026-01-10 10:54:05 -03:00

2690 lines
103 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.

<!-- Dialog Designer - General Bots (.bas Visual Editor) -->
<style>
/* Designer uses global theme variables with node-specific colors */
.designer-container {
--node-talk: #89b4fa;
--node-hear: #a6e3a1;
--node-set: #f9e2af;
--node-if: #cba6f7;
--node-for: #f38ba8;
--node-call: #94e2d5;
--node-send: #fab387;
--grid-size: 20px;
}
.designer-container {
display: grid;
grid-template-columns: 240px 1fr 280px;
grid-template-rows: 48px 1fr 32px;
height: calc(100vh - 64px);
background: var(--bg, #0f172a);
color: var(--text, #f8fafc);
}
/* Header */
.designer-header {
grid-column: 1 / -1;
background: var(--surface, #1e293b);
border-bottom: 1px solid var(--border, #334155);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
}
.designer-logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--primary, #3b82f6);
}
.designer-logo svg {
width: 24px;
height: 24px;
}
.designer-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.toolbar-btn {
background: var(--surface-hover, #334155);
border: 1px solid var(--border, #334155);
color: var(--text, #f8fafc);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
transition: all 0.15s;
}
.toolbar-btn.magic {
background: linear-gradient(135deg, var(--primary, #a855f7), var(--primary, #3b82f6));
border-color: var(--primary, #a855f7);
}
.toolbar-btn.magic:hover {
background: linear-gradient(135deg, var(--primary, #3b82f6), var(--primary, #a855f7));
transform: scale(1.05);
}
.magic-panel {
position: fixed;
right: 20px;
bottom: 60px;
width: 380px;
max-height: 500px;
background: var(--surface, #1e293b);
border: 1px solid var(--border, #334155);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
z-index: 1000;
display: none;
flex-direction: column;
overflow: hidden;
}
.magic-panel.visible {
display: flex;
}
.magic-header {
padding: 16px;
border-bottom: 1px solid var(--border, #334155);
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(135deg, rgba(203,166,247,0.1), rgba(137,180,250,0.1));
}
.magic-header svg {
width: 20px;
height: 20px;
color: var(--primary, #a855f7);
}
.magic-header h3 {
flex: 1;
font-size: 14px;
font-weight: 600;
}
.magic-close {
background: none;
border: none;
color: var(--text-secondary, #94a3b8);
cursor: pointer;
padding: 4px;
}
.magic-close:hover {
color: var(--text, #f8fafc);
}
.magic-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.magic-loading {
text-align: center;
padding: 40px;
color: var(--text-secondary, #94a3b8);
}
.magic-loading .spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border, #334155);
border-top-color: var(--primary, #a855f7);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
.magic-suggestion {
background: var(--surface-hover, #334155);
border: 1px solid var(--border, #334155);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.magic-suggestion-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.magic-suggestion-icon {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.magic-suggestion-icon.ux { background: rgba(166,227,161,0.2); color: var(--success, #22c55e); }
.magic-suggestion-icon.perf { background: rgba(249,226,175,0.2); color: var(--warning, #f59e0b); }
.magic-suggestion-icon.a11y { background: rgba(137,180,250,0.2); color: var(--primary, #3b82f6); }
.magic-suggestion-icon.feature { background: rgba(203,166,247,0.2); color: var(--primary, #a855f7); }
.magic-suggestion-title {
font-weight: 600;
font-size: 13px;
}
.magic-suggestion-desc {
font-size: 12px;
color: var(--text-secondary, #94a3b8);
line-height: 1.5;
}
.magic-suggestion-apply {
margin-top: 10px;
background: var(--primary, #a855f7);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.magic-suggestion-apply:hover {
opacity: 0.9;
}
.toolbar-btn:hover {
background: var(--border, #334155);
}
.toolbar-btn.primary {
background: var(--primary, #3b82f6);
color: var(--bg, #0f172a);
border-color: var(--primary, #3b82f6);
}
.toolbar-btn.primary:hover {
background: var(--primary-hover, #2563eb);
}
.toolbar-btn svg {
width: 16px;
height: 16px;
}
.toolbar-separator {
width: 1px;
height: 24px;
background: var(--border, #334155);
margin: 0 8px;
}
/* Toolbox Panel */
.toolbox-panel {
background: var(--surface, #1e293b);
border-right: 1px solid var(--border, #334155);
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, #94a3b8);
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(--surface-hover, #334155);
}
.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, #0f172a);
}
.node-item-name {
font-size: 13px;
font-weight: 500;
color: var(--text, #f8fafc);
}
.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: var(--info, #06b6d4); }
.toolbox-icon.wait { background: var(--warning, #f59e0b); }
.toolbox-icon.switch { background: var(--primary, #3b82f6); }
.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, #94a3b8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Canvas Area */
.canvas-container {
position: relative;
overflow: hidden;
background: var(--bg, #0f172a);
}
.canvas-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border, #334155) 1px, transparent 1px),
linear-gradient(90deg, var(--border, #334155) 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(--surface, #1e293b);
border: 2px solid var(--border, #334155);
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(--primary, #3b82f6);
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, #0f172a); }
.node-header.hear { background: var(--node-hear); color: var(--bg, #0f172a); }
.node-header.set { background: var(--node-set); color: var(--bg, #0f172a); }
.node-header.if { background: var(--node-if); color: var(--bg, #0f172a); }
.node-header.for { background: var(--node-for); color: var(--bg, #0f172a); }
.node-header.call { background: var(--node-call); color: var(--bg, #0f172a); }
.node-header.send { background: var(--node-send); color: var(--bg, #0f172a); }
.node-header.get { background: var(--info, #06b6d4); color: var(--bg, #0f172a); }
.node-header.wait { background: var(--warning, #f59e0b); color: var(--bg, #0f172a); }
.node-header.switch { background: var(--primary, #3b82f6); color: white; }
.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, #94a3b8);
margin-bottom: 4px;
}
.node-field-input {
width: 100%;
background: var(--surface-hover, #334155);
border: 1px solid var(--border, #334155);
border-radius: 4px;
padding: 6px 8px;
color: var(--text, #f8fafc);
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
}
.node-field-input:focus {
outline: none;
border-color: var(--primary, #3b82f6);
}
.node-field-select {
width: 100%;
background: var(--surface-hover, #334155);
border: 1px solid var(--border, #334155);
border-radius: 4px;
padding: 6px 8px;
color: var(--text, #f8fafc);
font-size: 12px;
cursor: pointer;
}
/* Connection Ports */
.node-port {
position: absolute;
width: 12px;
height: 12px;
background: var(--surface-hover, #334155);
border: 2px solid var(--border, #334155);
border-radius: 50%;
cursor: crosshair;
transition: all 0.15s;
}
.node-port:hover {
background: var(--primary, #3b82f6);
border-color: var(--primary, #3b82f6);
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(--success, #22c55e);
border-color: var(--success, #22c55e);
}
.node-port.output-false {
right: -6px;
top: 65%;
background: var(--error, #ef4444);
border-color: var(--error, #ef4444);
}
/* Connections SVG */
.connections-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
}
.connection {
fill: none;
stroke: var(--border, #334155);
stroke-width: 2;
pointer-events: stroke;
cursor: pointer;
}
.connection:hover {
stroke: var(--primary, #3b82f6);
stroke-width: 3;
}
.connection.drawing {
stroke: var(--primary, #3b82f6);
stroke-dasharray: 5, 5;
}
/* Properties Panel */
.properties-panel {
background: var(--surface, #1e293b);
border-left: 1px solid var(--border, #334155);
overflow-y: auto;
}
.properties-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border, #334155);
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.properties-header svg {
width: 16px;
height: 16px;
color: var(--primary, #3b82f6);
}
.properties-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-secondary, #94a3b8);
font-size: 13px;
}
.properties-content {
padding: 16px;
}
.property-group {
margin-bottom: 16px;
}
.property-group-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary, #94a3b8);
margin-bottom: 12px;
}
.property-field {
margin-bottom: 12px;
}
.property-label {
font-size: 12px;
margin-bottom: 4px;
color: var(--text, #f8fafc);
}
.property-input {
width: 100%;
background: var(--surface-hover, #334155);
border: 1px solid var(--border, #334155);
border-radius: 4px;
padding: 8px 10px;
color: var(--text, #f8fafc);
font-size: 13px;
}
.property-input:focus {
outline: none;
border-color: var(--primary, #3b82f6);
}
.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(--primary, #3b82f6);
}
/* Status Bar */
.status-bar {
grid-column: 1 / -1;
background: var(--surface, #1e293b);
border-top: 1px solid var(--border, #334155);
display: flex;
align-items: center;
padding: 0 12px;
font-size: 12px;
color: var(--text-secondary, #94a3b8);
gap: 16px;
}
.status-item {
display: flex;
align-items: center;
gap: 6px;
}
.status-item svg {
width: 14px;
height: 14px;
color: var(--text-secondary, #94a3b8);
}
.status-spacer {
flex: 1;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 4px;
}
.zoom-btn {
background: var(--surface-hover, #334155);
border: 1px solid var(--border, #334155);
color: var(--text, #f8fafc);
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-btn:hover {
background: var(--border, #334155);
}
.zoom-value {
min-width: 40px;
text-align: center;
}
/* Context Menu */
.context-menu {
position: fixed;
background: var(--surface, #1e293b);
border: 1px solid var(--border, #334155);
border-radius: 8px;
padding: 8px 0;
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(--surface-hover, #334155);
}
.context-menu-item.danger {
color: var(--error, #ef4444);
}
.context-menu-separator {
height: 1px;
background: var(--border, #334155);
margin: 4px 8px;
}
/* File Browser Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--surface, #1e293b);
border: 1px solid var(--border, #334155);
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border, #334155);
}
.magic-panel-header {
padding: 14px 16px;
border-bottom: 1px solid var(--border, #334155);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-weight: 600;
font-size: 16px;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary, #94a3b8);
cursor: pointer;
padding: 4px;
}
.modal-close:hover {
color: var(--text, #f8fafc);
}
.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, #334155);
}
.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(--surface-hover, #334155);
}
.file-item.selected {
background: var(--primary, #3b82f6);
color: var(--bg, #0f172a);
}
.file-icon {
width: 20px;
height: 20px;
}
.file-name {
flex: 1;
font-size: 14px;
}
.file-path {
font-size: 11px;
color: var(--text-secondary, #94a3b8);
}
.file-item.selected .file-path {
color: var(--surface-hover, #334155);
}
/* 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, #334155);
border-top-color: var(--primary, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Minimap */
.minimap {
position: absolute;
bottom: 16px;
right: 16px;
width: 150px;
height: 100px;
background: var(--surface, #1e293b);
border: 1px solid var(--border, #334155);
border-radius: 6px;
overflow: hidden;
}
.minimap-viewport {
position: absolute;
border: 1px solid var(--primary, #3b82f6);
background: rgba(137, 180, 250, 0.1);
}
.minimap-node {
position: absolute;
background: var(--primary, #3b82f6);
border-radius: 1px;
}
</style>
<!-- Note: ws-connect="/ws/designer" removed until backend WebSocket endpoint is implemented -->
<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/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/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/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/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 class="toolbar-separator"></div>
<button class="toolbar-btn magic" id="btn-magic"
onclick="showMagicPanel()"
title="AI Improvements (Ctrl+M)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"/>
</svg>
Magic
</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, #334155)"/>
</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>
<!-- Magic AI Panel -->
<div class="magic-panel" id="magic-panel">
<div class="magic-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"/>
</svg>
<h3>AI Improvements</h3>
<button class="magic-close" onclick="hideMagicPanel()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="magic-content" id="magic-content">
<div class="magic-loading">
<div class="spinner"></div>
<p>Analyzing your dialog...</p>
</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, #94a3b8);">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/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,
driveSource: null
};
// 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
function initDesigner() {
console.log('initDesigner called');
initDragAndDrop();
initCanvasInteraction();
initKeyboardShortcuts();
initContextMenu();
updateStatusBar();
loadFromUrlParams();
}
// Run on DOMContentLoaded (for direct page load)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDesigner);
} else {
// DOM already loaded (HTMX injection case)
initDesigner();
}
// Also run when HTMX swaps content
document.body.addEventListener('htmx:afterSwap', (e) => {
if (e.detail.target.id === 'main-content') {
console.log('htmx:afterSwap detected for main-content');
initDesigner();
}
});
// Load file from URL parameters (when opening .bas from drive)
async function loadFromUrlParams() {
// Parameters can be in query string OR in hash fragment (after #designer?)
let bucket = null;
let path = null;
// First try query string
const queryParams = new URLSearchParams(window.location.search);
bucket = queryParams.get('bucket');
path = queryParams.get('path');
// If not found, try hash fragment (e.g., /#designer?bucket=x&path=y)
if (!bucket || !path) {
const hash = window.location.hash;
const hashQueryIndex = hash.indexOf('?');
if (hashQueryIndex !== -1) {
const hashParams = new URLSearchParams(hash.substring(hashQueryIndex + 1));
bucket = bucket || hashParams.get('bucket');
path = path || hashParams.get('path');
}
}
console.log('loadFromUrlParams called:', { bucket, path, hash: window.location.hash, search: window.location.search });
if (bucket && path) {
const fileName = path.split('/').pop() || 'dialog.bas';
document.getElementById('current-filename').value = path;
document.getElementById('selected-file').value = path;
state.driveSource = { bucket, path };
try {
// Fetch file content directly from drive API
const response = await fetch('/api/files/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket, path })
});
if (!response.ok) {
throw new Error(`Failed to load file: ${response.statusText}`);
}
const data = await response.json();
const content = data.content || '';
console.log('Loaded .bas content:', content.substring(0, 200) + '...');
// Parse BASIC code and create nodes
parseBasicCodeToNodes(content);
updateStatusBar();
const statusEl = document.querySelector('.status-item span');
if (statusEl) {
statusEl.textContent = `Loaded: ${fileName}`;
}
} catch (err) {
console.error('Failed to load .bas file:', err);
alert(`Failed to load file: ${err.message}`);
}
}
}
// Parse BASIC code and create visual nodes
function parseBasicCodeToNodes(content) {
console.log('parseBasicCodeToNodes called');
state.nodes.clear();
state.connections = [];
state.nextNodeId = 1;
const lines = content.split('\n');
let yPos = 100;
let nodeCount = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("'")) continue;
const upper = trimmed.toUpperCase();
let nodeType = null;
let fields = {};
if (upper.startsWith('TALK ')) {
nodeType = 'TALK';
const match = trimmed.match(/TALK\s+"([^"]*)"/i) || trimmed.match(/TALK\s+(.+)/i);
fields.message = match ? match[1] : '';
} else if (upper.startsWith('HEAR ')) {
nodeType = 'HEAR';
const match = trimmed.match(/HEAR\s+(\w+)(?:\s+AS\s+(\w+))?/i);
fields.variable = match ? match[1] : 'input';
fields.type = match && match[2] ? match[2] : 'string';
} else if (upper.startsWith('SET ') || upper.includes(' = ')) {
nodeType = 'SET';
const match = trimmed.match(/(?:SET\s+)?(\w+)\s*=\s*(.+)/i);
fields.variable = match ? match[1] : 'x';
fields.expression = match ? match[2] : '0';
} else if (upper.startsWith('IF ')) {
nodeType = 'IF';
const match = trimmed.match(/IF\s+(.+?)\s+THEN/i);
fields.condition = match ? match[1] : 'true';
} else if (upper.startsWith('FOR ')) {
nodeType = 'FOR';
const match = trimmed.match(/FOR\s+(?:EACH\s+)?(\w+)\s+IN\s+(.+)/i);
fields.variable = match ? match[1] : 'item';
fields.collection = match ? match[2] : 'items';
} else if (upper.startsWith('CALL ')) {
nodeType = 'CALL';
const match = trimmed.match(/CALL\s+(\w+)\s*\(([^)]*)\)/i);
fields.procedure = match ? match[1] : 'sub';
fields.arguments = match ? match[2] : '';
} else if (upper.startsWith('WAIT ')) {
nodeType = 'WAIT';
const match = trimmed.match(/WAIT\s+(\d+)/i);
fields.duration = match ? match[1] : '1000';
} else if (upper.startsWith('GET ')) {
nodeType = 'GET';
const match = trimmed.match(/GET\s+(.+?)\s+TO\s+(\w+)/i);
fields.url = match ? match[1] : '';
fields.variable = match ? match[2] : 'result';
} else if (upper.startsWith('PARAM ')) {
nodeType = 'HEAR';
const match = trimmed.match(/PARAM\s+(\w+)\s+AS\s+(\w+)/i);
fields.variable = match ? match[1] : 'param';
fields.type = match ? match[2] : 'string';
}
if (nodeType && nodeTemplates[nodeType]) {
const node = createNode(nodeType, 400, yPos);
if (node) {
Object.assign(node.fields, fields);
// Update the rendered node with field values
const nodeEl = document.getElementById(node.id);
if (nodeEl) {
nodeEl.querySelectorAll('.node-field-input, .node-field-select, textarea').forEach(input => {
const fieldName = input.dataset.field || input.name;
if (fields[fieldName] !== undefined) {
input.value = fields[fieldName];
}
});
}
yPos += 100;
nodeCount++;
console.log('Created node:', nodeType, fields);
}
}
}
console.log(`Parsed ${nodeCount} nodes from BASIC code`);
updateStatusBar();
saveToHistory();
}
// Initialize canvas with loaded nodes from server (called by HTMX response)
function initializeCanvas() {
console.log('initializeCanvas called');
const canvasLoaded = document.querySelector('.canvas-loaded');
if (!canvasLoaded) {
console.log('No canvas-loaded element found');
return;
}
const content = canvasLoaded.dataset.content || '';
console.log('Canvas content from server:', content.substring(0, 100));
// Remove the server-rendered container and parse content client-side
canvasLoaded.remove();
parseBasicCodeToNodes(content);
}
// 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) {
console.warn('No template found for node type:', type);
return null;
}
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();
return node;
}
// 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);
if (state.driveSource) {
saveToDrive();
} else {
htmx.ajax('POST', '/api/designer/save', {
source: document.getElementById('designer-data'),
target: '#status-message'
});
}
}
// Save to drive (MinIO) when file was loaded from drive
async function saveToDrive() {
const basCode = generateBasCode();
const { bucket, path } = state.driveSource;
try {
const response = await fetch('/api/files/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket, path, content: basCode })
});
if (response.ok) {
const statusEl = document.querySelector('.status-item span');
if (statusEl) {
statusEl.textContent = `Saved: ${path.split('/').pop()}`;
}
} else {
const err = await response.json();
alert(`Save failed: ${err.error || 'Unknown error'}`);
}
} catch (e) {
alert(`Save failed: ${e.message}`);
}
}
// Generate BASIC code from nodes
function generateBasCode() {
let basCode = "' Generated by General Bots Designer\n";
basCode += "' " + new Date().toISOString() + "\n\n";
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 => {
switch (node.type) {
case 'TALK':
basCode += `TALK "${node.fields.message || ''}"\n`;
break;
case 'HEAR':
basCode += `HEAR ${node.fields.variable || 'input'} AS ${node.fields.type || 'string'}\n`;
break;
case 'SET':
basCode += `SET ${node.fields.variable || 'x'} = ${node.fields.expression || '0'}\n`;
break;
case 'IF':
basCode += `IF ${node.fields.condition || 'true'} THEN\n`;
break;
case 'FOR':
basCode += `FOR EACH ${node.fields.variable || 'item'} IN ${node.fields.collection || 'items'}\n`;
break;
case 'CALL':
basCode += `CALL ${node.fields.procedure || 'sub'}(${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 || 'url'} TO ${node.fields.variable || 'result'}\n`;
break;
case 'POST':
basCode += `POST ${node.fields.url || 'url'} WITH ${node.fields.body || '{}'} TO ${node.fields.variable || 'result'}\n`;
break;
case 'SAVE':
basCode += `SAVE ${node.fields.data || 'data'} TO "${node.fields.filename || 'file.txt'}"\n`;
break;
case 'WAIT':
basCode += `WAIT ${node.fields.duration || '1000'}\n`;
break;
case 'SET BOT MEMORY':
basCode += `SET BOT MEMORY "${node.fields.key || 'key'}", ${node.fields.value || '""'}\n`;
break;
case 'GET BOT MEMORY':
basCode += `GET BOT MEMORY "${node.fields.key || 'key'}" TO ${node.fields.variable || 'value'}\n`;
break;
case 'SET USER MEMORY':
basCode += `SET USER MEMORY "${node.fields.key || 'key'}", ${node.fields.value || '""'}\n`;
break;
case 'GET USER MEMORY':
basCode += `GET USER MEMORY "${node.fields.key || 'key'}" TO ${node.fields.variable || 'value'}\n`;
break;
case 'SWITCH':
basCode += `SWITCH ${node.fields.expression || 'value'}\n`;
break;
}
});
return basCode;
}
// 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;
}
});
function showMagicPanel() {
const panel = document.getElementById('magic-panel');
panel.classList.add('visible');
analyzeMagicSuggestions();
}
function hideMagicPanel() {
document.getElementById('magic-panel').classList.remove('visible');
}
async function analyzeMagicSuggestions() {
const content = document.getElementById('magic-content');
content.innerHTML = '<div class="magic-loading"><div class="spinner"></div><p>Analyzing your dialog...</p></div>';
const nodes = Array.from(state.nodes.values());
const dialogData = {
nodes: nodes.map(n => ({ type: n.type, fields: n.fields })),
connections: state.connections.length,
filename: document.getElementById('current-filename').value || 'untitled'
};
try {
const response = await fetch('/api/designer/magic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dialogData)
});
if (response.ok) {
const suggestions = await response.json();
renderMagicSuggestions(suggestions);
} else {
renderFallbackSuggestions(dialogData);
}
} catch (e) {
renderFallbackSuggestions(dialogData);
}
}
function renderFallbackSuggestions(dialogData) {
const suggestions = [];
const nodes = dialogData.nodes;
if (!nodes.some(n => n.type === 'HEAR')) {
suggestions.push({
type: 'ux',
title: 'Add User Input',
description: 'Your dialog has no HEAR nodes. Consider adding user input to make it interactive.'
});
}
if (nodes.filter(n => n.type === 'TALK').length > 5) {
suggestions.push({
type: 'ux',
title: 'Break Up Long Responses',
description: 'You have many TALK nodes. Consider grouping related messages or using a menu.'
});
}
if (!nodes.some(n => n.type === 'IF' || n.type === 'SWITCH')) {
suggestions.push({
type: 'feature',
title: 'Add Decision Logic',
description: 'Add IF or SWITCH nodes to handle different user responses dynamically.'
});
}
if (dialogData.connections < nodes.length - 1 && nodes.length > 1) {
suggestions.push({
type: 'perf',
title: 'Check Connections',
description: 'Some nodes may not be connected. Ensure all nodes flow properly.'
});
}
suggestions.push({
type: 'a11y',
title: 'Use Clear Language',
description: 'Keep messages short and clear. Avoid jargon for better accessibility.'
});
renderMagicSuggestions(suggestions);
}
function renderMagicSuggestions(suggestions) {
const content = document.getElementById('magic-content');
if (!suggestions || suggestions.length === 0) {
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary, #94a3b8);padding:40px;">Your dialog looks great! No suggestions at this time.</p>';
return;
}
const icons = {
ux: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
perf: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
a11y: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
feature: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"/></svg>'
};
content.innerHTML = suggestions.map(s => `
<div class="magic-suggestion">
<div class="magic-suggestion-header">
<div class="magic-suggestion-icon ${s.type}">${icons[s.type] || icons.feature}</div>
<span class="magic-suggestion-title">${s.title}</span>
</div>
<p class="magic-suggestion-desc">${s.description}</p>
</div>
`).join('');
}
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'm') {
e.preventDefault();
showMagicPanel();
}
});
</script>