Implement Workflow Builder and Refactor UI Tests: Add visual workflow builder for dynamic task management, enhance Playwright tests to improve UI automation coverage, and introduce a new base HTML template.

This commit is contained in:
2026-01-09 15:35:13 +00:00
parent 9c2fdee2f8
commit 8cdd656968
8 changed files with 1679 additions and 429 deletions

View File

@@ -29,4 +29,6 @@ model: openai/gpt-4o
# Test Comment
# Test Comment
# Test Comment
# Test Comment
# Test Comment
# Test Comment

View File

@@ -4,6 +4,7 @@ import secrets
from fastapi import FastAPI, Request, Form, BackgroundTasks, Depends, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from dotenv import load_dotenv, set_key
import subprocess
@@ -39,6 +40,11 @@ mock_running = False
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
templates = Jinja2Templates(directory=templates_dir)
# Setup static files
static_dir = os.path.join(os.path.dirname(__file__), "static")
if os.path.exists(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static")
def run_bot_task():
global bot_process, mock_running
if os.environ.get("MOCK_WEB_UI") == "true":

View File

@@ -0,0 +1,640 @@
/* ==========================================================================
AutoMetabuilder - Main Stylesheet
========================================================================== */
/* ==========================================================================
1. CSS Custom Properties (Theme Variables)
========================================================================== */
:root {
/* Light mode (default) */
--amb-bg-primary: #f8f9fa;
--amb-bg-secondary: #ffffff;
--amb-bg-tertiary: #e9ecef;
--amb-text-primary: #212529;
--amb-text-secondary: #495057;
--amb-text-muted: #6c757d;
--amb-border-color: #dee2e6;
--amb-card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--amb-sidebar-bg: #212529;
--amb-sidebar-text: #adb5bd;
--amb-sidebar-text-active: #ffffff;
--amb-sidebar-hover: rgba(255, 255, 255, 0.1);
--amb-code-bg: #f1f3f5;
--amb-accent: #0d6efd;
--amb-accent-hover: #0b5ed7;
--amb-success: #198754;
--amb-warning: #ffc107;
--amb-danger: #dc3545;
--amb-sidebar-width: 260px;
--amb-transition: 0.2s ease;
}
/* Dark mode */
[data-theme="dark"] {
--amb-bg-primary: #1a1d21;
--amb-bg-secondary: #212529;
--amb-bg-tertiary: #2c3034;
--amb-text-primary: #f8f9fa;
--amb-text-secondary: #ced4da;
--amb-text-muted: #adb5bd;
--amb-border-color: #495057;
--amb-card-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3);
--amb-sidebar-bg: #0d1117;
--amb-sidebar-text: #8b949e;
--amb-sidebar-text-active: #f0f6fc;
--amb-sidebar-hover: rgba(255, 255, 255, 0.05);
--amb-code-bg: #2d333b;
--amb-accent: #58a6ff;
--amb-accent-hover: #79b8ff;
--amb-success: #3fb950;
--amb-warning: #d29922;
--amb-danger: #f85149;
}
/* ==========================================================================
2. Base Styles
========================================================================== */
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body {
margin: 0;
padding: 0;
min-height: 100%;
background-color: var(--amb-bg-primary);
color: var(--amb-text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
transition: background-color var(--amb-transition), color var(--amb-transition);
}
/* ==========================================================================
3. Layout
========================================================================== */
.amb-layout {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.amb-sidebar {
width: var(--amb-sidebar-width);
background: var(--amb-sidebar-bg);
color: var(--amb-sidebar-text);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 1000;
transition: background-color var(--amb-transition);
}
.amb-sidebar-header {
padding: 1.5rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.amb-sidebar-header h5 {
margin: 0;
color: var(--amb-sidebar-text-active);
font-weight: 600;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.amb-sidebar-header .bi {
font-size: 1.25rem;
}
/* Navigation */
.amb-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.amb-nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
color: var(--amb-sidebar-text);
text-decoration: none;
font-size: 0.9rem;
transition: all var(--amb-transition);
border-left: 3px solid transparent;
}
.amb-nav-link:hover {
background: var(--amb-sidebar-hover);
color: var(--amb-sidebar-text-active);
}
.amb-nav-link.active {
background: var(--amb-sidebar-hover);
color: var(--amb-sidebar-text-active);
border-left-color: var(--amb-accent);
}
.amb-nav-link .bi {
font-size: 1.1rem;
width: 1.25rem;
text-align: center;
}
/* Sidebar Footer */
.amb-sidebar-footer {
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.85rem;
}
.amb-sidebar-footer .amb-user {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--amb-sidebar-text);
}
.amb-theme-toggle {
background: transparent;
border: none;
color: var(--amb-sidebar-text);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.375rem;
transition: all var(--amb-transition);
display: flex;
align-items: center;
justify-content: center;
}
.amb-theme-toggle:hover {
background: var(--amb-sidebar-hover);
color: var(--amb-sidebar-text-active);
}
.amb-theme-toggle .bi {
font-size: 1.1rem;
}
/* Main Content */
.amb-main {
flex: 1;
margin-left: var(--amb-sidebar-width);
padding: 2rem;
min-height: 100vh;
}
/* Sections */
.amb-section {
display: none;
}
.amb-section.active {
display: block;
}
.amb-section-header {
margin-bottom: 1.5rem;
}
.amb-section-header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.75rem;
font-weight: 600;
}
.amb-section-header p {
margin: 0;
color: var(--amb-text-muted);
}
/* ==========================================================================
4. Cards
========================================================================== */
.amb-card {
background: var(--amb-bg-secondary);
border: 1px solid var(--amb-border-color);
border-radius: 0.75rem;
box-shadow: var(--amb-card-shadow);
margin-bottom: 1.5rem;
transition: background-color var(--amb-transition), border-color var(--amb-transition), box-shadow var(--amb-transition);
}
.amb-card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--amb-border-color);
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.amb-card-header h5 {
margin: 0;
font-size: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.amb-card-body {
padding: 1.25rem;
}
.amb-card-footer {
padding: 1rem 1.25rem;
border-top: 1px solid var(--amb-border-color);
background: var(--amb-bg-tertiary);
border-radius: 0 0 0.75rem 0.75rem;
}
/* ==========================================================================
5. Forms
========================================================================== */
.amb-form-group {
margin-bottom: 1.25rem;
}
.amb-form-label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.amb-required {
color: var(--amb-danger);
margin-left: 0.25rem;
}
/* Form control overrides for dark mode */
[data-theme="dark"] .form-control,
[data-theme="dark"] .form-select {
background-color: var(--amb-bg-tertiary);
border-color: var(--amb-border-color);
color: var(--amb-text-primary);
}
[data-theme="dark"] .form-control:focus,
[data-theme="dark"] .form-select:focus {
background-color: var(--amb-bg-tertiary);
border-color: var(--amb-accent);
color: var(--amb-text-primary);
box-shadow: 0 0 0 0.25rem rgba(88, 166, 255, 0.25);
}
[data-theme="dark"] .form-control::placeholder {
color: var(--amb-text-muted);
}
[data-theme="dark"] .form-control:disabled,
[data-theme="dark"] .form-select:disabled {
background-color: var(--amb-bg-primary);
opacity: 0.65;
}
/* Validation states */
.form-control.is-valid,
.form-select.is-valid {
border-color: var(--amb-success);
}
.form-control.is-invalid,
.form-select.is-invalid {
border-color: var(--amb-danger);
}
/* ==========================================================================
6. Choices.js Overrides
========================================================================== */
.choices {
margin-bottom: 0;
}
.choices__inner {
background-color: var(--amb-bg-secondary);
border-color: var(--amb-border-color);
border-radius: 0.375rem;
min-height: 38px;
padding: 0.375rem 0.75rem;
transition: border-color var(--amb-transition), box-shadow var(--amb-transition);
}
.choices__inner:focus-within {
border-color: var(--amb-accent);
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.choices__list--dropdown {
background-color: var(--amb-bg-secondary);
border-color: var(--amb-border-color);
border-radius: 0.375rem;
box-shadow: var(--amb-card-shadow);
z-index: 100;
}
.choices__list--dropdown .choices__item--selectable.is-highlighted {
background-color: var(--amb-accent);
color: #fff;
}
.choices__input {
background-color: transparent !important;
color: var(--amb-text-primary);
}
.choices__placeholder {
color: var(--amb-text-muted);
opacity: 1;
}
.choices[data-type*="select-one"] .choices__input {
background-color: var(--amb-bg-secondary) !important;
}
/* Dark mode Choices.js */
[data-theme="dark"] .choices__inner {
background-color: var(--amb-bg-tertiary);
border-color: var(--amb-border-color);
}
[data-theme="dark"] .choices__inner:focus-within {
border-color: var(--amb-accent);
box-shadow: 0 0 0 0.25rem rgba(88, 166, 255, 0.25);
}
[data-theme="dark"] .choices__list--dropdown {
background-color: var(--amb-bg-secondary);
border-color: var(--amb-border-color);
}
[data-theme="dark"] .choices__list--dropdown .choices__item {
color: var(--amb-text-primary);
}
[data-theme="dark"] .choices[data-type*="select-one"] .choices__input {
background-color: var(--amb-bg-tertiary) !important;
color: var(--amb-text-primary);
}
/* ==========================================================================
7. Tables
========================================================================== */
.amb-table {
background: var(--amb-bg-secondary);
}
[data-theme="dark"] .table {
--bs-table-bg: var(--amb-bg-secondary);
--bs-table-color: var(--amb-text-primary);
--bs-table-border-color: var(--amb-border-color);
--bs-table-striped-bg: var(--amb-bg-tertiary);
--bs-table-hover-bg: var(--amb-bg-tertiary);
}
/* ==========================================================================
8. Logs Panel
========================================================================== */
.amb-logs {
background: var(--amb-code-bg);
border: 1px solid var(--amb-border-color);
border-radius: 0.5rem;
padding: 1rem;
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
font-size: 0.8rem;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
transition: background-color var(--amb-transition), border-color var(--amb-transition);
}
/* ==========================================================================
9. Status Indicators
========================================================================== */
.amb-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.9rem;
}
.amb-status-idle {
background: rgba(25, 135, 84, 0.1);
color: var(--amb-success);
}
.amb-status-running {
background: rgba(255, 193, 7, 0.1);
color: var(--amb-warning);
}
.amb-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.amb-status-running .amb-status-dot {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ==========================================================================
10. Workflow Builder
========================================================================== */
.amb-workflow-task {
background: var(--amb-bg-secondary);
border: 1px solid var(--amb-border-color);
border-radius: 0.75rem;
margin-bottom: 1rem;
box-shadow: var(--amb-card-shadow);
transition: background-color var(--amb-transition), border-color var(--amb-transition);
}
.amb-workflow-task-header {
background: var(--amb-accent);
color: #fff;
padding: 0.75rem 1rem;
border-radius: 0.75rem 0.75rem 0 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.amb-workflow-task-body {
padding: 1rem;
}
.amb-workflow-step {
background: var(--amb-bg-tertiary);
border: 1px solid var(--amb-border-color);
border-radius: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
transition: background-color var(--amb-transition), border-color var(--amb-transition);
}
.amb-workflow-step:last-child {
margin-bottom: 0;
}
.amb-workflow-step-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
/* ==========================================================================
11. Buttons
========================================================================== */
[data-theme="dark"] .btn-light {
background-color: var(--amb-bg-tertiary);
border-color: var(--amb-border-color);
color: var(--amb-text-primary);
}
[data-theme="dark"] .btn-light:hover {
background-color: var(--amb-bg-secondary);
border-color: var(--amb-border-color);
color: var(--amb-text-primary);
}
[data-theme="dark"] .btn-outline-primary {
color: var(--amb-accent);
border-color: var(--amb-accent);
}
[data-theme="dark"] .btn-outline-primary:hover {
background-color: var(--amb-accent);
border-color: var(--amb-accent);
color: #fff;
}
[data-theme="dark"] .btn-outline-secondary {
color: var(--amb-text-secondary);
border-color: var(--amb-border-color);
}
[data-theme="dark"] .btn-outline-secondary:hover {
background-color: var(--amb-bg-tertiary);
border-color: var(--amb-border-color);
color: var(--amb-text-primary);
}
/* ==========================================================================
12. Badges
========================================================================== */
[data-theme="dark"] .badge.bg-light {
background-color: var(--amb-bg-tertiary) !important;
color: var(--amb-text-primary) !important;
}
/* ==========================================================================
13. List Groups
========================================================================== */
[data-theme="dark"] .list-group-item {
background-color: var(--amb-bg-secondary);
border-color: var(--amb-border-color);
color: var(--amb-text-primary);
}
/* ==========================================================================
14. Input Groups
========================================================================== */
[data-theme="dark"] .input-group-text {
background-color: var(--amb-bg-tertiary);
border-color: var(--amb-border-color);
color: var(--amb-text-primary);
}
/* ==========================================================================
15. Textarea (Code Editor)
========================================================================== */
.amb-code-editor {
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
font-size: 0.85rem;
line-height: 1.6;
background: var(--amb-code-bg);
border: 1px solid var(--amb-border-color);
border-radius: 0.5rem;
padding: 1rem;
resize: vertical;
transition: background-color var(--amb-transition), border-color var(--amb-transition);
}
[data-theme="dark"] .amb-code-editor {
background: var(--amb-code-bg);
color: var(--amb-text-primary);
}
/* ==========================================================================
16. Utilities
========================================================================== */
.amb-scrollable {
max-height: 400px;
overflow-y: auto;
}
.amb-gap-2 {
gap: 0.5rem;
}
.amb-gap-3 {
gap: 1rem;
}
/* ==========================================================================
17. Responsive
========================================================================== */
@media (max-width: 991.98px) {
.amb-sidebar {
transform: translateX(-100%);
transition: transform var(--amb-transition);
}
.amb-sidebar.show {
transform: translateX(0);
}
.amb-main {
margin-left: 0;
}
}
/* ==========================================================================
18. Transitions
========================================================================== */
.amb-fade-in {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -0,0 +1,308 @@
/**
* AutoMetabuilder - Main JavaScript
*/
/* ==========================================================================
Theme Manager
========================================================================== */
const ThemeManager = {
STORAGE_KEY: 'amb-theme',
init() {
const saved = localStorage.getItem(this.STORAGE_KEY);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
this.setTheme(theme);
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
btn.addEventListener('click', () => this.toggle());
});
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', e => {
if (!localStorage.getItem(this.STORAGE_KEY)) {
this.setTheme(e.matches ? 'dark' : 'light');
}
});
},
setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(this.STORAGE_KEY, theme);
this.updateToggleIcon(theme);
},
toggle() {
const current = document.documentElement.getAttribute('data-theme');
this.setTheme(current === 'dark' ? 'light' : 'dark');
},
updateToggleIcon(theme) {
document.querySelectorAll('[data-theme-toggle] i').forEach(icon => {
icon.className = theme === 'dark' ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
});
}
};
/* ==========================================================================
Navigation Manager
========================================================================== */
const NavigationManager = {
init() {
document.querySelectorAll('[data-section]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.dataset.section;
this.showSection(target);
});
});
// Handle initial hash
const hash = window.location.hash.slice(1);
if (hash && document.querySelector(`#${hash}`)) {
this.showSection(hash);
}
// Handle browser back/forward
window.addEventListener('popstate', () => {
const hash = window.location.hash.slice(1);
if (hash && document.querySelector(`#${hash}`)) {
this.showSection(hash, false);
}
});
},
showSection(sectionId, updateHistory = true) {
// Hide all sections
document.querySelectorAll('.amb-section').forEach(s => s.classList.remove('active'));
// Show target section
const targetSection = document.querySelector(`#${sectionId}`);
if (targetSection) {
targetSection.classList.add('active');
}
// Update nav active state
document.querySelectorAll('.amb-nav-link').forEach(n => n.classList.remove('active'));
const activeLink = document.querySelector(`[data-section="${sectionId}"]`);
if (activeLink) {
activeLink.classList.add('active');
}
// Update URL hash
if (updateHistory) {
history.pushState(null, '', `#${sectionId}`);
}
}
};
/* ==========================================================================
Choices Manager
========================================================================== */
const ChoicesManager = {
instances: [],
init() {
this.initAll();
},
initAll() {
document.querySelectorAll('[data-choices]').forEach(el => {
// Skip if already initialized
if (el.classList.contains('choices__input')) return;
const optionsStr = el.dataset.choicesOptions || '{}';
let options = {};
try {
options = JSON.parse(optionsStr);
} catch (e) {
console.warn('Invalid choices options:', optionsStr);
}
const instance = new Choices(el, {
searchEnabled: true,
shouldSort: false,
allowHTML: false,
removeItemButton: options.removeItemButton || false,
placeholder: true,
placeholderValue: el.dataset.placeholder || 'Select...',
searchPlaceholderValue: 'Type to search...',
noResultsText: 'No results found',
noChoicesText: 'No choices available',
...options
});
this.instances.push(instance);
});
},
destroy() {
this.instances.forEach(instance => {
try {
instance.destroy();
} catch (e) {
// Ignore errors from already destroyed instances
}
});
this.instances = [];
},
refresh() {
this.destroy();
this.initAll();
}
};
/* ==========================================================================
Form Validator
========================================================================== */
const FormValidator = {
init() {
document.querySelectorAll('form[data-validate]').forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
});
});
}
};
/* ==========================================================================
Status Poller
========================================================================== */
const StatusPoller = {
logsInterval: null,
statusInterval: null,
init() {
this.refreshLogs();
this.refreshStatus();
this.logsInterval = setInterval(() => this.refreshLogs(), 2000);
this.statusInterval = setInterval(() => this.refreshStatus(), 2000);
},
async refreshLogs() {
try {
const response = await fetch('/api/logs');
const data = await response.json();
const logsPre = document.getElementById('logs');
if (!logsPre) return;
const wasAtBottom = logsPre.scrollHeight - logsPre.clientHeight <= logsPre.scrollTop + 1;
logsPre.textContent = data.logs;
if (wasAtBottom) {
logsPre.scrollTop = logsPre.scrollHeight;
}
} catch (error) {
console.error('Error fetching logs:', error);
}
},
async refreshStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
// Update status indicator
const statusIndicator = document.getElementById('status-indicator');
if (statusIndicator) {
if (data.is_running) {
statusIndicator.className = 'amb-status amb-status-running';
statusIndicator.innerHTML = '<span class="amb-status-dot"></span> Running';
} else {
statusIndicator.className = 'amb-status amb-status-idle';
statusIndicator.innerHTML = '<span class="amb-status-dot"></span> Idle';
}
}
// Update MVP badge
const mvpBadge = document.getElementById('mvp-badge');
if (mvpBadge) {
if (data.mvp_reached) {
mvpBadge.className = 'badge bg-primary';
mvpBadge.innerHTML = '<i class="bi bi-check-circle-fill"></i> Reached';
} else {
mvpBadge.className = 'badge bg-secondary';
mvpBadge.innerHTML = '<i class="bi bi-hourglass-split"></i> In Progress';
}
}
// Update run button
const runBtn = document.getElementById('run-btn');
if (runBtn) {
runBtn.disabled = data.is_running;
}
// Update progress bar visibility
const progressBar = document.getElementById('status-progress');
if (progressBar) {
progressBar.style.display = data.is_running ? 'block' : 'none';
}
} catch (error) {
console.error('Error fetching status:', error);
}
}
};
/* ==========================================================================
Toast Notifications
========================================================================== */
const Toast = {
show(message, type = 'info') {
const container = document.getElementById('toast-container') || this.createContainer();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-bg-${type} border-0 show`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
container.appendChild(toast);
// Auto remove after 5 seconds
setTimeout(() => {
toast.remove();
}, 5000);
// Close button
toast.querySelector('.btn-close').addEventListener('click', () => toast.remove());
},
createContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
container.style.zIndex = '1100';
document.body.appendChild(container);
return container;
}
};
/* ==========================================================================
App Initialization
========================================================================== */
const App = {
init() {
ThemeManager.init();
NavigationManager.init();
ChoicesManager.init();
FormValidator.init();
StatusPoller.init();
}
};
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => App.init());
// Export for use in other scripts
window.App = App;
window.ThemeManager = ThemeManager;
window.NavigationManager = NavigationManager;
window.ChoicesManager = ChoicesManager;
window.FormValidator = FormValidator;
window.StatusPoller = StatusPoller;
window.Toast = Toast;

View File

@@ -0,0 +1,279 @@
/**
* AutoMetabuilder - Workflow Builder
*/
const WorkflowBuilder = {
workflow: [],
stepDefinitions: {},
allSuggestions: new Set(),
container: null,
textarea: null,
init(containerId, textareaId, stepDefinitions) {
this.container = document.getElementById(containerId);
this.textarea = document.getElementById(textareaId);
this.stepDefinitions = stepDefinitions || {};
// Build all suggestions set
Object.values(this.stepDefinitions).forEach(def => {
Object.values(def.fields || {}).forEach(f => {
if (f.suggestions) {
f.suggestions.forEach(s => this.allSuggestions.add(s));
}
});
});
// Parse initial workflow
try {
this.workflow = JSON.parse(this.textarea.value || '[]');
} catch (e) {
console.error('Failed to parse workflow JSON', e);
this.workflow = [];
}
this.render();
},
render() {
if (!this.container) return;
this.container.innerHTML = '';
this.workflow.forEach((task, taskIdx) => {
const taskCard = document.createElement('div');
taskCard.className = 'amb-workflow-task';
taskCard.innerHTML = `
<div class="amb-workflow-task-header">
<div class="d-flex align-items-center gap-2 flex-grow-1">
<input type="text" class="form-control form-control-sm" style="max-width: 200px;"
value="${this.escapeHtml(task.name || '')}"
data-choices data-placeholder="Task name"
onchange="WorkflowBuilder.updateTask(${taskIdx}, 'name', this.value)"
placeholder="Task Name">
<select class="form-select form-select-sm" style="max-width: 160px;"
onchange="WorkflowBuilder.updateTask(${taskIdx}, 'type', this.value)">
<option value="" ${!task.type ? 'selected' : ''}>Standard</option>
<option value="loop" ${task.type === 'loop' ? 'selected' : ''}>Loop</option>
</select>
${task.type === 'loop' ? `
<input type="number" class="form-control form-control-sm" style="max-width: 80px;"
value="${task.max_iterations || 1}"
onchange="WorkflowBuilder.updateTask(${taskIdx}, 'max_iterations', parseInt(this.value))"
title="Max Iterations" min="1">
` : ''}
</div>
<div class="d-flex align-items-center gap-1">
<button class="btn btn-sm btn-light" onclick="WorkflowBuilder.moveTask(${taskIdx}, -1)"
${taskIdx === 0 ? 'disabled' : ''} title="Move up">
<i class="bi bi-arrow-up"></i>
</button>
<button class="btn btn-sm btn-light" onclick="WorkflowBuilder.moveTask(${taskIdx}, 1)"
${taskIdx === this.workflow.length - 1 ? 'disabled' : ''} title="Move down">
<i class="bi bi-arrow-down"></i>
</button>
<button class="btn btn-sm btn-danger ms-1" onclick="WorkflowBuilder.removeTask(${taskIdx})"
title="Remove task">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="amb-workflow-task-body">
<div class="steps-container" id="steps-${taskIdx}"></div>
<button class="btn btn-sm btn-outline-primary mt-2" onclick="WorkflowBuilder.addStep(${taskIdx})">
<i class="bi bi-plus-lg"></i> Add Step
</button>
</div>
`;
this.container.appendChild(taskCard);
const stepsContainer = taskCard.querySelector(`#steps-${taskIdx}`);
(task.steps || []).forEach((step, stepIdx) => {
const stepDiv = this.createStepElement(task, taskIdx, step, stepIdx);
stepsContainer.appendChild(stepDiv);
});
});
// Add task button
const addTaskBtn = document.createElement('button');
addTaskBtn.className = 'btn btn-primary';
addTaskBtn.innerHTML = '<i class="bi bi-plus-lg"></i> Add Task';
addTaskBtn.onclick = () => this.addTask();
this.container.appendChild(addTaskBtn);
this.sync();
// Reinitialize Choices.js for new elements
if (window.ChoicesManager) {
window.ChoicesManager.refresh();
}
},
createStepElement(task, taskIdx, step, stepIdx) {
const stepDiv = document.createElement('div');
stepDiv.className = 'amb-workflow-step';
let fieldsHtml = `
<select class="form-select form-select-sm mb-2" data-choices
onchange="WorkflowBuilder.updateStepType(${taskIdx}, ${stepIdx}, this.value)">
<option value="">Select action type...</option>
${Object.entries(this.stepDefinitions).map(([type, def]) =>
`<option value="${type}" ${step.type === type ? 'selected' : ''}>${this.escapeHtml(def.label)}</option>`
).join('')}
</select>
`;
if (step.type && this.stepDefinitions[step.type]) {
fieldsHtml += '<div class="row g-2 mt-2">';
const def = this.stepDefinitions[step.type];
Object.entries(def.fields || {}).forEach(([field, fieldDef]) => {
const val = step[field] !== undefined ? step[field] : '';
const isCheckbox = fieldDef.type === 'checkbox';
const suggestions = fieldDef.suggestions || Array.from(this.allSuggestions);
fieldsHtml += `
<div class="col-md-6">
<label class="form-label small fw-semibold">${this.escapeHtml(fieldDef.label)}</label>
${isCheckbox ? `
<div class="form-check">
<input type="checkbox" class="form-check-input"
${step[field] ? 'checked' : ''}
onchange="WorkflowBuilder.updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.checked)">
</div>
` : `
<select class="form-select form-select-sm" data-choices
onchange="WorkflowBuilder.updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.value)">
<option value="">${this.escapeHtml(fieldDef.default || 'Select...')}</option>
${suggestions.map(s =>
`<option value="${this.escapeHtml(s)}" ${val === s ? 'selected' : ''}>${this.escapeHtml(s)}</option>`
).join('')}
</select>
`}
</div>
`;
});
fieldsHtml += '</div>';
}
stepDiv.innerHTML = `
<div class="amb-workflow-step-header">
<span class="badge bg-secondary">Step ${stepIdx + 1}</span>
<div class="d-flex align-items-center gap-1">
<button class="btn btn-sm btn-link p-0" onclick="WorkflowBuilder.moveStep(${taskIdx}, ${stepIdx}, -1)"
${stepIdx === 0 ? 'disabled' : ''} title="Move up">
<i class="bi bi-arrow-up"></i>
</button>
<button class="btn btn-sm btn-link p-0" onclick="WorkflowBuilder.moveStep(${taskIdx}, ${stepIdx}, 1)"
${stepIdx === (task.steps || []).length - 1 ? 'disabled' : ''} title="Move down">
<i class="bi bi-arrow-down"></i>
</button>
<button class="btn btn-sm btn-link p-0 text-danger" onclick="WorkflowBuilder.removeStep(${taskIdx}, ${stepIdx})"
title="Remove step">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
${fieldsHtml}
`;
return stepDiv;
},
sync() {
if (this.textarea) {
this.textarea.value = JSON.stringify(this.workflow, null, 2);
}
},
toggleRaw() {
if (!this.textarea) return;
this.textarea.classList.toggle('d-none');
if (!this.textarea.classList.contains('d-none')) {
this.textarea.oninput = () => {
try {
this.workflow = JSON.parse(this.textarea.value);
this.render();
} catch (e) {
// Invalid JSON, ignore
}
};
}
},
updateTask(idx, field, value) {
this.workflow[idx][field] = value;
if (field === 'type' && value !== 'loop') {
delete this.workflow[idx].max_iterations;
} else if (field === 'type' && value === 'loop' && !this.workflow[idx].max_iterations) {
this.workflow[idx].max_iterations = 10;
}
this.render();
},
addTask() {
this.workflow.push({ name: 'New Task', steps: [] });
this.render();
},
removeTask(idx) {
if (confirm('Remove this task?')) {
this.workflow.splice(idx, 1);
this.render();
}
},
moveTask(idx, dir) {
const target = idx + dir;
if (target >= 0 && target < this.workflow.length) {
[this.workflow[idx], this.workflow[target]] = [this.workflow[target], this.workflow[idx]];
this.render();
}
},
updateStepType(taskIdx, stepIdx, type) {
const newStep = { type: type };
if (this.stepDefinitions[type]) {
Object.entries(this.stepDefinitions[type].fields || {}).forEach(([field, fieldDef]) => {
newStep[field] = fieldDef.default;
});
}
this.workflow[taskIdx].steps[stepIdx] = newStep;
this.render();
},
updateStepField(taskIdx, stepIdx, field, value) {
this.workflow[taskIdx].steps[stepIdx][field] = value;
this.sync();
},
addStep(taskIdx) {
if (!this.workflow[taskIdx].steps) {
this.workflow[taskIdx].steps = [];
}
this.workflow[taskIdx].steps.push({ type: '' });
this.render();
},
removeStep(taskIdx, stepIdx) {
this.workflow[taskIdx].steps.splice(stepIdx, 1);
this.render();
},
moveStep(taskIdx, stepIdx, dir) {
const target = stepIdx + dir;
const steps = this.workflow[taskIdx].steps;
if (target >= 0 && target < steps.length) {
[steps[stepIdx], steps[target]] = [steps[target], steps[stepIdx]];
this.render();
}
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
// Export globally
window.WorkflowBuilder = WorkflowBuilder;

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AutoMetabuilder Dashboard{% endblock %}</title>
<!-- Bootstrap 5.3.3 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<!-- Choices.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css">
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/main.css">
{% block head %}{% endblock %}
</head>
<body class="amb-layout">
<!-- Sidebar -->
<aside class="amb-sidebar">
<div class="amb-sidebar-header">
<h5><i class="bi bi-cpu"></i> AutoMetabuilder</h5>
</div>
<nav class="amb-nav">
<a href="#dashboard" class="amb-nav-link active" data-section="dashboard">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<a href="#workflow" class="amb-nav-link" data-section="workflow">
<i class="bi bi-diagram-3"></i> Workflow
</a>
<a href="#prompt" class="amb-nav-link" data-section="prompt">
<i class="bi bi-file-text"></i> Prompt
</a>
<a href="#settings" class="amb-nav-link" data-section="settings">
<i class="bi bi-gear"></i> Settings
</a>
<a href="#translations" class="amb-nav-link" data-section="translations">
<i class="bi bi-translate"></i> Translations
</a>
</nav>
<div class="amb-sidebar-footer">
<div class="amb-user">
<i class="bi bi-person-circle"></i>
<span>{{ username }}</span>
</div>
<button class="amb-theme-toggle" data-theme-toggle title="Toggle theme">
<i class="bi bi-sun-fill"></i>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="amb-main">
{% block content %}{% endblock %}
</main>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Choices.js -->
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"></script>
<!-- Custom Scripts -->
<script src="/static/js/main.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,381 +1,240 @@
<!DOCTYPE html>
<html>
<head>
<title>AutoMetabuilder Dashboard</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body class="container mt-5">
<div class="d-flex justify-content-between align-items-center">
<h1>AutoMetabuilder Dashboard</h1>
<span>Logged in as: <strong>{{ username }}</strong></span>
{% extends "base.html" %}
{% block content %}
<!-- Dashboard Section -->
<section id="dashboard" class="amb-section active">
<div class="amb-section-header">
<h1>Dashboard</h1>
<p>Monitor system status and recent activity</p>
</div>
<div class="row mt-4">
<div class="col-md-8">
<h2>Recent Logs</h2>
<pre id="logs" class="bg-light p-3 border" style="max-height: 400px; overflow-y: scroll;">{{ logs }}</pre>
</div>
<div class="col-md-4">
<h2>System Status</h2>
<div id="status-container">
<p>Status:
{% if is_running %}
<span class="badge bg-warning text-dark">Bot Running...</span>
{% else %}
<span class="badge bg-success">Idle</span>
{% endif %}
</p>
<p>MVP Milestone:
{% if mvp_reached %}
<span class="badge bg-primary">Reached ✓</span>
{% else %}
<span class="badge bg-secondary">In Progress</span>
{% endif %}
</p>
<div class="row">
<!-- Status Card -->
<div class="col-lg-4 col-md-6">
<div class="amb-card">
<div class="amb-card-header">
<h5><i class="bi bi-activity"></i> System Status</h5>
</div>
<div class="amb-card-body">
<div class="d-flex flex-column gap-3">
<div class="d-flex align-items-center justify-content-between">
<span class="text-muted">Bot Status</span>
<div id="status-indicator" class="amb-status {% if is_running %}amb-status-running{% else %}amb-status-idle{% endif %}">
<span class="amb-status-dot"></span>
{% if is_running %}Running{% else %}Idle{% endif %}
</div>
</div>
<div class="d-flex align-items-center justify-content-between">
<span class="text-muted">MVP Milestone</span>
<span id="mvp-badge" class="badge {% if mvp_reached %}bg-primary{% else %}bg-secondary{% endif %}">
{% if mvp_reached %}
<i class="bi bi-check-circle-fill"></i> Reached
{% else %}
<i class="bi bi-hourglass-split"></i> In Progress
{% endif %}
</span>
</div>
<div id="status-progress" class="progress mt-2" style="display: {% if is_running %}block{% else %}none{% endif %};">
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
</div>
</div>
</div>
<div class="amb-card-footer">
<form action="/run" method="post" class="m-0">
<button id="run-btn" type="submit" class="btn btn-danger w-100" {% if is_running %}disabled{% endif %}>
<i class="bi bi-play-fill"></i> Run Bot One Iteration
</button>
</form>
</div>
</div>
<form action="/run" method="post" class="mt-2">
<button id="run-btn" type="submit" class="btn btn-danger w-100" {% if is_running %}disabled{% endif %}>
Run Bot One Iteration
</button>
</form>
<h2 class="mt-4">Translations</h2>
<ul class="list-group">
{% for lang, file in translations.items() %}
<li class="list-group-item">{{ lang }} ({{ file }})</li>
{% endfor %}
</ul>
<form action="/translations" method="post" class="mt-2">
<div class="input-group">
<input type="text" name="lang" class="form-control" placeholder="New lang code (e.g. de)" list="lang-suggestions">
<datalist id="lang-suggestions">
{% for lang in metadata.suggestions.languages %}
<option value="{{ lang }}">
{% endfor %}
</datalist>
<button class="btn btn-primary" type="submit">Add</button>
</div>
<!-- Logs Card -->
<div class="col-lg-8 col-md-6">
<div class="amb-card">
<div class="amb-card-header">
<h5><i class="bi bi-terminal"></i> Recent Logs</h5>
</div>
<div class="amb-card-body p-0">
<pre id="logs" class="amb-logs m-0">{{ logs }}</pre>
</div>
</div>
</div>
</div>
</section>
<!-- Workflow Section -->
<section id="workflow" class="amb-section">
<div class="amb-section-header">
<h1>Workflow Builder</h1>
<p>Configure the bot's task execution workflow</p>
</div>
<div class="amb-card">
<div class="amb-card-body">
<div id="workflow-builder" class="mb-3">
<!-- Visual builder will be rendered here -->
</div>
<form action="/workflow" method="post" id="workflow-form" data-validate>
<textarea id="workflow-content" name="content" class="form-control amb-code-editor d-none" rows="15">{{ workflow_content }}</textarea>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-success">
<i class="bi bi-save"></i> Save Workflow
</button>
<button type="button" class="btn btn-outline-secondary" onclick="WorkflowBuilder.toggleRaw()">
<i class="bi bi-code-slash"></i> Toggle Raw JSON
</button>
</div>
</form>
</div>
</div>
</section>
<div class="row mt-4">
<div class="col-md-6">
<h2>Settings (.env)</h2>
<form action="/settings" method="post">
<table class="table table-bordered">
<!-- Prompt Section -->
<section id="prompt" class="amb-section">
<div class="amb-section-header">
<h1>Prompt Editor</h1>
<p>Edit the system prompt configuration (prompt.yml)</p>
</div>
<div class="amb-card">
<div class="amb-card-body">
<form action="/prompt" method="post" data-validate>
<textarea name="content" class="form-control amb-code-editor" rows="20" required>{{ prompt_content }}</textarea>
<div class="invalid-feedback">Prompt content is required.</div>
<button type="submit" class="btn btn-success mt-3">
<i class="bi bi-save"></i> Save Prompt
</button>
</form>
</div>
</div>
</section>
<!-- Settings Section -->
<section id="settings" class="amb-section">
<div class="amb-section-header">
<h1>Settings</h1>
<p>Manage environment variables (.env)</p>
</div>
<div class="amb-card">
<div class="amb-card-body">
<form action="/settings" method="post" data-validate>
<table class="table table-bordered mb-3">
<thead>
<tr>
<th>Key</th>
<th style="width: 40%;">Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for key, value in env_vars.items() %}
<tr>
<td>{{ key }}</td>
<td><code>{{ key }}</code></td>
<td>
<input type="text" name="env_{{ key }}" value="{{ value }}" class="form-control" list="env-value-suggestions">
<select name="env_{{ key }}" class="form-select form-select-sm" data-choices>
<option value="{{ value }}" selected>{{ value }}</option>
{% for val in metadata.suggestions.env_values %}
{% if val != value %}
<option value="{{ val }}">{{ val }}</option>
{% endif %}
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
<tr>
<tr class="table-light">
<td>
<input type="text" name="new_env_key" class="form-control" placeholder="NEW_KEY" list="env-key-suggestions">
<select name="new_env_key" class="form-select form-select-sm" data-choices data-placeholder="Add new key...">
<option value="">Add new key...</option>
{% for key in metadata.suggestions.env_keys %}
<option value="{{ key }}">{{ key }}</option>
{% endfor %}
</select>
</td>
<td>
<input type="text" name="new_env_value" class="form-control" placeholder="New Value" list="env-value-suggestions">
<select name="new_env_value" class="form-select form-select-sm" data-choices data-placeholder="Value...">
<option value="">Value...</option>
{% for val in metadata.suggestions.env_values %}
<option value="{{ val }}">{{ val }}</option>
{% endfor %}
</select>
</td>
</tr>
</tbody>
</table>
<datalist id="env-key-suggestions">
{% for key in env_vars.keys() %}
<option value="{{ key }}">
{% endfor %}
{% for key in metadata.suggestions.env_keys %}
<option value="{{ key }}">
{% endfor %}
</datalist>
<datalist id="env-value-suggestions">
{% for value in env_vars.values() %}
<option value="{{ value }}">
{% endfor %}
{% for val in metadata.suggestions.env_values %}
<option value="{{ val }}">
{% endfor %}
</datalist>
<button type="submit" class="btn btn-success">Save Settings</button>
</form>
</div>
<div class="col-md-6">
<h2>Prompt (prompt.yml)</h2>
<form action="/prompt" method="post">
<textarea name="content" class="form-control" rows="15">{{ prompt_content }}</textarea>
<button type="submit" class="btn btn-success mt-2">Save Prompt</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-save"></i> Save Settings
</button>
</form>
</div>
</div>
</section>
<div class="row mt-4 mb-5">
<div class="col-12">
<h2>Workflow Builder (workflow.json)</h2>
<div id="workflow-builder" class="mb-3">
<!-- Visual builder will be rendered here -->
</div>
<form action="/workflow" method="post" id="workflow-form">
<datalist id="task-name-suggestions">
{% for name in metadata.suggestions.task_names %}
<option value="{{ name }}">
{% endfor %}
</datalist>
<textarea id="workflow-content" name="content" class="form-control d-none" rows="15">{{ workflow_content }}</textarea>
<button type="submit" class="btn btn-success mt-2">Save Workflow</button>
<button type="button" class="btn btn-secondary mt-2" onclick="toggleRawWorkflow()">Toggle Raw JSON</button>
</form>
</div>
<!-- Translations Section -->
<section id="translations" class="amb-section">
<div class="amb-section-header">
<h1>Translations</h1>
<p>Manage language translations</p>
</div>
<script>
let workflow = [];
try {
workflow = JSON.parse(document.getElementById('workflow-content').value);
} catch (e) {
console.error("Failed to parse workflow JSON", e);
}
const stepDefinitions = JSON.parse('{{ metadata.step_definitions | tojson | safe }}');
const allSuggestions = new Set();
Object.values(stepDefinitions).forEach(def => {
Object.values(def.fields).forEach(f => {
if (f.suggestions) f.suggestions.forEach(s => allSuggestions.add(s));
});
});
function renderWorkflow() {
const container = document.getElementById('workflow-builder');
container.innerHTML = '';
workflow.forEach((task, taskIdx) => {
const taskCard = document.createElement('div');
taskCard.className = 'card mb-4 border-primary';
taskCard.innerHTML = `
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<div class="flex-grow-1">
<input type="text" class="form-control form-control-sm d-inline-block w-auto" value="${task.name || ''}" list="task-name-suggestions" onchange="updateTask(${taskIdx}, 'name', this.value)" placeholder="Task Name">
<select class="form-select form-select-sm d-inline-block w-auto ms-2" onchange="updateTask(${taskIdx}, 'type', this.value)">
<option value="" ${!task.type ? 'selected' : ''}>Standard Sequence</option>
<option value="loop" ${task.type === 'loop' ? 'selected' : ''}>Loop / Iterate</option>
</select>
${task.type === 'loop' ? `<input type="number" class="form-control form-control-sm d-inline-block w-auto ms-2" value="${task.max_iterations || 1}" onchange="updateTask(${taskIdx}, 'max_iterations', parseInt(this.value))" title="Max Iterations">` : ''}
</div>
<div>
<button class="btn btn-sm btn-light" onclick="moveTask(${taskIdx}, -1)" ${taskIdx === 0 ? 'disabled' : ''}>↑</button>
<button class="btn btn-sm btn-light" onclick="moveTask(${taskIdx}, 1)" ${taskIdx === workflow.length - 1 ? 'disabled' : ''}>↓</button>
<button class="btn btn-sm btn-danger ms-2" onclick="removeTask(${taskIdx})">Remove Task</button>
</div>
</div>
<div class="card-body">
<div id="steps-${taskIdx}"></div>
<button class="btn btn-sm btn-outline-primary mt-2" onclick="addStep(${taskIdx})">+ Add Step</button>
</div>
`;
container.appendChild(taskCard);
const stepsContainer = taskCard.querySelector(`#steps-${taskIdx}`);
(task.steps || []).forEach((step, stepIdx) => {
const stepDiv = document.createElement('div');
stepDiv.className = 'card mb-2 bg-light';
let fieldsHtml = `
<select class="form-select form-select-sm mb-2" onchange="updateStepType(${taskIdx}, ${stepIdx}, this.value)">
<option value="">Select Action Type...</option>
${Object.entries(stepDefinitions).map(([type, def]) => `<option value="${type}" ${step.type === type ? 'selected' : ''}>${def.label}</option>`).join('')}
</select>
`;
if (step.type && stepDefinitions[step.type]) {
fieldsHtml += '<div class="row g-2">';
const def = stepDefinitions[step.type];
Object.entries(def.fields).forEach(([field, fieldDef]) => {
const val = step[field] !== undefined ? step[field] : '';
const isCheckbox = fieldDef.type === 'checkbox';
fieldsHtml += `
<div class="col-md-6">
<label class="small fw-bold">${fieldDef.label}</label>
${isCheckbox
? `<div class="form-check mt-1"><input type="checkbox" class="form-check-input" ${step[field] ? 'checked' : ''} onchange="updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.checked)"></div>`
: `
<input type="text" class="form-control form-control-sm" value="${val}" list="suggestions-${taskIdx}-${stepIdx}-${field}" onchange="updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.value)" placeholder="${fieldDef.default || ''}">
<datalist id="suggestions-${taskIdx}-${stepIdx}-${field}">
${(fieldDef.suggestions || Array.from(allSuggestions)).map(s => `<option value="${s}">`).join('')}
</datalist>
`
}
</div>
`;
});
fieldsHtml += '</div>';
}
stepDiv.innerHTML = `
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="badge bg-secondary">Step ${stepIdx + 1}</span>
<div>
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="moveStep(${taskIdx}, ${stepIdx}, -1)" ${stepIdx === 0 ? 'disabled' : ''}>↑</button>
<button class="btn btn-sm btn-link p-0 text-decoration-none ms-1" onclick="moveStep(${taskIdx}, ${stepIdx}, 1)" ${stepIdx === (task.steps.length - 1) ? 'disabled' : ''}>↓</button>
<button class="btn btn-sm btn-link p-0 text-danger text-decoration-none ms-2" onclick="removeStep(${taskIdx}, ${stepIdx})">✕</button>
</div>
<div class="row">
<div class="col-lg-6">
<div class="amb-card">
<div class="amb-card-header">
<h5><i class="bi bi-list-check"></i> Available Languages</h5>
</div>
<div class="amb-card-body p-0">
<ul class="list-group list-group-flush">
{% for lang, file in translations.items() %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-globe me-2"></i>
<strong>{{ lang }}</strong>
</div>
${fieldsHtml}
<span class="badge bg-light text-dark">{{ file }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="amb-card">
<div class="amb-card-header">
<h5><i class="bi bi-plus-circle"></i> Add Translation</h5>
</div>
<div class="amb-card-body">
<form action="/translations" method="post" data-validate>
<div class="amb-form-group">
<label class="amb-form-label">
Language Code <span class="amb-required">*</span>
</label>
<select name="lang" class="form-select" data-choices data-placeholder="Select language..." required>
<option value="">Select language...</option>
{% for lang in metadata.suggestions.languages %}
<option value="{{ lang }}">{{ lang }}</option>
{% endfor %}
</select>
<div class="invalid-feedback">Please select a language code.</div>
</div>
`;
stepsContainer.appendChild(stepDiv);
});
});
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus-lg"></i> Add Translation
</button>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
const addTaskBtn = document.createElement('button');
addTaskBtn.className = 'btn btn-primary mb-5';
addTaskBtn.textContent = '+ Add Task';
addTaskBtn.onclick = addTask;
container.appendChild(addTaskBtn);
syncWorkflow();
}
function syncWorkflow() {
document.getElementById('workflow-content').value = JSON.stringify(workflow, null, 2);
}
function toggleRawWorkflow() {
const textarea = document.getElementById('workflow-content');
textarea.classList.toggle('d-none');
if (!textarea.classList.contains('d-none')) {
textarea.oninput = function() {
try {
workflow = JSON.parse(this.value);
renderWorkflow();
} catch (e) {}
};
}
}
function updateTask(idx, field, value) {
workflow[idx][field] = value;
if (field === 'type' && value !== 'loop') {
delete workflow[idx].max_iterations;
} else if (field === 'type' && value === 'loop' && !workflow[idx].max_iterations) {
workflow[idx].max_iterations = 10;
}
renderWorkflow();
}
function addTask() {
workflow.push({ name: 'New Task', steps: [] });
renderWorkflow();
}
function removeTask(idx) {
if (confirm('Remove this task?')) {
workflow.splice(idx, 1);
renderWorkflow();
}
}
function moveTask(idx, dir) {
const target = idx + dir;
if (target >= 0 && target < workflow.length) {
[workflow[idx], workflow[target]] = [workflow[target], workflow[idx]];
renderWorkflow();
}
}
function updateStepType(taskIdx, stepIdx, type) {
const newStep = { type: type };
if (stepDefinitions[type]) {
Object.entries(stepDefinitions[type].fields).forEach(([field, fieldDef]) => {
newStep[field] = fieldDef.default;
});
}
workflow[taskIdx].steps[stepIdx] = newStep;
renderWorkflow();
}
function updateStepField(taskIdx, stepIdx, field, value) {
workflow[taskIdx].steps[stepIdx][field] = value;
syncWorkflow();
}
function addStep(taskIdx) {
if (!workflow[taskIdx].steps) workflow[taskIdx].steps = [];
workflow[taskIdx].steps.push({ type: '' });
renderWorkflow();
}
function removeStep(taskIdx, stepIdx) {
workflow[taskIdx].steps.splice(stepIdx, 1);
renderWorkflow();
}
function moveStep(taskIdx, stepIdx, dir) {
const target = stepIdx + dir;
const steps = workflow[taskIdx].steps;
if (target >= 0 && target < steps.length) {
[steps[stepIdx], steps[target]] = [steps[target], steps[stepIdx]];
renderWorkflow();
}
}
renderWorkflow();
async function refreshLogs() {
try {
const response = await fetch('/api/logs');
const data = await response.json();
const logsPre = document.getElementById('logs');
const wasAtBottom = logsPre.scrollHeight - logsPre.clientHeight <= logsPre.scrollTop + 1;
logsPre.textContent = data.logs;
if (wasAtBottom) {
logsPre.scrollTop = logsPre.scrollHeight;
}
} catch (error) {
console.error('Error fetching logs:', error);
}
}
async function refreshStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
const statusContainer = document.getElementById('status-container');
const runBtn = document.getElementById('run-btn');
if (data.is_running) {
statusContainer.innerHTML = `
<p>Status: <span class="badge bg-warning text-dark">Bot Running...</span></p>
<p>MVP Milestone: ${data.mvp_reached ? '<span class="badge bg-primary">Reached ✓</span>' : '<span class="badge bg-secondary">In Progress</span>'}</p>
<div class="progress mt-2">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div>
</div>
`;
runBtn.disabled = true;
} else {
statusContainer.innerHTML = `
<p>Status: <span class="badge bg-success">Idle</span></p>
<p>MVP Milestone: ${data.mvp_reached ? '<span class="badge bg-primary">Reached ✓</span>' : '<span class="badge bg-secondary">In Progress</span>'}</p>
`;
runBtn.disabled = false;
}
} catch (error) {
console.error('Error fetching status:', error);
}
}
setInterval(refreshLogs, 2000);
setInterval(refreshStatus, 2000);
</script>
</body>
</html>
{% block scripts %}
<script src="/static/js/workflow.js"></script>
<script>
// Initialize workflow builder with step definitions from metadata
const stepDefinitions = {{ metadata.step_definitions | tojson | safe }};
WorkflowBuilder.init('workflow-builder', 'workflow-content', stepDefinitions);
</script>
{% endblock %}

View File

@@ -1,124 +1,206 @@
import pytest
import json
import os
import re
from playwright.sync_api import Page, expect
def test_login_and_dashboard(page: Page, server: str):
# Go to the server
page.goto(server)
# We should be prompted for basic auth
# Playwright handles basic auth via context or by encoding it in the URL
# or we can use the page.authenticate method
# Alternative: use URL with credentials
# Go to the server with auth
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Check if we are on the dashboard
expect(page.locator("h1")).to_contain_text("AutoMetabuilder Dashboard")
expect(page.locator("text=Logged in as: testuser")).to_be_visible()
# Check if we are on the dashboard - select h1 in the active section
expect(page.locator("#dashboard.active h1")).to_contain_text("Dashboard")
# User info is now in sidebar footer
expect(page.locator(".amb-sidebar-footer")).to_contain_text("testuser")
def test_run_bot_mock(page: Page, server: str):
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Click run button
page.click("button:has-text('Run Bot')")
# Status should change to "Bot Running..."
expect(page.locator(".badge.bg-warning")).to_contain_text("Bot Running")
# Wait for it to finish (mock takes 5 seconds)
page.wait_for_timeout(6000)
# Refresh
page.reload()
expect(page.locator(".badge.bg-success")).to_contain_text("Idle")
# Wait for status to update (uses AJAX polling every 2s)
page.wait_for_timeout(3000)
# Status should change to "Running"
expect(page.locator("#status-indicator")).to_contain_text("Running")
# Wait for it to finish (mock takes 5 seconds, add buffer for polling)
page.wait_for_timeout(8000)
# Status should return to Idle - allow longer timeout for polling
expect(page.locator("#status-indicator")).to_contain_text("Idle", timeout=15000)
def test_update_prompt(page: Page, server: str):
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Find prompt textarea
textarea = page.locator("textarea[name='content']").first
# Navigate to prompt section
page.click("[data-section='prompt']")
page.wait_for_selector("#prompt.active")
# Find prompt textarea - specifically in the prompt section
textarea = page.locator("#prompt textarea[name='content']")
original_content = textarea.input_value()
new_content = original_content + "\n# Test Comment"
textarea.fill(new_content)
# Click update prompt
page.click("button:has-text('Save Prompt')")
# Click save prompt
page.click("#prompt button:has-text('Save Prompt')")
# Verify it updated
page.reload()
expect(page.locator("textarea[name='content']").first).to_have_value(new_content)
page.click("[data-section='prompt']")
page.wait_for_selector("#prompt.active")
expect(page.locator("#prompt textarea[name='content']")).to_have_value(new_content)
def test_update_settings(page: Page, server: str):
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Add a new setting
page.fill("input[name='new_env_key']", "TEST_SETTING")
page.fill("input[name='new_env_value']", "test_value")
page.click("button:has-text('Save Settings')")
# Navigate to settings section
page.click("[data-section='settings']")
page.wait_for_selector("#settings.active")
# Wait for Choices.js to initialize
page.wait_for_timeout(1000)
# Add a new setting using Choices.js select
# Click on the outer .choices wrapper (first match only)
key_choices = page.locator("#settings select[name='new_env_key']").locator("xpath=ancestor::div[@class='choices' or contains(@class, 'choices ')]").first
key_choices.click()
page.keyboard.type("GITHUB_TOKEN")
page.keyboard.press("Enter")
# For new_env_value
value_choices = page.locator("#settings select[name='new_env_value']").locator("xpath=ancestor::div[@class='choices' or contains(@class, 'choices ')]").first
value_choices.click()
page.keyboard.type("DEBUG")
page.keyboard.press("Enter")
page.click("#settings button:has-text('Save Settings')")
# Verify it appeared in the table
page.reload()
expect(page.locator("input[name='env_TEST_SETTING']")).to_have_value("test_value")
page.click("[data-section='settings']")
page.wait_for_selector("#settings.active")
expect(page.locator("#settings code:has-text('GITHUB_TOKEN')")).to_be_visible()
def test_all_text_inputs_have_autocomplete(page: Page, server: str):
def test_navigation_sections(page: Page, server: str):
"""Test that sidebar navigation works correctly"""
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Wait for the workflow builder to render
page.wait_for_selector("#workflow-builder")
# Select all text inputs
inputs = page.locator("input[type='text']")
count = inputs.count()
assert count > 0, "No text inputs found on the page"
for i in range(count):
input_element = inputs.nth(i)
# Check if the 'list' attribute is present and not empty
list_attr = input_element.get_attribute("list")
input_name = input_element.get_attribute("name") or input_element.get_attribute("placeholder") or f"index {i}"
assert list_attr, f"Input '{input_name}' does not have a 'list' attribute for autocomplete"
# Check if the corresponding datalist exists
datalist = page.locator(f"datalist#{list_attr}")
expect(datalist).to_be_attached(), f"Datalist '{list_attr}' for input '{input_name}' is missing"
# Dashboard should be active by default
expect(page.locator("#dashboard")).to_have_class(re.compile(r"active"))
# Navigate to each section
sections = ["workflow", "prompt", "settings", "translations"]
for section in sections:
page.click(f"[data-section='{section}']")
page.wait_for_timeout(100)
expect(page.locator(f"#{section}")).to_have_class(re.compile(r"active"))
# Previous section should no longer be active
expect(page.locator("#dashboard")).not_to_have_class(re.compile(r"active"))
def test_theme_toggle(page: Page, server: str):
"""Test dark mode toggle functionality"""
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Get initial theme
html = page.locator("html")
initial_theme = html.get_attribute("data-theme")
# Click toggle
page.click("[data-theme-toggle]")
page.wait_for_timeout(100)
# Theme should have changed
new_theme = html.get_attribute("data-theme")
assert new_theme != initial_theme, f"Theme did not change from {initial_theme}"
# Toggle back
page.click("[data-theme-toggle]")
page.wait_for_timeout(100)
final_theme = html.get_attribute("data-theme")
assert final_theme == initial_theme, f"Theme should return to {initial_theme}"
def test_choices_dropdowns_exist(page: Page, server: str):
"""Test that Choices.js dropdowns are initialized"""
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Navigate to translations to find language dropdown
page.click("[data-section='translations']")
page.wait_for_selector("#translations.active")
# Wait for Choices.js to initialize
page.wait_for_timeout(1000)
# Check that data-choices elements have been enhanced
choices_elements = page.locator("#translations [data-choices]")
count = choices_elements.count()
assert count > 0, "No Choices.js elements found in translations section"
# Verify Choices.js wrapper exists - use CSS selector for the outer wrapper
choices_wrapper = page.locator("#translations div.choices[data-type='select-one']").first
expect(choices_wrapper).to_be_visible()
def test_autocomplete_values_from_json(page: Page, server: str):
"""Test that dropdown options are populated from metadata.json"""
# Load metadata.json
metadata_path = os.path.join(os.path.dirname(__file__), "../../src/autometabuilder/metadata.json")
with open(metadata_path, 'r') as f:
metadata = json.load(f)
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Verify lang-suggestions
for lang in metadata["suggestions"]["languages"]:
expect(page.locator(f"#lang-suggestions option[value='{lang}']").first).to_be_attached()
# Verify env-key-suggestions
for key in metadata["suggestions"]["env_keys"]:
expect(page.locator(f"#env-key-suggestions option[value='{key}']").first).to_be_attached()
# Verify env-value-suggestions
for val in metadata["suggestions"]["env_values"]:
expect(page.locator(f"#env-value-suggestions option[value='{val}']").first).to_be_attached()
# Verify task-name-suggestions
for name in metadata["suggestions"]["task_names"]:
expect(page.locator(f"#task-name-suggestions option[value='{name}']").first).to_be_attached()
# Verify workflow builder step suggestions
# We need to add a task and a step first
# This might be complex but let's at least check if allSuggestions was populated
# and used in some datalist.
# The existing test_all_text_inputs_have_autocomplete already checks if datalists are attached.
# Navigate to translations section to check language options
page.click("[data-section='translations']")
page.wait_for_selector("#translations.active")
# Verify language options in select
lang_select = page.locator("#translations select[name='lang']")
for lang in metadata["suggestions"]["languages"]:
expect(lang_select.locator(f"option[value='{lang}']")).to_be_attached()
# Navigate to settings to check env options
page.click("[data-section='settings']")
page.wait_for_selector("#settings.active")
# Verify env key options
key_select = page.locator("#settings select[name='new_env_key']")
for key in metadata["suggestions"]["env_keys"]:
expect(key_select.locator(f"option[value='{key}']")).to_be_attached()
# Verify env value options
value_select = page.locator("#settings select[name='new_env_value']")
for val in metadata["suggestions"]["env_values"]:
expect(value_select.locator(f"option[value='{val}']")).to_be_attached()
def test_workflow_builder_renders(page: Page, server: str):
"""Test that workflow builder initializes and renders"""
auth_url = server.replace("http://", "http://testuser:testpass@")
page.goto(auth_url)
# Navigate to workflow section
page.click("[data-section='workflow']")
page.wait_for_selector("#workflow.active")
# Wait for workflow builder to render
page.wait_for_selector("#workflow-builder")
# Should have at least the "Add Task" button
expect(page.locator("#workflow-builder button:has-text('Add Task')")).to_be_visible()
# Toggle raw JSON should work
page.click("#workflow button:has-text('Toggle Raw JSON')")
expect(page.locator("#workflow-content")).to_be_visible()
page.click("#workflow button:has-text('Toggle Raw JSON')")
expect(page.locator("#workflow-content")).not_to_be_visible()