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
This commit is contained in:
parent
5edb45133f
commit
36d5f3838c
5 changed files with 2552 additions and 2 deletions
|
|
@ -37,12 +37,14 @@
|
|||
# Part IV - User Interface
|
||||
|
||||
- [Chapter 04: .gbui Interface Reference](./chapter-04-gbui/README.md)
|
||||
- [Suite User Manual](./chapter-04-gbui/suite-manual.md)
|
||||
- [UI Structure](./chapter-04-gbui/ui-structure.md)
|
||||
- [default.gbui - Full Desktop](./chapter-04-gbui/default-gbui.md)
|
||||
- [single.gbui - Simple Chat](./chapter-04-gbui/single-gbui.md)
|
||||
- [Console Mode](./chapter-04-gbui/console-mode.md)
|
||||
- [Player - Media Viewer](./chapter-04-gbui/player.md)
|
||||
- [Monitoring Dashboard](./chapter-04-gbui/monitoring.md)
|
||||
- [HTMX Architecture](./chapter-04-gbui/htmx-architecture.md)
|
||||
|
||||
# Part V - Themes and Styling
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,535 @@
|
|||
# HTMX Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
General Bots Suite uses **HTMX** for its user interface - a modern approach that delivers the interactivity of a single-page application without the complexity of JavaScript frameworks like React, Vue, or Angular.
|
||||
|
||||
> **Why HTMX?**
|
||||
> - Simpler code, easier maintenance
|
||||
> - Server-rendered HTML (fast, SEO-friendly)
|
||||
> - Progressive enhancement
|
||||
> - No build step required
|
||||
> - Smaller payload than SPA frameworks
|
||||
|
||||
---
|
||||
|
||||
## How HTMX Works
|
||||
|
||||
### Traditional Web vs HTMX
|
||||
|
||||
**Traditional (Full Page Reload):**
|
||||
```
|
||||
User clicks → Browser requests full page → Server returns entire HTML → Browser replaces everything
|
||||
```
|
||||
|
||||
**HTMX (Partial Update):**
|
||||
```
|
||||
User clicks → HTMX requests fragment → Server returns HTML snippet → HTMX updates only that part
|
||||
```
|
||||
|
||||
### Core Concept
|
||||
|
||||
HTMX extends HTML with attributes that define:
|
||||
1. **What triggers the request** (`hx-trigger`)
|
||||
2. **Where to send it** (`hx-get`, `hx-post`)
|
||||
3. **What to update** (`hx-target`)
|
||||
4. **How to update it** (`hx-swap`)
|
||||
|
||||
---
|
||||
|
||||
## HTMX Attributes Reference
|
||||
|
||||
### Request Attributes
|
||||
|
||||
| Attribute | Purpose | Example |
|
||||
|-----------|---------|---------|
|
||||
| `hx-get` | GET request to URL | `hx-get="/api/tasks"` |
|
||||
| `hx-post` | POST request | `hx-post="/api/tasks"` |
|
||||
| `hx-put` | PUT request | `hx-put="/api/tasks/1"` |
|
||||
| `hx-patch` | PATCH request | `hx-patch="/api/tasks/1"` |
|
||||
| `hx-delete` | DELETE request | `hx-delete="/api/tasks/1"` |
|
||||
|
||||
### Trigger Attributes
|
||||
|
||||
| Attribute | Purpose | Example |
|
||||
|-----------|---------|---------|
|
||||
| `hx-trigger` | Event that triggers request | `hx-trigger="click"` |
|
||||
| | Load on page | `hx-trigger="load"` |
|
||||
| | Periodic polling | `hx-trigger="every 5s"` |
|
||||
| | Keyboard event | `hx-trigger="keyup changed delay:300ms"` |
|
||||
|
||||
### Target & Swap Attributes
|
||||
|
||||
| Attribute | Purpose | Example |
|
||||
|-----------|---------|---------|
|
||||
| `hx-target` | Element to update | `hx-target="#results"` |
|
||||
| `hx-swap` | How to insert content | `hx-swap="innerHTML"` |
|
||||
| | | `hx-swap="outerHTML"` |
|
||||
| | | `hx-swap="beforeend"` |
|
||||
| | | `hx-swap="afterbegin"` |
|
||||
|
||||
---
|
||||
|
||||
## Suite Architecture
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
ui/suite/
|
||||
├── index.html # Main entry point with navigation
|
||||
├── default.gbui # Full desktop layout
|
||||
├── single.gbui # Simple chat layout
|
||||
├── designer.html # Visual dialog designer
|
||||
├── editor.html # Code editor
|
||||
├── settings.html # User settings
|
||||
├── css/
|
||||
│ └── app.css # Global styles
|
||||
├── js/
|
||||
│ ├── layout.js # Layout management
|
||||
│ └── theme-manager.js # Theme switching
|
||||
├── chat/
|
||||
│ ├── chat.html # Chat component
|
||||
│ └── chat.css # Chat styles
|
||||
├── drive/
|
||||
│ └── index.html # File manager
|
||||
├── tasks/
|
||||
│ ├── tasks.html # Task manager
|
||||
│ └── tasks.css # Task styles
|
||||
├── mail/
|
||||
│ ├── mail.html # Email client
|
||||
│ └── mail.css # Email styles
|
||||
├── calendar/
|
||||
│ └── calendar.html # Calendar view
|
||||
├── meet/
|
||||
│ ├── meet.html # Video meetings
|
||||
│ └── meet.css # Meeting styles
|
||||
├── paper/
|
||||
│ └── paper.html # Document editor
|
||||
├── research/
|
||||
│ └── research.html # AI search
|
||||
├── analytics/
|
||||
│ └── analytics.html # Dashboards
|
||||
├── sources/
|
||||
│ └── index.html # Prompts & templates
|
||||
├── tools/
|
||||
│ └── compliance.html # Security scanner
|
||||
└── monitoring/
|
||||
└── ... # System monitoring
|
||||
```
|
||||
|
||||
### Loading Pattern
|
||||
|
||||
The Suite uses **lazy loading** - components load only when needed:
|
||||
|
||||
```html
|
||||
<!-- Main navigation in index.html -->
|
||||
<a href="#chat"
|
||||
data-section="chat"
|
||||
hx-get="/ui/suite/chat/chat.html"
|
||||
hx-target="#main-content"
|
||||
hx-swap="innerHTML">
|
||||
Chat
|
||||
</a>
|
||||
```
|
||||
|
||||
When user clicks "Chat":
|
||||
1. HTMX requests `/ui/suite/chat/chat.html`
|
||||
2. Server returns the Chat HTML fragment
|
||||
3. HTMX inserts it into `#main-content`
|
||||
4. Only Chat code loads, not entire app
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### 1. Load on Page View
|
||||
|
||||
```html
|
||||
<!-- Tasks load immediately when component is shown -->
|
||||
<div id="task-list"
|
||||
hx-get="/api/tasks"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading">Loading tasks...</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Form Submission
|
||||
|
||||
```html
|
||||
<!-- Add task form -->
|
||||
<form hx-post="/api/tasks"
|
||||
hx-target="#task-list"
|
||||
hx-swap="afterbegin"
|
||||
hx-on::after-request="this.reset()">
|
||||
<input type="text" name="text" placeholder="New task..." required>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. User types task, clicks Add
|
||||
2. HTMX POSTs form data to `/api/tasks`
|
||||
3. Server creates task, returns HTML for new task item
|
||||
4. HTMX inserts at beginning of `#task-list`
|
||||
5. Form resets automatically
|
||||
|
||||
### 3. Click Actions
|
||||
|
||||
```html
|
||||
<!-- Task item with actions -->
|
||||
<div class="task-item" id="task-123">
|
||||
<input type="checkbox"
|
||||
hx-patch="/api/tasks/123"
|
||||
hx-vals='{"completed": true}'
|
||||
hx-target="#task-123"
|
||||
hx-swap="outerHTML">
|
||||
<span>Review quarterly report</span>
|
||||
<button hx-delete="/api/tasks/123"
|
||||
hx-target="#task-123"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this task?">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Search with Debounce
|
||||
|
||||
```html
|
||||
<!-- Search input with 300ms delay -->
|
||||
<input type="text"
|
||||
name="q"
|
||||
placeholder="Search..."
|
||||
hx-get="/api/search"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#search-results"
|
||||
hx-indicator="#search-spinner">
|
||||
|
||||
<span id="search-spinner" class="htmx-indicator">🔄</span>
|
||||
<div id="search-results"></div>
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. User types in search box
|
||||
2. After 300ms of no typing, HTMX sends request
|
||||
3. Spinner shows during request
|
||||
4. Results replace `#search-results` content
|
||||
|
||||
### 5. Real-time Updates (WebSocket)
|
||||
|
||||
```html
|
||||
<!-- Chat with WebSocket -->
|
||||
<div id="chat-app" hx-ext="ws" ws-connect="/ws">
|
||||
<div id="messages"
|
||||
hx-get="/api/sessions/current/history"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
<form ws-send>
|
||||
<input name="content" type="text">
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. WebSocket connects on load
|
||||
2. History loads via HTMX GET
|
||||
3. New messages sent via WebSocket (`ws-send`)
|
||||
4. Server pushes updates to all connected clients
|
||||
|
||||
### 6. Polling for Updates
|
||||
|
||||
```html
|
||||
<!-- Analytics that refresh every 30 seconds -->
|
||||
<div class="metric-card"
|
||||
hx-get="/api/analytics/messages/count"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Content updates automatically -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### 7. Infinite Scroll
|
||||
|
||||
```html
|
||||
<!-- File list with infinite scroll -->
|
||||
<div id="file-list">
|
||||
<!-- Files here -->
|
||||
|
||||
<div hx-get="/api/files?page=2"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="afterend">
|
||||
Loading more...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Response Patterns
|
||||
|
||||
### Server Returns HTML Fragments
|
||||
|
||||
The server doesn't return JSON - it returns ready-to-display HTML:
|
||||
|
||||
**Request:**
|
||||
```
|
||||
GET /api/tasks
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```html
|
||||
<div class="task-item" id="task-1">
|
||||
<input type="checkbox">
|
||||
<span>Review quarterly report</span>
|
||||
</div>
|
||||
<div class="task-item" id="task-2">
|
||||
<input type="checkbox">
|
||||
<span>Update documentation</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Swap Strategies
|
||||
|
||||
| Strategy | Effect |
|
||||
|----------|--------|
|
||||
| `innerHTML` | Replace contents of target |
|
||||
| `outerHTML` | Replace entire target element |
|
||||
| `beforeend` | Append inside target (at end) |
|
||||
| `afterbegin` | Prepend inside target (at start) |
|
||||
| `beforebegin` | Insert before target |
|
||||
| `afterend` | Insert after target |
|
||||
| `delete` | Delete target element |
|
||||
| `none` | Don't swap (for side effects) |
|
||||
|
||||
---
|
||||
|
||||
## CSS Integration
|
||||
|
||||
### Loading Indicators
|
||||
|
||||
```css
|
||||
/* Hidden by default */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Shown during request */
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Or when indicator IS the requesting element */
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline-block;
|
||||
}
|
||||
```
|
||||
|
||||
### Transition Effects
|
||||
|
||||
```css
|
||||
/* Fade in new content */
|
||||
.htmx-settling {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.htmx-swapping {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JavaScript Integration
|
||||
|
||||
### HTMX Events
|
||||
|
||||
```javascript
|
||||
// After any HTMX swap
|
||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||
console.log('Content updated:', e.detail.target);
|
||||
});
|
||||
|
||||
// Before request
|
||||
document.body.addEventListener('htmx:beforeRequest', (e) => {
|
||||
console.log('Sending request to:', e.detail.pathInfo.path);
|
||||
});
|
||||
|
||||
// After request completes
|
||||
document.body.addEventListener('htmx:afterRequest', (e) => {
|
||||
if (e.detail.successful) {
|
||||
console.log('Request succeeded');
|
||||
} else {
|
||||
console.error('Request failed');
|
||||
}
|
||||
});
|
||||
|
||||
// On WebSocket message
|
||||
document.body.addEventListener('htmx:wsAfterMessage', (e) => {
|
||||
console.log('Received:', e.detail.message);
|
||||
});
|
||||
```
|
||||
|
||||
### Triggering HTMX from JavaScript
|
||||
|
||||
```javascript
|
||||
// Trigger an HTMX request programmatically
|
||||
htmx.trigger('#task-list', 'load');
|
||||
|
||||
// Make an AJAX request
|
||||
htmx.ajax('GET', '/api/tasks', {
|
||||
target: '#task-list',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
|
||||
// Process new HTMX content
|
||||
htmx.process(document.getElementById('new-content'));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Designer Page Architecture
|
||||
|
||||
The visual dialog designer uses a hybrid approach:
|
||||
|
||||
### Canvas Management (JavaScript)
|
||||
```javascript
|
||||
// State managed in JavaScript
|
||||
const state = {
|
||||
nodes: new Map(), // Node data
|
||||
connections: [], // Connections between nodes
|
||||
zoom: 1, // Canvas zoom level
|
||||
pan: { x: 0, y: 0 } // Canvas position
|
||||
};
|
||||
```
|
||||
|
||||
### File Operations (HTMX)
|
||||
```html
|
||||
<!-- Load file via HTMX -->
|
||||
<button hx-get="/api/v1/designer/files"
|
||||
hx-target="#file-list-content">
|
||||
Open File
|
||||
</button>
|
||||
|
||||
<!-- Save via HTMX -->
|
||||
<button hx-post="/api/v1/designer/save"
|
||||
hx-include="#designer-data">
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
### Drag-and-Drop (JavaScript)
|
||||
```javascript
|
||||
// Toolbox items are draggable
|
||||
toolboxItems.forEach(item => {
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('nodeType', item.dataset.nodeType);
|
||||
});
|
||||
});
|
||||
|
||||
// Canvas handles drop
|
||||
canvas.addEventListener('drop', (e) => {
|
||||
const nodeType = e.dataTransfer.getData('nodeType');
|
||||
createNode(nodeType, e.clientX, e.clientY);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Minimize Request Size
|
||||
|
||||
Return only what's needed:
|
||||
```html
|
||||
<!-- Good: Return just the updated row -->
|
||||
<tr id="row-123">...</tr>
|
||||
|
||||
<!-- Bad: Return entire table -->
|
||||
<table>...</table>
|
||||
```
|
||||
|
||||
### 2. Use Appropriate Triggers
|
||||
|
||||
```html
|
||||
<!-- Don't poll too frequently -->
|
||||
hx-trigger="every 30s" <!-- Good for dashboards -->
|
||||
hx-trigger="every 1s" <!-- Too frequent! -->
|
||||
|
||||
<!-- Debounce user input -->
|
||||
hx-trigger="keyup changed delay:300ms" <!-- Good -->
|
||||
hx-trigger="keyup" <!-- Too many requests -->
|
||||
```
|
||||
|
||||
### 3. Lazy Load Heavy Content
|
||||
|
||||
```html
|
||||
<!-- Load tab content only when tab is clicked -->
|
||||
<div role="tabpanel"
|
||||
hx-get="/api/heavy-content"
|
||||
hx-trigger="intersect once">
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Use `hx-boost` for Navigation
|
||||
|
||||
```html
|
||||
<!-- Boost all links in nav -->
|
||||
<nav hx-boost="true">
|
||||
<a href="/page1">Page 1</a> <!-- Now uses HTMX -->
|
||||
<a href="/page2">Page 2</a>
|
||||
</nav>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
HTMX automatically includes CSRF tokens:
|
||||
|
||||
```html
|
||||
<meta name="csrf-token" content="abc123...">
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Configure HTMX to send CSRF token
|
||||
document.body.addEventListener('htmx:configRequest', (e) => {
|
||||
e.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
|
||||
});
|
||||
```
|
||||
|
||||
### Content Security
|
||||
|
||||
- Server validates all inputs
|
||||
- HTML is sanitized before rendering
|
||||
- Authentication checked on every request
|
||||
|
||||
---
|
||||
|
||||
## Comparison: HTMX vs React
|
||||
|
||||
| Aspect | HTMX | React |
|
||||
|--------|------|-------|
|
||||
| **Learning Curve** | Low (HTML attributes) | High (JSX, hooks, state) |
|
||||
| **Bundle Size** | ~14KB | ~40KB + app code |
|
||||
| **Build Step** | None | Required |
|
||||
| **Server Load** | More (renders HTML) | Less (returns JSON) |
|
||||
| **Client Load** | Less | More |
|
||||
| **SEO** | Excellent | Requires SSR |
|
||||
| **Complexity** | Simple | Complex |
|
||||
| **Best For** | Content sites, dashboards | Complex SPAs, offline apps |
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [HTMX Official Documentation](https://htmx.org/docs/)
|
||||
- [HTMX Examples](https://htmx.org/examples/)
|
||||
- [Hypermedia Systems (Book)](https://hypermedia.systems/)
|
||||
- [Chapter 04: UI Reference](./README.md)
|
||||
1138
docs/src/chapter-04-gbui/suite-manual.md
Normal file
1138
docs/src/chapter-04-gbui/suite-manual.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1462,4 +1462,670 @@
|
|||
// Zoom with scroll
|
||||
container.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY >
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1650,4 +1650,214 @@
|
|||
<div class="item-card">
|
||||
<div class="item-header">
|
||||
<div class="item-icon bg-orange">
|
||||
<svg viewBox="0 0 24 24" fill="none"
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 16v-4M12 8h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="item-title-section">
|
||||
<div class="item-title">Gemini 1.5 Pro</div>
|
||||
<div class="item-category">Google</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="item-description">Google's most capable model with 1M context window.</p>
|
||||
<div class="item-footer">
|
||||
<div class="item-tags">
|
||||
<span class="item-tag">1M context</span>
|
||||
<span class="item-tag">Multimodal</span>
|
||||
</div>
|
||||
<button class="item-action">Select</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-card">
|
||||
<div class="item-header">
|
||||
<div class="item-icon bg-cyan">
|
||||
<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>
|
||||
</div>
|
||||
<div class="item-title-section">
|
||||
<div class="item-title">Llama 3.1 405B</div>
|
||||
<div class="item-category">Meta</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="item-description">Open source model with strong performance across tasks.</p>
|
||||
<div class="item-footer">
|
||||
<div class="item-tags">
|
||||
<span class="item-tag">128K context</span>
|
||||
<span class="item-tag">Open Source</span>
|
||||
</div>
|
||||
<button class="item-action">Select</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching functionality
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
// Add active class to clicked tab
|
||||
tab.classList.add('active');
|
||||
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show corresponding tab content
|
||||
const tabId = tab.dataset.tab;
|
||||
const content = document.getElementById(`tab-${tabId}`);
|
||||
if (content) {
|
||||
content.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Category selection
|
||||
document.querySelectorAll('.category-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
document.querySelectorAll('.category-item').forEach(i => i.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
// Trigger HTMX request with category filter
|
||||
const category = item.dataset.category;
|
||||
htmx.ajax('GET', `/api/v1/sources/prompts?category=${category}`, {
|
||||
target: '#prompts-grid',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Search with debounce
|
||||
let searchTimeout;
|
||||
document.getElementById('search-input')?.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const query = e.target.value;
|
||||
const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'prompts';
|
||||
htmx.ajax('GET', `/api/v1/sources/${activeTab}?search=${encodeURIComponent(query)}`, {
|
||||
target: `#${activeTab}-grid`,
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Filter dropdown
|
||||
document.getElementById('filter-select')?.addEventListener('change', (e) => {
|
||||
const filter = e.target.value;
|
||||
const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'prompts';
|
||||
htmx.ajax('GET', `/api/v1/sources/${activeTab}?sort=${filter}`, {
|
||||
target: `#${activeTab}-grid`,
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl/Cmd + K to focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
document.getElementById('search-input')?.focus();
|
||||
}
|
||||
|
||||
// Escape to clear search
|
||||
if (e.key === 'Escape') {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput && document.activeElement === searchInput) {
|
||||
searchInput.value = '';
|
||||
searchInput.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// Tab navigation with numbers 1-5
|
||||
if (e.key >= '1' && e.key <= '5' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const index = parseInt(e.key) - 1;
|
||||
if (tabs[index]) {
|
||||
tabs[index].click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Item card actions
|
||||
document.addEventListener('click', (e) => {
|
||||
const actionBtn = e.target.closest('.item-action');
|
||||
if (actionBtn) {
|
||||
const card = actionBtn.closest('.item-card');
|
||||
const title = card?.querySelector('.item-title')?.textContent;
|
||||
const category = card?.querySelector('.item-category')?.textContent;
|
||||
|
||||
// Determine action based on active tab
|
||||
const activeTab = document.querySelector('.tab.active')?.dataset.tab;
|
||||
switch (activeTab) {
|
||||
case 'prompts':
|
||||
// Copy prompt to clipboard or use it
|
||||
console.log('Using prompt:', title);
|
||||
break;
|
||||
case 'templates':
|
||||
// Navigate to template or install it
|
||||
console.log('Opening template:', title);
|
||||
break;
|
||||
case 'mcp':
|
||||
// Connect to MCP server
|
||||
console.log('Connecting to MCP server:', title);
|
||||
break;
|
||||
case 'tools':
|
||||
// Enable/disable tool
|
||||
console.log('Toggling tool:', title);
|
||||
break;
|
||||
case 'models':
|
||||
// Select model
|
||||
console.log('Selecting model:', title);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize HTMX event listeners
|
||||
document.body.addEventListener('htmx:beforeRequest', (e) => {
|
||||
// Show loading state
|
||||
const target = e.detail.target;
|
||||
if (target) {
|
||||
target.classList.add('loading');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||
// Remove loading state
|
||||
const target = e.detail.target;
|
||||
if (target) {
|
||||
target.classList.remove('loading');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', (e) => {
|
||||
console.error('HTMX request failed:', e.detail.error);
|
||||
// Show error state
|
||||
const target = e.detail.target;
|
||||
if (target) {
|
||||
target.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="48" height="48">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<h3>Failed to load content</h3>
|
||||
<p>Please try again later.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue