初始化提交
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled

This commit is contained in:
iven
2026-03-01 16:24:24 +08:00
commit 92e5def702
492 changed files with 211343 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
// OpenFang API Client — Fetch wrapper, WebSocket manager, auth injection, toast notifications
'use strict';
// ── Toast Notification System ──
var OpenFangToast = (function() {
var _container = null;
var _toastId = 0;
function getContainer() {
if (!_container) {
_container = document.getElementById('toast-container');
if (!_container) {
_container = document.createElement('div');
_container.id = 'toast-container';
_container.className = 'toast-container';
document.body.appendChild(_container);
}
}
return _container;
}
function toast(message, type, duration) {
type = type || 'info';
duration = duration || 4000;
var id = ++_toastId;
var el = document.createElement('div');
el.className = 'toast toast-' + type;
el.setAttribute('data-toast-id', id);
var msgSpan = document.createElement('span');
msgSpan.className = 'toast-msg';
msgSpan.textContent = message;
el.appendChild(msgSpan);
var closeBtn = document.createElement('button');
closeBtn.className = 'toast-close';
closeBtn.textContent = '\u00D7';
closeBtn.onclick = function() { dismissToast(el); };
el.appendChild(closeBtn);
el.onclick = function(e) { if (e.target === el) dismissToast(el); };
getContainer().appendChild(el);
// Auto-dismiss
if (duration > 0) {
setTimeout(function() { dismissToast(el); }, duration);
}
return id;
}
function dismissToast(el) {
if (!el || el.classList.contains('toast-dismiss')) return;
el.classList.add('toast-dismiss');
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
}
function success(msg, duration) { return toast(msg, 'success', duration); }
function error(msg, duration) { return toast(msg, 'error', duration || 6000); }
function warn(msg, duration) { return toast(msg, 'warn', duration || 5000); }
function info(msg, duration) { return toast(msg, 'info', duration); }
// Styled confirmation modal — replaces native confirm()
function confirm(title, message, onConfirm) {
var overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
var modal = document.createElement('div');
modal.className = 'confirm-modal';
var titleEl = document.createElement('div');
titleEl.className = 'confirm-title';
titleEl.textContent = title;
modal.appendChild(titleEl);
var msgEl = document.createElement('div');
msgEl.className = 'confirm-message';
msgEl.textContent = message;
modal.appendChild(msgEl);
var actions = document.createElement('div');
actions.className = 'confirm-actions';
var cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-ghost confirm-cancel';
cancelBtn.textContent = 'Cancel';
actions.appendChild(cancelBtn);
var okBtn = document.createElement('button');
okBtn.className = 'btn btn-danger confirm-ok';
okBtn.textContent = 'Confirm';
actions.appendChild(okBtn);
modal.appendChild(actions);
overlay.appendChild(modal);
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); document.removeEventListener('keydown', onKey); }
cancelBtn.onclick = close;
okBtn.onclick = function() { close(); if (onConfirm) onConfirm(); };
overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); });
function onKey(e) { if (e.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
okBtn.focus();
}
return {
toast: toast,
success: success,
error: error,
warn: warn,
info: info,
confirm: confirm
};
})();
// ── Friendly Error Messages ──
function friendlyError(status, serverMsg) {
if (status === 0 || !status) return 'Cannot reach daemon — is openfang running?';
if (status === 401) return 'Not authorized — check your API key';
if (status === 403) return 'Permission denied';
if (status === 404) return serverMsg || 'Resource not found';
if (status === 429) return 'Rate limited — slow down and try again';
if (status === 413) return 'Request too large';
if (status === 500) return 'Server error — check daemon logs';
if (status === 502 || status === 503) return 'Daemon unavailable — is it running?';
return serverMsg || 'Unexpected error (' + status + ')';
}
// ── API Client ──
var OpenFangAPI = (function() {
var BASE = window.location.origin;
var WS_BASE = BASE.replace(/^http/, 'ws');
var _authToken = '';
// Connection state tracking
var _connectionState = 'connected';
var _reconnectAttempt = 0;
var _connectionListeners = [];
function setAuthToken(token) { _authToken = token; }
function headers() {
var h = { 'Content-Type': 'application/json' };
if (_authToken) h['Authorization'] = 'Bearer ' + _authToken;
return h;
}
function setConnectionState(state) {
if (_connectionState === state) return;
_connectionState = state;
_connectionListeners.forEach(function(fn) { fn(state); });
}
function onConnectionChange(fn) { _connectionListeners.push(fn); }
function request(method, path, body) {
var opts = { method: method, headers: headers() };
if (body !== undefined) opts.body = JSON.stringify(body);
return fetch(BASE + path, opts).then(function(r) {
if (_connectionState !== 'connected') setConnectionState('connected');
if (!r.ok) {
return r.text().then(function(text) {
var msg = '';
try {
var json = JSON.parse(text);
msg = json.error || r.statusText;
} catch(e) {
msg = r.statusText;
}
throw new Error(friendlyError(r.status, msg));
});
}
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('application/json') >= 0) return r.json();
return r.text().then(function(t) {
try { return JSON.parse(t); } catch(e) { return { text: t }; }
});
}).catch(function(e) {
if (e.name === 'TypeError' && e.message.includes('Failed to fetch')) {
setConnectionState('disconnected');
throw new Error('Cannot connect to daemon — is openfang running?');
}
throw e;
});
}
function get(path) { return request('GET', path); }
function post(path, body) { return request('POST', path, body); }
function put(path, body) { return request('PUT', path, body); }
function patch(path, body) { return request('PATCH', path, body); }
function del(path) { return request('DELETE', path); }
// WebSocket manager with auto-reconnect
var _ws = null;
var _wsCallbacks = {};
var _wsConnected = false;
var _wsAgentId = null;
var _reconnectTimer = null;
var _reconnectAttempts = 0;
var MAX_RECONNECT = 5;
function wsConnect(agentId, callbacks) {
wsDisconnect();
_wsCallbacks = callbacks || {};
_wsAgentId = agentId;
_reconnectAttempts = 0;
_doConnect(agentId);
}
function _doConnect(agentId) {
try {
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
_ws = new WebSocket(url);
_ws.onopen = function() {
_wsConnected = true;
_reconnectAttempts = 0;
setConnectionState('connected');
if (_reconnectAttempt > 0) {
OpenFangToast.success('Reconnected');
_reconnectAttempt = 0;
}
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
};
_ws.onmessage = function(e) {
try {
var data = JSON.parse(e.data);
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
} catch(err) { /* ignore parse errors */ }
};
_ws.onclose = function(e) {
_wsConnected = false;
_ws = null;
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
_reconnectAttempts++;
_reconnectAttempt = _reconnectAttempts;
setConnectionState('reconnecting');
if (_reconnectAttempts === 1) {
OpenFangToast.warn('Connection lost, reconnecting...');
}
var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 10000);
_reconnectTimer = setTimeout(function() { _doConnect(_wsAgentId); }, delay);
return;
}
if (_wsAgentId && _reconnectAttempts >= MAX_RECONNECT) {
setConnectionState('disconnected');
OpenFangToast.error('Connection lost — switched to HTTP mode', 0);
}
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
};
_ws.onerror = function() {
_wsConnected = false;
if (_wsCallbacks.onError) _wsCallbacks.onError();
};
} catch(e) {
_wsConnected = false;
}
}
function wsDisconnect() {
_wsAgentId = null;
_reconnectAttempts = MAX_RECONNECT;
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
if (_ws) { _ws.close(1000); _ws = null; }
_wsConnected = false;
}
function wsSend(data) {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify(data));
return true;
}
return false;
}
function isWsConnected() { return _wsConnected; }
function getConnectionState() { return _connectionState; }
function getToken() { return _authToken; }
function upload(agentId, file) {
var hdrs = {
'Content-Type': file.type || 'application/octet-stream',
'X-Filename': file.name
};
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
method: 'POST',
headers: hdrs,
body: file
}).then(function(r) {
if (!r.ok) throw new Error('Upload failed');
return r.json();
});
}
return {
setAuthToken: setAuthToken,
getToken: getToken,
get: get,
post: post,
put: put,
patch: patch,
del: del,
delete: del,
upload: upload,
wsConnect: wsConnect,
wsDisconnect: wsDisconnect,
wsSend: wsSend,
isWsConnected: isWsConnected,
getConnectionState: getConnectionState,
onConnectionChange: onConnectionChange
};
})();

View File

@@ -0,0 +1,319 @@
// OpenFang App — Alpine.js init, hash router, global store
'use strict';
// Marked.js configuration
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: true,
gfm: true,
highlight: function(code, lang) {
if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) {
try { return hljs.highlight(code, { language: lang }).value; } catch(e) {}
}
return code;
}
});
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function renderMarkdown(text) {
if (!text) return '';
if (typeof marked !== 'undefined') {
var html = marked.parse(text);
// Add copy buttons to code blocks
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
return html;
}
return escapeHtml(text);
}
function copyCode(btn) {
var code = btn.nextElementSibling;
if (code) {
navigator.clipboard.writeText(code.textContent).then(function() {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
});
}
}
// Tool category icon SVGs — returns inline SVG for each tool category
function toolIcon(toolName) {
if (!toolName) return '';
var n = toolName.toLowerCase();
var s = 'width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
// File/directory operations
if (n.indexOf('file_') === 0 || n.indexOf('directory_') === 0)
return '<svg ' + s + '><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>';
// Web/fetch
if (n.indexOf('web_') === 0 || n.indexOf('link_') === 0)
return '<svg ' + s + '><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10 15 15 0 0 1 4-10z"/></svg>';
// Shell/exec
if (n.indexOf('shell') === 0 || n.indexOf('exec_') === 0)
return '<svg ' + s + '><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
// Agent operations
if (n.indexOf('agent_') === 0)
return '<svg ' + s + '><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
// Memory/knowledge
if (n.indexOf('memory_') === 0 || n.indexOf('knowledge_') === 0)
return '<svg ' + s + '><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>';
// Cron/schedule
if (n.indexOf('cron_') === 0 || n.indexOf('schedule_') === 0)
return '<svg ' + s + '><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
// Browser/playwright
if (n.indexOf('browser_') === 0 || n.indexOf('playwright_') === 0)
return '<svg ' + s + '><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
// Container/docker
if (n.indexOf('container_') === 0 || n.indexOf('docker_') === 0)
return '<svg ' + s + '><path d="M22 12H2"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>';
// Image/media
if (n.indexOf('image_') === 0 || n.indexOf('tts_') === 0)
return '<svg ' + s + '><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
// Hand tools
if (n.indexOf('hand_') === 0)
return '<svg ' + s + '><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v6"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.9-5.7-2.4L3.4 16a2 2 0 0 1 3.2-2.4L8 15"/></svg>';
// Task/collab
if (n.indexOf('task_') === 0)
return '<svg ' + s + '><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>';
// Default — wrench
return '<svg ' + s + '><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>';
}
// Alpine.js global store
document.addEventListener('alpine:init', function() {
// Restore saved API key on load
var savedKey = localStorage.getItem('openfang-api-key');
if (savedKey) OpenFangAPI.setAuthToken(savedKey);
Alpine.store('app', {
agents: [],
connected: false,
booting: true,
wsConnected: false,
connectionState: 'connected',
lastError: '',
version: '0.1.0',
agentCount: 0,
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
toggleFocusMode() {
this.focusMode = !this.focusMode;
localStorage.setItem('openfang-focus', this.focusMode);
},
async refreshAgents() {
try {
var agents = await OpenFangAPI.get('/api/agents');
this.agents = Array.isArray(agents) ? agents : [];
this.agentCount = this.agents.length;
} catch(e) { /* silent */ }
},
async checkStatus() {
try {
var s = await OpenFangAPI.get('/api/status');
this.connected = true;
this.booting = false;
this.lastError = '';
this.version = s.version || '0.1.0';
this.agentCount = s.agent_count || 0;
} catch(e) {
this.connected = false;
this.lastError = e.message || 'Unknown error';
console.warn('[OpenFang] Status check failed:', e.message);
}
},
async checkOnboarding() {
if (localStorage.getItem('openfang-onboarded')) return;
try {
var config = await OpenFangAPI.get('/api/config');
var apiKey = config && config.api_key;
var noKey = !apiKey || apiKey === 'not set' || apiKey === '';
if (noKey && this.agentCount === 0) {
this.showOnboarding = true;
}
} catch(e) {
// If config endpoint fails, still show onboarding if no agents
if (this.agentCount === 0) this.showOnboarding = true;
}
},
dismissOnboarding() {
this.showOnboarding = false;
localStorage.setItem('openfang-onboarded', 'true');
},
async checkAuth() {
try {
// Use a protected endpoint (not in the public allowlist) to detect
// whether the server requires an API key.
await OpenFangAPI.get('/api/tools');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
// Only show prompt if we don't already have a saved key
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
// Saved key might be stale — clear it and show prompt
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
this.showAuthPrompt = true;
}
}
},
submitApiKey(key) {
if (!key || !key.trim()) return;
OpenFangAPI.setAuthToken(key.trim());
localStorage.setItem('openfang-api-key', key.trim());
this.showAuthPrompt = false;
this.refreshAgents();
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
});
});
// Main app component
function app() {
return {
page: 'agents',
themeMode: localStorage.getItem('openfang-theme-mode') || 'system',
theme: (() => {
var mode = localStorage.getItem('openfang-theme-mode') || 'system';
if (mode === 'system') return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
return mode;
})(),
sidebarCollapsed: localStorage.getItem('openfang-sidebar') === 'collapsed',
mobileMenuOpen: false,
connected: false,
wsConnected: false,
version: '0.1.0',
agentCount: 0,
get agents() { return Alpine.store('app').agents; },
init() {
var self = this;
// Listen for OS theme changes (only matters when mode is 'system')
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (self.themeMode === 'system') {
self.theme = e.matches ? 'dark' : 'light';
}
});
// Hash routing
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
var pageRedirects = {
'chat': 'agents',
'templates': 'agents',
'triggers': 'workflows',
'cron': 'scheduler',
'schedules': 'scheduler',
'memory': 'sessions',
'audit': 'logs',
'security': 'settings',
'peers': 'settings',
'migration': 'settings',
'usage': 'analytics',
'approval': 'approvals'
};
function handleHash() {
var hash = window.location.hash.replace('#', '') || 'agents';
if (pageRedirects[hash]) {
hash = pageRedirects[hash];
window.location.hash = hash;
}
if (validPages.indexOf(hash) >= 0) self.page = hash;
}
window.addEventListener('hashchange', handleHash);
handleHash();
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Ctrl+K — focus agent switch / go to agents
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
self.navigate('agents');
}
// Ctrl+N — new agent
if ((e.ctrlKey || e.metaKey) && e.key === 'n' && !e.shiftKey) {
e.preventDefault();
self.navigate('agents');
}
// Ctrl+Shift+F — toggle focus mode
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') {
e.preventDefault();
Alpine.store('app').toggleFocusMode();
}
// Escape — close mobile menu
if (e.key === 'Escape') {
self.mobileMenuOpen = false;
}
});
// Connection state listener
OpenFangAPI.onConnectionChange(function(state) {
Alpine.store('app').connectionState = state;
});
// Initial data load
this.pollStatus();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
},
navigate(p) {
this.page = p;
window.location.hash = p;
this.mobileMenuOpen = false;
},
setTheme(mode) {
this.themeMode = mode;
localStorage.setItem('openfang-theme-mode', mode);
if (mode === 'system') {
this.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
this.theme = mode;
}
},
toggleTheme() {
var modes = ['light', 'system', 'dark'];
var next = modes[(modes.indexOf(this.themeMode) + 1) % modes.length];
this.setTheme(next);
},
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
localStorage.setItem('openfang-sidebar', this.sidebarCollapsed ? 'collapsed' : 'expanded');
},
async pollStatus() {
var store = Alpine.store('app');
await store.checkStatus();
await store.refreshAgents();
this.connected = store.connected;
this.version = store.version;
this.agentCount = store.agentCount;
this.wsConnected = OpenFangAPI.isWsConnected();
}
};
}

View File

@@ -0,0 +1,582 @@
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
'use strict';
function agentsPage() {
return {
tab: 'agents',
activeChatAgent: null,
// -- Agents state --
showSpawnModal: false,
showDetailModal: false,
detailAgent: null,
spawnMode: 'wizard',
spawning: false,
spawnToml: '',
filterState: 'all',
loading: true,
loadError: '',
spawnForm: {
name: '',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
systemPrompt: 'You are a helpful assistant.',
profile: 'full',
caps: { memory_read: true, memory_write: true, network: false, shell: false, agent_spawn: false }
},
// -- Multi-step wizard state --
spawnStep: 1,
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
selectedPreset: '',
soulContent: '',
emojiOptions: [
'\u{1F916}', '\u{1F4BB}', '\u{1F50D}', '\u{270D}\uFE0F', '\u{1F4CA}', '\u{1F6E0}\uFE0F',
'\u{1F4AC}', '\u{1F393}', '\u{1F310}', '\u{1F512}', '\u{26A1}', '\u{1F680}',
'\u{1F9EA}', '\u{1F3AF}', '\u{1F4D6}', '\u{1F9D1}\u200D\u{1F4BB}', '\u{1F4E7}', '\u{1F3E2}',
'\u{2764}\uFE0F', '\u{1F31F}', '\u{1F527}', '\u{1F4DD}', '\u{1F4A1}', '\u{1F3A8}'
],
archetypeOptions: ['Assistant', 'Researcher', 'Coder', 'Writer', 'DevOps', 'Support', 'Analyst', 'Custom'],
personalityPresets: [
{ id: 'professional', label: 'Professional', soul: 'Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.' },
{ id: 'friendly', label: 'Friendly', soul: 'Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.' },
{ id: 'technical', label: 'Technical', soul: 'Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.' },
{ id: 'creative', label: 'Creative', soul: 'Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.' },
{ id: 'concise', label: 'Concise', soul: 'Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.' },
{ id: 'mentor', label: 'Mentor', soul: 'Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.' }
],
// -- Detail modal tabs --
detailTab: 'info',
agentFiles: [],
editingFile: null,
fileContent: '',
fileSaving: false,
filesLoading: false,
configForm: {},
configSaving: false,
// -- Templates state --
tplTemplates: [],
tplProviders: [],
tplLoading: false,
tplLoadError: '',
selectedCategory: 'All',
searchQuery: '',
builtinTemplates: [
{
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
},
{
name: 'Customer Support',
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'messaging',
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
},
{
name: 'Tutor',
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
},
{
name: 'API Designer',
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
},
{
name: 'Meeting Notes',
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'minimal',
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
}
],
// ── Profile Descriptions ──
profileDescriptions: {
minimal: { label: 'Minimal', desc: 'Read-only file access' },
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
research: { label: 'Research', desc: 'Web search + file read/write' },
messaging: { label: 'Messaging', desc: 'Agents + memory access' },
automation: { label: 'Automation', desc: 'All tools except custom' },
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
full: { label: 'Full', desc: 'All 35+ tools' }
},
profileInfo: function(name) {
return this.profileDescriptions[name] || { label: name, desc: '' };
},
// ── Tool Preview in Spawn Modal ──
spawnProfiles: [],
spawnProfilesLoaded: false,
async loadSpawnProfiles() {
if (this.spawnProfilesLoaded) return;
try {
var data = await OpenFangAPI.get('/api/profiles');
this.spawnProfiles = data.profiles || [];
this.spawnProfilesLoaded = true;
} catch(e) { this.spawnProfiles = []; }
},
get selectedProfileTools() {
var pname = this.spawnForm.profile;
var match = this.spawnProfiles.find(function(p) { return p.name === pname; });
if (match && match.tools) return match.tools.slice(0, 15);
return [];
},
get agents() { return Alpine.store('app').agents; },
get filteredAgents() {
var f = this.filterState;
if (f === 'all') return this.agents;
return this.agents.filter(function(a) { return a.state.toLowerCase() === f; });
},
get runningCount() {
return this.agents.filter(function(a) { return a.state === 'Running'; }).length;
},
get stoppedCount() {
return this.agents.filter(function(a) { return a.state !== 'Running'; }).length;
},
// -- Templates computed --
get categories() {
var cats = { 'All': true };
this.builtinTemplates.forEach(function(t) { cats[t.category] = true; });
this.tplTemplates.forEach(function(t) { if (t.category) cats[t.category] = true; });
return Object.keys(cats);
},
get filteredBuiltins() {
var self = this;
return this.builtinTemplates.filter(function(t) {
if (self.selectedCategory !== 'All' && t.category !== self.selectedCategory) return false;
if (self.searchQuery) {
var q = self.searchQuery.toLowerCase();
if (t.name.toLowerCase().indexOf(q) === -1 &&
t.description.toLowerCase().indexOf(q) === -1) return false;
}
return true;
});
},
get filteredCustom() {
var self = this;
return this.tplTemplates.filter(function(t) {
if (self.searchQuery) {
var q = self.searchQuery.toLowerCase();
if ((t.name || '').toLowerCase().indexOf(q) === -1 &&
(t.description || '').toLowerCase().indexOf(q) === -1) return false;
}
return true;
});
},
isProviderConfigured(providerName) {
if (!providerName) return false;
var p = this.tplProviders.find(function(pr) { return pr.id === providerName; });
return p ? p.auth_status === 'configured' : false;
},
async init() {
var self = this;
this.loading = true;
this.loadError = '';
try {
await Alpine.store('app').refreshAgents();
} catch(e) {
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
}
this.loading = false;
// If a pending agent was set (e.g. from wizard or redirect), open chat inline
var store = Alpine.store('app');
if (store.pendingAgent) {
this.activeChatAgent = store.pendingAgent;
}
// Watch for future pendingAgent changes
this.$watch('$store.app.pendingAgent', function(agent) {
if (agent) {
self.activeChatAgent = agent;
}
});
},
async loadData() {
this.loading = true;
this.loadError = '';
try {
await Alpine.store('app').refreshAgents();
} catch(e) {
this.loadError = e.message || 'Could not load agents.';
}
this.loading = false;
},
async loadTemplates() {
this.tplLoading = true;
this.tplLoadError = '';
try {
var results = await Promise.all([
OpenFangAPI.get('/api/templates'),
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
]);
this.tplTemplates = results[0].templates || [];
this.tplProviders = results[1].providers || [];
} catch(e) {
this.tplTemplates = [];
this.tplLoadError = e.message || 'Could not load templates.';
}
this.tplLoading = false;
},
chatWithAgent(agent) {
Alpine.store('app').pendingAgent = agent;
this.activeChatAgent = agent;
},
closeChat() {
this.activeChatAgent = null;
OpenFangAPI.wsDisconnect();
},
showDetail(agent) {
this.detailAgent = agent;
this.detailTab = 'info';
this.agentFiles = [];
this.editingFile = null;
this.fileContent = '';
this.configForm = {
name: agent.name || '',
system_prompt: agent.system_prompt || '',
emoji: (agent.identity && agent.identity.emoji) || '',
color: (agent.identity && agent.identity.color) || '#FF5C00',
archetype: (agent.identity && agent.identity.archetype) || '',
vibe: (agent.identity && agent.identity.vibe) || ''
};
this.showDetailModal = true;
},
killAgent(agent) {
var self = this;
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + agent.name + '"? The agent will be shut down.', async function() {
try {
await OpenFangAPI.del('/api/agents/' + agent.id);
OpenFangToast.success('Agent "' + agent.name + '" stopped');
self.showDetailModal = false;
await Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to stop agent: ' + e.message);
}
});
},
killAllAgents() {
var list = this.filteredAgents;
if (!list.length) return;
OpenFangToast.confirm('Stop All Agents', 'Stop ' + list.length + ' agent(s)? All agents will be shut down.', async function() {
var errors = [];
for (var i = 0; i < list.length; i++) {
try {
await OpenFangAPI.del('/api/agents/' + list[i].id);
} catch(e) { errors.push(list[i].name + ': ' + e.message); }
}
await Alpine.store('app').refreshAgents();
if (errors.length) {
OpenFangToast.error('Some agents failed to stop: ' + errors.join(', '));
} else {
OpenFangToast.success(list.length + ' agent(s) stopped');
}
});
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
this.spawnIdentity = { emoji: '', color: '#FF5C00', archetype: '' };
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
},
nextStep() {
if (this.spawnStep === 1 && !this.spawnForm.name.trim()) {
OpenFangToast.warn('Please enter an agent name');
return;
}
if (this.spawnStep < 5) this.spawnStep++;
},
prevStep() {
if (this.spawnStep > 1) this.spawnStep--;
},
selectPreset(preset) {
this.selectedPreset = preset.id;
this.soulContent = preset.soul;
},
generateToml() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
lines.push('profile = "' + f.profile + '"');
}
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
if (f.caps.memory_write) lines.push('memory_write = ["self.*"]');
if (f.caps.network) lines.push('network = ["*"]');
if (f.caps.shell) lines.push('shell = ["*"]');
if (f.caps.agent_spawn) lines.push('agent_spawn = true');
}
return lines.join('\n');
},
async setMode(agent, mode) {
try {
await OpenFangAPI.put('/api/agents/' + agent.id + '/mode', { mode: mode });
agent.mode = mode;
OpenFangToast.success('Mode set to ' + mode);
await Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to set mode: ' + e.message);
}
},
async spawnAgent() {
this.spawning = true;
var toml = this.spawnMode === 'wizard' ? this.generateToml() : this.spawnToml;
if (!toml.trim()) {
this.spawning = false;
OpenFangToast.warn('Manifest is empty \u2014 enter agent config first');
return;
}
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
if (res.agent_id) {
// Post-spawn: update identity + write SOUL.md if personality preset selected
var patchBody = {};
if (this.spawnIdentity.emoji) patchBody.emoji = this.spawnIdentity.emoji;
if (this.spawnIdentity.color) patchBody.color = this.spawnIdentity.color;
if (this.spawnIdentity.archetype) patchBody.archetype = this.spawnIdentity.archetype;
if (this.selectedPreset) patchBody.vibe = this.selectedPreset;
if (Object.keys(patchBody).length) {
OpenFangAPI.patch('/api/agents/' + res.agent_id + '/config', patchBody).catch(function(e) { console.warn('Post-spawn config patch failed:', e.message); });
}
if (this.soulContent.trim()) {
OpenFangAPI.put('/api/agents/' + res.agent_id + '/files/SOUL.md', { content: '# Soul\n' + this.soulContent }).catch(function(e) { console.warn('SOUL.md write failed:', e.message); });
}
this.showSpawnModal = false;
this.spawnForm.name = '';
this.spawnToml = '';
this.spawnStep = 1;
OpenFangToast.success('Agent "' + (res.name || 'new') + '" spawned');
await Alpine.store('app').refreshAgents();
this.chatWithAgent({ id: res.agent_id, name: res.name, model_provider: '?', model_name: '?' });
} else {
OpenFangToast.error('Spawn failed: ' + (res.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Failed to spawn agent: ' + e.message);
}
this.spawning = false;
},
// ── Detail modal: Files tab ──
async loadAgentFiles() {
if (!this.detailAgent) return;
this.filesLoading = true;
try {
var data = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/files');
this.agentFiles = data.files || [];
} catch(e) {
this.agentFiles = [];
OpenFangToast.error('Failed to load files: ' + e.message);
}
this.filesLoading = false;
},
async openFile(file) {
if (!file.exists) {
// Create with empty content
this.editingFile = file.name;
this.fileContent = '';
return;
}
try {
var data = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/files/' + encodeURIComponent(file.name));
this.editingFile = file.name;
this.fileContent = data.content || '';
} catch(e) {
OpenFangToast.error('Failed to read file: ' + e.message);
}
},
async saveFile() {
if (!this.editingFile || !this.detailAgent) return;
this.fileSaving = true;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/files/' + encodeURIComponent(this.editingFile), { content: this.fileContent });
OpenFangToast.success(this.editingFile + ' saved');
await this.loadAgentFiles();
} catch(e) {
OpenFangToast.error('Failed to save file: ' + e.message);
}
this.fileSaving = false;
},
closeFileEditor() {
this.editingFile = null;
this.fileContent = '';
},
// ── Detail modal: Config tab ──
async saveConfig() {
if (!this.detailAgent) return;
this.configSaving = true;
try {
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', this.configForm);
OpenFangToast.success('Config updated');
await Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to save config: ' + e.message);
}
this.configSaving = false;
},
// ── Clone agent ──
async cloneAgent(agent) {
var newName = (agent.name || 'agent') + '-copy';
try {
var res = await OpenFangAPI.post('/api/agents/' + agent.id + '/clone', { new_name: newName });
if (res.agent_id) {
OpenFangToast.success('Cloned as "' + res.name + '"');
await Alpine.store('app').refreshAgents();
this.showDetailModal = false;
}
} catch(e) {
OpenFangToast.error('Clone failed: ' + e.message);
}
},
// -- Template methods --
async spawnFromTemplate(name) {
try {
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
if (data.manifest_toml) {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
if (res.agent_id) {
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
await Alpine.store('app').refreshAgents();
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
}
}
} catch(e) {
OpenFangToast.error('Failed to spawn from template: ' + e.message);
}
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
toml += 'module = "builtin:chat"\n';
toml += 'profile = "' + t.profile + '"\n\n';
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
if (res.agent_id) {
OpenFangToast.success('Agent "' + t.name + '" spawned');
await Alpine.store('app').refreshAgents();
this.chatWithAgent({ id: res.agent_id, name: t.name, model_provider: t.provider, model_name: t.model });
}
} catch(e) {
OpenFangToast.error('Failed to spawn agent: ' + e.message);
}
}
};
}

View File

@@ -0,0 +1,66 @@
// OpenFang Approvals Page — Execution approval queue for sensitive agent actions
'use strict';
function approvalsPage() {
return {
approvals: [],
filterStatus: 'all',
loading: true,
loadError: '',
get filtered() {
var f = this.filterStatus;
if (f === 'all') return this.approvals;
return this.approvals.filter(function(a) { return a.status === f; });
},
get pendingCount() {
return this.approvals.filter(function(a) { return a.status === 'pending'; }).length;
},
async loadData() {
this.loading = true;
this.loadError = '';
try {
var data = await OpenFangAPI.get('/api/approvals');
this.approvals = data.approvals || [];
} catch(e) {
this.loadError = e.message || 'Could not load approvals.';
}
this.loading = false;
},
async approve(id) {
try {
await OpenFangAPI.post('/api/approvals/' + id + '/approve', {});
OpenFangToast.success('Approved');
await this.loadData();
} catch(e) {
OpenFangToast.error(e.message);
}
},
async reject(id) {
var self = this;
OpenFangToast.confirm('Reject Action', 'Are you sure you want to reject this action?', async function() {
try {
await OpenFangAPI.post('/api/approvals/' + id + '/reject', {});
OpenFangToast.success('Rejected');
await self.loadData();
} catch(e) {
OpenFangToast.error(e.message);
}
});
},
timeAgo(dateStr) {
if (!dateStr) return '';
var d = new Date(dateStr);
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
if (secs < 60) return secs + 's ago';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
return Math.floor(secs / 86400) + 'd ago';
}
};
}

View File

@@ -0,0 +1,300 @@
// OpenFang Channels Page — OpenClaw-style setup UX with QR code support
'use strict';
function channelsPage() {
return {
allChannels: [],
categoryFilter: 'all',
searchQuery: '',
setupModal: null,
configuring: false,
testing: {},
formValues: {},
showAdvanced: false,
showBusinessApi: false,
loading: true,
loadError: '',
pollTimer: null,
// Setup flow step tracking
setupStep: 1, // 1=Configure, 2=Verify, 3=Ready
testPassed: false,
// WhatsApp QR state
qr: {
loading: false,
available: false,
dataUrl: '',
sessionId: '',
message: '',
help: '',
connected: false,
expired: false,
error: ''
},
qrPollTimer: null,
categories: [
{ key: 'all', label: 'All' },
{ key: 'messaging', label: 'Messaging' },
{ key: 'social', label: 'Social' },
{ key: 'enterprise', label: 'Enterprise' },
{ key: 'developer', label: 'Developer' },
{ key: 'notifications', label: 'Notifications' }
],
get filteredChannels() {
var self = this;
return this.allChannels.filter(function(ch) {
if (self.categoryFilter !== 'all' && ch.category !== self.categoryFilter) return false;
if (self.searchQuery) {
var q = self.searchQuery.toLowerCase();
return ch.name.toLowerCase().indexOf(q) !== -1 ||
ch.display_name.toLowerCase().indexOf(q) !== -1 ||
ch.description.toLowerCase().indexOf(q) !== -1;
}
return true;
});
},
get configuredCount() {
return this.allChannels.filter(function(ch) { return ch.configured; }).length;
},
categoryCount(cat) {
var all = this.allChannels.filter(function(ch) { return cat === 'all' || ch.category === cat; });
var configured = all.filter(function(ch) { return ch.configured; });
return configured.length + '/' + all.length;
},
basicFields() {
if (!this.setupModal || !this.setupModal.fields) return [];
return this.setupModal.fields.filter(function(f) { return !f.advanced; });
},
advancedFields() {
if (!this.setupModal || !this.setupModal.fields) return [];
return this.setupModal.fields.filter(function(f) { return f.advanced; });
},
hasAdvanced() {
return this.advancedFields().length > 0;
},
isQrChannel() {
return this.setupModal && this.setupModal.setup_type === 'qr';
},
async loadChannels() {
this.loading = true;
this.loadError = '';
try {
var data = await OpenFangAPI.get('/api/channels');
this.allChannels = (data.channels || []).map(function(ch) {
ch.connected = ch.configured && ch.has_token;
return ch;
});
} catch(e) {
this.loadError = e.message || 'Could not load channels.';
}
this.loading = false;
this.startPolling();
},
async loadData() { return this.loadChannels(); },
startPolling() {
var self = this;
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(function() { self.refreshStatus(); }, 15000);
},
async refreshStatus() {
try {
var data = await OpenFangAPI.get('/api/channels');
var byName = {};
(data.channels || []).forEach(function(ch) { byName[ch.name] = ch; });
this.allChannels.forEach(function(c) {
var fresh = byName[c.name];
if (fresh) {
c.configured = fresh.configured;
c.has_token = fresh.has_token;
c.connected = fresh.configured && fresh.has_token;
c.fields = fresh.fields;
}
});
} catch(e) { console.warn('Channel refresh failed:', e.message); }
},
statusBadge(ch) {
if (!ch.configured) return { text: 'Not Configured', cls: 'badge-muted' };
if (!ch.has_token) return { text: 'Missing Token', cls: 'badge-warn' };
if (ch.connected) return { text: 'Ready', cls: 'badge-success' };
return { text: 'Configured', cls: 'badge-info' };
},
difficultyClass(d) {
if (d === 'Easy') return 'difficulty-easy';
if (d === 'Hard') return 'difficulty-hard';
return 'difficulty-medium';
},
openSetup(ch) {
this.setupModal = ch;
this.formValues = {};
this.showAdvanced = false;
this.showBusinessApi = false;
this.setupStep = ch.configured ? 3 : 1;
this.testPassed = !!ch.configured;
this.resetQR();
// Auto-start QR flow for QR-type channels
if (ch.setup_type === 'qr') {
this.startQR();
}
},
// ── QR Code Flow (WhatsApp Web style) ──────────────────────────
resetQR() {
this.qr = {
loading: false, available: false, dataUrl: '', sessionId: '',
message: '', help: '', connected: false, expired: false, error: ''
};
if (this.qrPollTimer) { clearInterval(this.qrPollTimer); this.qrPollTimer = null; }
},
async startQR() {
this.qr.loading = true;
this.qr.error = '';
this.qr.connected = false;
this.qr.expired = false;
try {
var result = await OpenFangAPI.post('/api/channels/whatsapp/qr/start', {});
this.qr.available = result.available || false;
this.qr.dataUrl = result.qr_data_url || '';
this.qr.sessionId = result.session_id || '';
this.qr.message = result.message || '';
this.qr.help = result.help || '';
this.qr.connected = result.connected || false;
if (this.qr.available && this.qr.dataUrl && !this.qr.connected) {
this.pollQR();
}
if (this.qr.connected) {
OpenFangToast.success('WhatsApp connected!');
await this.refreshStatus();
}
} catch(e) {
this.qr.error = e.message || 'Could not start QR login';
}
this.qr.loading = false;
},
pollQR() {
var self = this;
if (this.qrPollTimer) clearInterval(this.qrPollTimer);
this.qrPollTimer = setInterval(async function() {
try {
var result = await OpenFangAPI.get('/api/channels/whatsapp/qr/status?session_id=' + encodeURIComponent(self.qr.sessionId));
if (result.connected) {
clearInterval(self.qrPollTimer);
self.qrPollTimer = null;
self.qr.connected = true;
self.qr.message = result.message || 'Connected!';
OpenFangToast.success('WhatsApp linked successfully!');
await self.refreshStatus();
} else if (result.expired) {
clearInterval(self.qrPollTimer);
self.qrPollTimer = null;
self.qr.expired = true;
self.qr.message = 'QR code expired. Click to generate a new one.';
} else {
self.qr.message = result.message || 'Waiting for scan...';
}
} catch(e) { /* silent retry */ }
}, 3000);
},
// ── Standard Form Flow ─────────────────────────────────────────
async saveChannel() {
if (!this.setupModal) return;
var name = this.setupModal.name;
this.configuring = true;
try {
await OpenFangAPI.post('/api/channels/' + name + '/configure', {
fields: this.formValues
});
this.setupStep = 2;
// Auto-test after save
try {
var testResult = await OpenFangAPI.post('/api/channels/' + name + '/test', {});
if (testResult.status === 'ok') {
this.testPassed = true;
this.setupStep = 3;
OpenFangToast.success(this.setupModal.display_name + ' activated!');
} else {
OpenFangToast.success(this.setupModal.display_name + ' saved. ' + (testResult.message || ''));
}
} catch(te) {
OpenFangToast.success(this.setupModal.display_name + ' saved. Test to verify connection.');
}
await this.refreshStatus();
} catch(e) {
OpenFangToast.error('Failed: ' + (e.message || 'Unknown error'));
}
this.configuring = false;
},
async removeChannel() {
if (!this.setupModal) return;
var name = this.setupModal.name;
var displayName = this.setupModal.display_name;
var self = this;
OpenFangToast.confirm('Remove Channel', 'Remove ' + displayName + ' configuration? This will deactivate the channel.', async function() {
try {
await OpenFangAPI.delete('/api/channels/' + name + '/configure');
OpenFangToast.success(displayName + ' removed and deactivated.');
await self.refreshStatus();
self.setupModal = null;
} catch(e) {
OpenFangToast.error('Failed: ' + (e.message || 'Unknown error'));
}
});
},
async testChannel() {
if (!this.setupModal) return;
var name = this.setupModal.name;
this.testing[name] = true;
try {
var result = await OpenFangAPI.post('/api/channels/' + name + '/test', {});
if (result.status === 'ok') {
this.testPassed = true;
this.setupStep = 3;
OpenFangToast.success(result.message);
} else {
OpenFangToast.error(result.message);
}
} catch(e) {
OpenFangToast.error('Test failed: ' + (e.message || 'Unknown error'));
}
this.testing[name] = false;
},
async copyConfig(ch) {
var tpl = ch ? ch.config_template : (this.setupModal ? this.setupModal.config_template : '');
if (!tpl) return;
try {
await navigator.clipboard.writeText(tpl);
OpenFangToast.success('Copied to clipboard');
} catch(e) {
OpenFangToast.error('Copy failed');
}
},
destroy() {
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
if (this.qrPollTimer) { clearInterval(this.qrPollTimer); this.qrPollTimer = null; }
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,504 @@
// OpenFang Hands Page — curated autonomous capability packages
'use strict';
function handsPage() {
return {
tab: 'available',
hands: [],
instances: [],
loading: true,
activeLoading: false,
loadError: '',
activatingId: null,
activateResult: null,
detailHand: null,
settingsValues: {},
_toastTimer: null,
browserViewer: null,
browserViewerOpen: false,
_browserPollTimer: null,
// ── Setup Wizard State ──────────────────────────────────────────────
setupWizard: null,
setupStep: 1,
setupLoading: false,
setupChecking: false,
clipboardMsg: null,
_clipboardTimer: null,
detectedPlatform: 'linux',
installPlatforms: {},
async loadData() {
this.loading = true;
this.loadError = '';
try {
var data = await OpenFangAPI.get('/api/hands');
this.hands = data.hands || [];
} catch(e) {
this.hands = [];
this.loadError = e.message || 'Could not load hands.';
}
this.loading = false;
},
async loadActive() {
this.activeLoading = true;
try {
var data = await OpenFangAPI.get('/api/hands/active');
this.instances = (data.instances || []).map(function(i) {
i._stats = null;
return i;
});
} catch(e) {
this.instances = [];
}
this.activeLoading = false;
},
getHandIcon(handId) {
for (var i = 0; i < this.hands.length; i++) {
if (this.hands[i].id === handId) return this.hands[i].icon;
}
return '\u{1F91A}';
},
async showDetail(handId) {
try {
var data = await OpenFangAPI.get('/api/hands/' + handId);
this.detailHand = data;
} catch(e) {
for (var i = 0; i < this.hands.length; i++) {
if (this.hands[i].id === handId) {
this.detailHand = this.hands[i];
break;
}
}
}
},
// ── Setup Wizard ────────────────────────────────────────────────────
async activate(handId) {
this.openSetupWizard(handId);
},
async openSetupWizard(handId) {
this.setupLoading = true;
this.setupWizard = null;
try {
var data = await OpenFangAPI.get('/api/hands/' + handId);
// Pre-populate settings defaults
this.settingsValues = {};
if (data.settings && data.settings.length > 0) {
for (var i = 0; i < data.settings.length; i++) {
var s = data.settings[i];
this.settingsValues[s.key] = s.default || '';
}
}
// Detect platform from server response, fallback to client-side
if (data.server_platform) {
this.detectedPlatform = data.server_platform;
} else {
this._detectClientPlatform();
}
// Initialize per-requirement platform selections
this.installPlatforms = {};
if (data.requirements) {
for (var j = 0; j < data.requirements.length; j++) {
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
}
}
this.setupWizard = data;
// Skip deps step if no requirements
var hasReqs = data.requirements && data.requirements.length > 0;
this.setupStep = hasReqs ? 1 : 2;
} catch(e) {
this.showToast('Could not load hand details: ' + (e.message || 'unknown error'));
}
this.setupLoading = false;
},
_detectClientPlatform() {
var ua = (navigator.userAgent || '').toLowerCase();
if (ua.indexOf('mac') !== -1) {
this.detectedPlatform = 'macos';
} else if (ua.indexOf('win') !== -1) {
this.detectedPlatform = 'windows';
} else {
this.detectedPlatform = 'linux';
}
},
// ── Auto-Install Dependencies ───────────────────────────────────
installProgress: null, // null = idle, object = { status, current, total, results, error }
async installDeps() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
var missing = (this.setupWizard.requirements || []).filter(function(r) { return !r.satisfied; });
if (missing.length === 0) {
this.showToast('All dependencies already installed!');
return;
}
this.installProgress = {
status: 'installing',
current: 0,
total: missing.length,
currentLabel: missing[0] ? missing[0].label : '',
results: [],
error: null
};
try {
var data = await OpenFangAPI.post('/api/hands/' + handId + '/install-deps', {});
var results = data.results || [];
this.installProgress.results = results;
this.installProgress.current = results.length;
this.installProgress.status = 'done';
// Update requirements from server response
if (data.requirements && this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var existing = this.setupWizard.requirements[i];
for (var j = 0; j < data.requirements.length; j++) {
if (data.requirements[j].key === existing.key) {
existing.satisfied = data.requirements[j].satisfied;
break;
}
}
}
this.setupWizard.requirements_met = data.requirements_met;
}
var installed = results.filter(function(r) { return r.status === 'installed' || r.status === 'already_installed'; }).length;
var failed = results.filter(function(r) { return r.status === 'error' || r.status === 'timeout'; }).length;
if (data.requirements_met) {
this.showToast('All dependencies installed successfully!');
// Auto-advance to step 2 after a short delay
var self = this;
setTimeout(function() {
self.installProgress = null;
self.setupNextStep();
}, 1500);
} else if (failed > 0) {
this.installProgress.error = failed + ' dependency(ies) failed to install. Check the details below.';
}
} catch(e) {
this.installProgress = {
status: 'error',
current: 0,
total: missing.length,
currentLabel: '',
results: [],
error: e.message || 'Installation request failed'
};
}
},
getInstallResultIcon(status) {
if (status === 'installed' || status === 'already_installed') return '\u2713';
if (status === 'error' || status === 'timeout') return '\u2717';
return '\u2022';
},
getInstallResultClass(status) {
if (status === 'installed' || status === 'already_installed') return 'dep-met';
if (status === 'error' || status === 'timeout') return 'dep-missing';
return '';
},
async recheckDeps() {
if (!this.setupWizard) return;
this.setupChecking = true;
try {
var data = await OpenFangAPI.post('/api/hands/' + this.setupWizard.id + '/check-deps', {});
if (data.requirements && this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var existing = this.setupWizard.requirements[i];
for (var j = 0; j < data.requirements.length; j++) {
if (data.requirements[j].key === existing.key) {
existing.satisfied = data.requirements[j].satisfied;
break;
}
}
}
this.setupWizard.requirements_met = data.requirements_met;
}
if (data.requirements_met) {
this.showToast('All dependencies satisfied!');
}
} catch(e) {
this.showToast('Check failed: ' + (e.message || 'unknown'));
}
this.setupChecking = false;
},
getInstallCmd(req) {
if (!req || !req.install) return null;
var inst = req.install;
var plat = this.installPlatforms[req.key] || this.detectedPlatform;
if (plat === 'macos' && inst.macos) return inst.macos;
if (plat === 'windows' && inst.windows) return inst.windows;
if (plat === 'linux') {
return inst.linux_apt || inst.linux_dnf || inst.linux_pacman || inst.pip || null;
}
return inst.pip || inst.macos || inst.windows || inst.linux_apt || null;
},
getLinuxVariant(req) {
if (!req || !req.install) return null;
var inst = req.install;
var plat = this.installPlatforms[req.key] || this.detectedPlatform;
if (plat !== 'linux') return null;
// Return all available Linux variants
var variants = [];
if (inst.linux_apt) variants.push({ label: 'apt', cmd: inst.linux_apt });
if (inst.linux_dnf) variants.push({ label: 'dnf', cmd: inst.linux_dnf });
if (inst.linux_pacman) variants.push({ label: 'pacman', cmd: inst.linux_pacman });
if (inst.pip) variants.push({ label: 'pip', cmd: inst.pip });
return variants.length > 1 ? variants : null;
},
copyToClipboard(text) {
var self = this;
navigator.clipboard.writeText(text).then(function() {
self.clipboardMsg = text;
if (self._clipboardTimer) clearTimeout(self._clipboardTimer);
self._clipboardTimer = setTimeout(function() { self.clipboardMsg = null; }, 2000);
});
},
get setupReqsMet() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
var count = 0;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
if (this.setupWizard.requirements[i].satisfied) count++;
}
return count;
},
get setupReqsTotal() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
return this.setupWizard.requirements.length;
},
get setupAllReqsMet() {
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
},
get setupHasReqs() {
return this.setupReqsTotal > 0;
},
get setupHasSettings() {
return this.setupWizard && this.setupWizard.settings && this.setupWizard.settings.length > 0;
},
setupNextStep() {
if (this.setupStep === 1 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 1) {
this.setupStep = 3;
} else if (this.setupStep === 2) {
this.setupStep = 3;
}
},
setupPrevStep() {
if (this.setupStep === 3 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 3) {
this.setupStep = this.setupHasReqs ? 1 : 2;
} else if (this.setupStep === 2 && this.setupHasReqs) {
this.setupStep = 1;
}
},
closeSetupWizard() {
this.setupWizard = null;
this.setupStep = 1;
this.setupLoading = false;
this.setupChecking = false;
this.clipboardMsg = null;
this.installPlatforms = {};
},
async launchHand() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
var config = {};
for (var key in this.settingsValues) {
config[key] = this.settingsValues[key];
}
this.activatingId = handId;
try {
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', { config: config });
this.showToast('Hand "' + handId + '" activated as ' + (data.agent_name || data.instance_id));
this.closeSetupWizard();
await this.loadActive();
this.tab = 'active';
} catch(e) {
this.showToast('Activation failed: ' + (e.message || 'unknown error'));
}
this.activatingId = null;
},
selectOption(settingKey, value) {
this.settingsValues[settingKey] = value;
},
getSettingDisplayValue(setting) {
var val = this.settingsValues[setting.key] || setting.default || '';
if (setting.setting_type === 'toggle') {
return val === 'true' ? 'Enabled' : 'Disabled';
}
if (setting.setting_type === 'select' && setting.options) {
for (var i = 0; i < setting.options.length; i++) {
if (setting.options[i].value === val) return setting.options[i].label;
}
}
return val || '-';
},
// ── Existing methods ────────────────────────────────────────────────
async pauseHand(inst) {
try {
await OpenFangAPI.post('/api/hands/instances/' + inst.instance_id + '/pause', {});
inst.status = 'Paused';
} catch(e) {
this.showToast('Pause failed: ' + (e.message || 'unknown error'));
}
},
async resumeHand(inst) {
try {
await OpenFangAPI.post('/api/hands/instances/' + inst.instance_id + '/resume', {});
inst.status = 'Active';
} catch(e) {
this.showToast('Resume failed: ' + (e.message || 'unknown error'));
}
},
async deactivate(inst) {
var self = this;
var handName = inst.agent_name || inst.hand_id;
OpenFangToast.confirm('Deactivate Hand', 'Deactivate hand "' + handName + '"? This will kill its agent.', async function() {
try {
await OpenFangAPI.delete('/api/hands/instances/' + inst.instance_id);
self.instances = self.instances.filter(function(i) { return i.instance_id !== inst.instance_id; });
OpenFangToast.success('Hand deactivated.');
} catch(e) {
OpenFangToast.error('Deactivation failed: ' + (e.message || 'unknown error'));
}
});
},
async loadStats(inst) {
try {
var data = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
inst._stats = data.metrics || {};
} catch(e) {
inst._stats = { 'Error': { value: e.message || 'Could not load stats', format: 'text' } };
}
},
formatMetric(m) {
if (!m || m.value === null || m.value === undefined) return '-';
if (m.format === 'duration') {
var secs = parseInt(m.value, 10);
if (isNaN(secs)) return String(m.value);
var h = Math.floor(secs / 3600);
var min = Math.floor((secs % 3600) / 60);
var s = secs % 60;
if (h > 0) return h + 'h ' + min + 'm';
if (min > 0) return min + 'm ' + s + 's';
return s + 's';
}
if (m.format === 'number') {
var n = parseFloat(m.value);
if (isNaN(n)) return String(m.value);
return n.toLocaleString();
}
return String(m.value);
},
showToast(msg) {
var self = this;
this.activateResult = msg;
if (this._toastTimer) clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(function() { self.activateResult = null; }, 4000);
},
// ── Browser Viewer ───────────────────────────────────────────────────
isBrowserHand(inst) {
return inst.hand_id === 'browser';
},
async openBrowserViewer(inst) {
this.browserViewer = {
instance_id: inst.instance_id,
hand_id: inst.hand_id,
agent_name: inst.agent_name,
url: '',
title: '',
screenshot: '',
content: '',
loading: true,
error: ''
};
this.browserViewerOpen = true;
await this.refreshBrowserView();
this.startBrowserPolling();
},
async refreshBrowserView() {
if (!this.browserViewer) return;
var id = this.browserViewer.instance_id;
try {
var data = await OpenFangAPI.get('/api/hands/instances/' + id + '/browser');
if (data.active) {
this.browserViewer.url = data.url || '';
this.browserViewer.title = data.title || '';
this.browserViewer.screenshot = data.screenshot_base64 || '';
this.browserViewer.content = data.content || '';
this.browserViewer.error = '';
} else {
this.browserViewer.error = 'No active browser session';
this.browserViewer.screenshot = '';
}
} catch(e) {
this.browserViewer.error = e.message || 'Could not load browser state';
}
this.browserViewer.loading = false;
},
startBrowserPolling() {
var self = this;
this.stopBrowserPolling();
this._browserPollTimer = setInterval(function() {
if (self.browserViewerOpen) {
self.refreshBrowserView();
} else {
self.stopBrowserPolling();
}
}, 3000);
},
stopBrowserPolling() {
if (this._browserPollTimer) {
clearInterval(this._browserPollTimer);
this._browserPollTimer = null;
}
},
closeBrowserViewer() {
this.stopBrowserPolling();
this.browserViewerOpen = false;
this.browserViewer = null;
}
};
}

View File

@@ -0,0 +1,255 @@
// OpenFang Logs Page — Real-time log viewer (SSE streaming + polling fallback) + Audit Trail tab
'use strict';
function logsPage() {
return {
tab: 'live',
// -- Live logs state --
entries: [],
levelFilter: '',
textFilter: '',
autoRefresh: true,
hovering: false,
loading: true,
loadError: '',
_pollTimer: null,
// -- SSE streaming state --
_eventSource: null,
streamConnected: false,
streamPaused: false,
// -- Audit state --
auditEntries: [],
tipHash: '',
chainValid: null,
filterAction: '',
auditLoading: false,
auditLoadError: '',
startStreaming: function() {
var self = this;
if (this._eventSource) { this._eventSource.close(); this._eventSource = null; }
var url = '/api/logs/stream';
var sep = '?';
var token = OpenFangAPI.getToken();
if (token) { url += sep + 'token=' + encodeURIComponent(token); sep = '&'; }
try {
this._eventSource = new EventSource(url);
} catch(e) {
// EventSource not supported or blocked; fall back to polling
this.streamConnected = false;
this.startPolling();
return;
}
this._eventSource.onopen = function() {
self.streamConnected = true;
self.loading = false;
self.loadError = '';
};
this._eventSource.onmessage = function(event) {
if (self.streamPaused) return;
try {
var entry = JSON.parse(event.data);
// Avoid duplicate entries by checking seq
var dominated = false;
for (var i = 0; i < self.entries.length; i++) {
if (self.entries[i].seq === entry.seq) { dominated = true; break; }
}
if (!dominated) {
self.entries.push(entry);
// Cap at 500 entries (remove oldest)
if (self.entries.length > 500) {
self.entries.splice(0, self.entries.length - 500);
}
// Auto-scroll to bottom
if (self.autoRefresh && !self.hovering) {
self.$nextTick(function() {
var el = document.getElementById('log-container');
if (el) el.scrollTop = el.scrollHeight;
});
}
}
} catch(e) {
// Ignore parse errors (heartbeat comments are not delivered to onmessage)
}
};
this._eventSource.onerror = function() {
self.streamConnected = false;
if (self._eventSource) {
self._eventSource.close();
self._eventSource = null;
}
// Fall back to polling
self.startPolling();
};
},
startPolling: function() {
var self = this;
this.streamConnected = false;
this.fetchLogs();
if (this._pollTimer) clearInterval(this._pollTimer);
this._pollTimer = setInterval(function() {
if (self.autoRefresh && !self.hovering && self.tab === 'live' && !self.streamPaused) {
self.fetchLogs();
}
}, 2000);
},
async fetchLogs() {
if (this.loading) this.loadError = '';
try {
var data = await OpenFangAPI.get('/api/audit/recent?n=200');
this.entries = data.entries || [];
if (this.autoRefresh && !this.hovering) {
this.$nextTick(function() {
var el = document.getElementById('log-container');
if (el) el.scrollTop = el.scrollHeight;
});
}
if (this.loading) this.loading = false;
} catch(e) {
if (this.loading) {
this.loadError = e.message || 'Could not load logs.';
this.loading = false;
}
}
},
async loadData() {
this.loading = true;
return this.fetchLogs();
},
togglePause: function() {
this.streamPaused = !this.streamPaused;
if (!this.streamPaused && this.streamConnected) {
// Resume: scroll to bottom
var self = this;
this.$nextTick(function() {
var el = document.getElementById('log-container');
if (el) el.scrollTop = el.scrollHeight;
});
}
},
clearLogs: function() {
this.entries = [];
},
classifyLevel: function(action) {
if (!action) return 'info';
var a = action.toLowerCase();
if (a.indexOf('error') !== -1 || a.indexOf('fail') !== -1 || a.indexOf('crash') !== -1) return 'error';
if (a.indexOf('warn') !== -1 || a.indexOf('deny') !== -1 || a.indexOf('block') !== -1) return 'warn';
return 'info';
},
get filteredEntries() {
var self = this;
var levelF = this.levelFilter;
var textF = this.textFilter.toLowerCase();
return this.entries.filter(function(e) {
if (levelF && self.classifyLevel(e.action) !== levelF) return false;
if (textF) {
var haystack = ((e.action || '') + ' ' + (e.detail || '') + ' ' + (e.agent_id || '')).toLowerCase();
if (haystack.indexOf(textF) === -1) return false;
}
return true;
});
},
get connectionLabel() {
if (this.streamPaused) return 'Paused';
if (this.streamConnected) return 'Live';
if (this._pollTimer) return 'Polling';
return 'Disconnected';
},
get connectionClass() {
if (this.streamPaused) return 'paused';
if (this.streamConnected) return 'live';
if (this._pollTimer) return 'polling';
return 'disconnected';
},
exportLogs: function() {
var lines = this.filteredEntries.map(function(e) {
return new Date(e.timestamp).toISOString() + ' [' + e.action + '] ' + (e.detail || '');
});
var blob = new Blob([lines.join('\n')], { type: 'text/plain' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'openfang-logs-' + new Date().toISOString().slice(0, 10) + '.txt';
a.click();
URL.revokeObjectURL(url);
},
// -- Audit methods --
get filteredAuditEntries() {
var self = this;
if (!self.filterAction) return self.auditEntries;
return self.auditEntries.filter(function(e) { return e.action === self.filterAction; });
},
async loadAudit() {
this.auditLoading = true;
this.auditLoadError = '';
try {
var data = await OpenFangAPI.get('/api/audit/recent?n=200');
this.auditEntries = data.entries || [];
this.tipHash = data.tip_hash || '';
} catch(e) {
this.auditEntries = [];
this.auditLoadError = e.message || 'Could not load audit log.';
}
this.auditLoading = false;
},
auditAgentName: function(agentId) {
if (!agentId) return '-';
var agents = Alpine.store('app').agents || [];
var agent = agents.find(function(a) { return a.id === agentId; });
return agent ? agent.name : agentId.substring(0, 8) + '...';
},
friendlyAction: function(action) {
if (!action) return 'Unknown';
var map = {
'AgentSpawn': 'Agent Created', 'AgentKill': 'Agent Stopped', 'AgentTerminated': 'Agent Stopped',
'ToolInvoke': 'Tool Used', 'ToolResult': 'Tool Completed', 'AgentMessage': 'Message',
'NetworkAccess': 'Network Access', 'ShellExec': 'Shell Command', 'FileAccess': 'File Access',
'MemoryAccess': 'Memory Access', 'AuthAttempt': 'Login Attempt', 'AuthSuccess': 'Login Success',
'AuthFailure': 'Login Failed', 'CapabilityDenied': 'Permission Denied', 'RateLimited': 'Rate Limited'
};
return map[action] || action.replace(/([A-Z])/g, ' $1').trim();
},
async verifyChain() {
try {
var data = await OpenFangAPI.get('/api/audit/verify');
this.chainValid = data.valid === true;
if (this.chainValid) {
OpenFangToast.success('Audit chain verified — ' + (data.entries || 0) + ' entries valid');
} else {
OpenFangToast.error('Audit chain broken!');
}
} catch(e) {
this.chainValid = false;
OpenFangToast.error('Chain verification failed: ' + e.message);
}
},
destroy: function() {
if (this._eventSource) { this._eventSource.close(); this._eventSource = null; }
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
}
};
}

View File

@@ -0,0 +1,292 @@
// OpenFang Overview Dashboard — Landing page with system stats + provider status
'use strict';
function overviewPage() {
return {
health: {},
status: {},
usageSummary: {},
recentAudit: [],
channels: [],
providers: [],
mcpServers: [],
skillCount: 0,
loading: true,
loadError: '',
refreshTimer: null,
lastRefresh: null,
async loadOverview() {
this.loading = true;
this.loadError = '';
try {
await Promise.all([
this.loadHealth(),
this.loadStatus(),
this.loadUsage(),
this.loadAudit(),
this.loadChannels(),
this.loadProviders(),
this.loadMcpServers(),
this.loadSkills()
]);
this.lastRefresh = Date.now();
} catch(e) {
this.loadError = e.message || 'Could not load overview data.';
}
this.loading = false;
},
async loadData() { return this.loadOverview(); },
// Silent background refresh (no loading spinner)
async silentRefresh() {
try {
await Promise.all([
this.loadHealth(),
this.loadStatus(),
this.loadUsage(),
this.loadAudit(),
this.loadChannels(),
this.loadProviders(),
this.loadMcpServers(),
this.loadSkills()
]);
this.lastRefresh = Date.now();
} catch(e) { /* silent */ }
},
startAutoRefresh() {
this.stopAutoRefresh();
this.refreshTimer = setInterval(() => this.silentRefresh(), 30000);
},
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
},
async loadHealth() {
try {
this.health = await OpenFangAPI.get('/api/health');
} catch(e) { this.health = { status: 'unreachable' }; }
},
async loadStatus() {
try {
this.status = await OpenFangAPI.get('/api/status');
} catch(e) { this.status = {}; throw e; }
},
async loadUsage() {
try {
var data = await OpenFangAPI.get('/api/usage');
var agents = data.agents || [];
var totalTokens = 0;
var totalTools = 0;
var totalCost = 0;
agents.forEach(function(a) {
totalTokens += (a.total_tokens || 0);
totalTools += (a.tool_calls || 0);
totalCost += (a.cost_usd || 0);
});
this.usageSummary = {
total_tokens: totalTokens,
total_tools: totalTools,
total_cost: totalCost,
agent_count: agents.length
};
} catch(e) {
this.usageSummary = { total_tokens: 0, total_tools: 0, total_cost: 0, agent_count: 0 };
}
},
async loadAudit() {
try {
var data = await OpenFangAPI.get('/api/audit/recent?n=8');
this.recentAudit = data.entries || [];
} catch(e) { this.recentAudit = []; }
},
async loadChannels() {
try {
var data = await OpenFangAPI.get('/api/channels');
this.channels = (data.channels || []).filter(function(ch) { return ch.has_token; });
} catch(e) { this.channels = []; }
},
async loadProviders() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
} catch(e) { this.providers = []; }
},
async loadMcpServers() {
try {
var data = await OpenFangAPI.get('/api/mcp/servers');
this.mcpServers = data.servers || [];
} catch(e) { this.mcpServers = []; }
},
async loadSkills() {
try {
var data = await OpenFangAPI.get('/api/skills');
this.skillCount = (data.skills || []).length;
} catch(e) { this.skillCount = 0; }
},
get configuredProviders() {
return this.providers.filter(function(p) { return p.auth_status === 'configured'; });
},
get unconfiguredProviders() {
return this.providers.filter(function(p) { return p.auth_status === 'not_set' || p.auth_status === 'missing'; });
},
get connectedMcp() {
return this.mcpServers.filter(function(s) { return s.status === 'connected'; });
},
// Provider health badge color
providerBadgeClass(p) {
if (p.auth_status === 'configured') {
if (p.health === 'cooldown' || p.health === 'open') return 'badge-warn';
return 'badge-success';
}
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'badge-muted';
return 'badge-dim';
},
// Provider health tooltip
providerTooltip(p) {
if (p.health === 'cooldown') return p.display_name + ' \u2014 cooling down (rate limited)';
if (p.health === 'open') return p.display_name + ' \u2014 circuit breaker open';
if (p.auth_status === 'configured') return p.display_name + ' \u2014 ready';
return p.display_name + ' \u2014 not configured';
},
// Audit action badge color
actionBadgeClass(action) {
if (!action) return 'badge-dim';
if (action === 'AgentSpawn' || action === 'AuthSuccess') return 'badge-success';
if (action === 'AgentKill' || action === 'AgentTerminated' || action === 'AuthFailure' || action === 'CapabilityDenied') return 'badge-error';
if (action === 'RateLimited' || action === 'ToolInvoke') return 'badge-warn';
return 'badge-created';
},
// ── Setup Checklist ──
checklistDismissed: localStorage.getItem('of-checklist-dismissed') === 'true',
get setupChecklist() {
return [
{ key: 'provider', label: 'Configure an LLM provider', done: this.configuredProviders.length > 0, action: '#settings' },
{ key: 'agent', label: 'Create your first agent', done: (Alpine.store('app').agents || []).length > 0, action: '#agents' },
{ key: 'chat', label: 'Send your first message', done: localStorage.getItem('of-first-msg') === 'true', action: '#chat' },
{ key: 'channel', label: 'Connect a messaging channel', done: this.channels.length > 0, action: '#channels' },
{ key: 'skill', label: 'Browse or install a skill', done: localStorage.getItem('of-skill-browsed') === 'true', action: '#skills' }
];
},
get setupProgress() {
var done = this.setupChecklist.filter(function(item) { return item.done; }).length;
return (done / 5) * 100;
},
get setupDoneCount() {
return this.setupChecklist.filter(function(item) { return item.done; }).length;
},
dismissChecklist() {
this.checklistDismissed = true;
localStorage.setItem('of-checklist-dismissed', 'true');
},
formatUptime(secs) {
if (!secs) return '-';
var d = Math.floor(secs / 86400);
var h = Math.floor((secs % 86400) / 3600);
var m = Math.floor((secs % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
},
formatNumber(n) {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
},
formatCost(n) {
if (!n || n === 0) return '$0.00';
if (n < 0.01) return '<$0.01';
return '$' + n.toFixed(2);
},
// Relative time formatting ("2m ago", "1h ago", "just now")
timeAgo(timestamp) {
if (!timestamp) return '';
var now = Date.now();
var ts = new Date(timestamp).getTime();
var diff = Math.floor((now - ts) / 1000);
if (diff < 10) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
},
// Map raw audit action names to user-friendly labels
friendlyAction(action) {
if (!action) return 'Unknown';
var map = {
'AgentSpawn': 'Agent Created',
'AgentKill': 'Agent Stopped',
'AgentTerminated': 'Agent Stopped',
'ToolInvoke': 'Tool Used',
'ToolResult': 'Tool Completed',
'MessageReceived': 'Message In',
'MessageSent': 'Response Sent',
'SessionReset': 'Session Reset',
'SessionCompact': 'Compacted',
'ModelSwitch': 'Model Changed',
'AuthAttempt': 'Login Attempt',
'AuthSuccess': 'Login OK',
'AuthFailure': 'Login Failed',
'CapabilityDenied': 'Denied',
'RateLimited': 'Rate Limited',
'WorkflowRun': 'Workflow Run',
'TriggerFired': 'Trigger Fired',
'SkillInstalled': 'Skill Installed',
'McpConnected': 'MCP Connected'
};
return map[action] || action.replace(/([A-Z])/g, ' $1').trim();
},
// Audit action icon (small inline SVG)
actionIcon(action) {
if (!action) return '';
var icons = {
'AgentSpawn': '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>',
'AgentKill': '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>',
'AgentTerminated': '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>',
'ToolInvoke': '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
'MessageReceived': '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
'MessageSent': '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>'
};
return icons[action] || '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
},
// Resolve agent UUID to name if possible
agentName(agentId) {
if (!agentId) return '-';
var agents = Alpine.store('app').agents || [];
var agent = agents.find(function(a) { return a.id === agentId; });
return agent ? agent.name : agentId.substring(0, 8) + '\u2026';
}
};
}

View File

@@ -0,0 +1,393 @@
// OpenFang Scheduler Page — Cron job management + event triggers unified view
'use strict';
function schedulerPage() {
return {
tab: 'jobs',
// -- Scheduled Jobs state --
jobs: [],
loading: true,
loadError: '',
// -- Event Triggers state --
triggers: [],
trigLoading: false,
trigLoadError: '',
// -- Run History state --
history: [],
historyLoading: false,
// -- Create Job form --
showCreateForm: false,
newJob: {
name: '',
cron: '',
agent_id: '',
message: '',
enabled: true
},
creating: false,
// -- Run Now state --
runningJobId: '',
// Cron presets
cronPresets: [
{ label: 'Every minute', cron: '* * * * *' },
{ label: 'Every 5 minutes', cron: '*/5 * * * *' },
{ label: 'Every 15 minutes', cron: '*/15 * * * *' },
{ label: 'Every 30 minutes', cron: '*/30 * * * *' },
{ label: 'Every hour', cron: '0 * * * *' },
{ label: 'Every 6 hours', cron: '0 */6 * * *' },
{ label: 'Daily at midnight', cron: '0 0 * * *' },
{ label: 'Daily at 9am', cron: '0 9 * * *' },
{ label: 'Weekdays at 9am', cron: '0 9 * * 1-5' },
{ label: 'Every Monday 9am', cron: '0 9 * * 1' },
{ label: 'First of month', cron: '0 0 1 * *' }
],
// ── Lifecycle ──
async loadData() {
this.loading = true;
this.loadError = '';
try {
await this.loadJobs();
} catch(e) {
this.loadError = e.message || 'Could not load scheduler data.';
}
this.loading = false;
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
// Normalize cron API response to flat fields the UI expects
this.jobs = raw.map(function(j) {
var cron = '';
if (j.schedule) {
if (j.schedule.kind === 'cron') cron = j.schedule.expr || '';
else if (j.schedule.kind === 'every') cron = 'every ' + j.schedule.every_secs + 's';
else if (j.schedule.kind === 'at') cron = 'at ' + (j.schedule.at || '');
}
return {
id: j.id,
name: j.name,
cron: cron,
agent_id: j.agent_id,
message: j.action ? j.action.message || '' : '',
enabled: j.enabled,
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
created_at: j.created_at
};
});
},
async loadTriggers() {
this.trigLoading = true;
this.trigLoadError = '';
try {
var data = await OpenFangAPI.get('/api/triggers');
this.triggers = Array.isArray(data) ? data : [];
} catch(e) {
this.triggers = [];
this.trigLoadError = e.message || 'Could not load triggers.';
}
this.trigLoading = false;
},
async loadHistory() {
this.historyLoading = true;
try {
var historyItems = [];
var jobs = this.jobs || [];
for (var i = 0; i < jobs.length; i++) {
var job = jobs[i];
if (job.last_run) {
historyItems.push({
timestamp: job.last_run,
name: job.name || '(unnamed)',
type: 'schedule',
status: 'completed',
run_count: 0
});
}
}
var triggers = this.triggers || [];
for (var j = 0; j < triggers.length; j++) {
var t = triggers[j];
if (t.fire_count > 0) {
historyItems.push({
timestamp: t.created_at,
name: 'Trigger: ' + this.triggerType(t.pattern),
type: 'trigger',
status: 'fired',
run_count: t.fire_count
});
}
}
historyItems.sort(function(a, b) {
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
this.history = historyItems;
} catch(e) {
this.history = [];
}
this.historyLoading = false;
},
// ── Job CRUD ──
async createJob() {
if (!this.newJob.name.trim()) {
OpenFangToast.warn('Please enter a job name');
return;
}
if (!this.newJob.cron.trim()) {
OpenFangToast.warn('Please enter a cron expression');
return;
}
this.creating = true;
try {
var jobName = this.newJob.name;
var body = {
agent_id: this.newJob.agent_id,
name: this.newJob.name,
schedule: { kind: 'cron', expr: this.newJob.cron },
action: { kind: 'agent_turn', message: this.newJob.message || 'Scheduled task: ' + this.newJob.name },
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
};
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
OpenFangToast.success('Schedule "' + jobName + '" created');
await this.loadJobs();
} catch(e) {
OpenFangToast.error('Failed to create schedule: ' + (e.message || e));
}
this.creating = false;
},
async toggleJob(job) {
try {
var newState = !job.enabled;
await OpenFangAPI.put('/api/cron/jobs/' + job.id + '/enable', { enabled: newState });
job.enabled = newState;
OpenFangToast.success('Schedule ' + (newState ? 'enabled' : 'paused'));
} catch(e) {
OpenFangToast.error('Failed to toggle schedule: ' + (e.message || e));
}
},
deleteJob(job) {
var self = this;
var jobName = job.name || job.id;
OpenFangToast.confirm('Delete Schedule', 'Delete "' + jobName + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/cron/jobs/' + job.id);
self.jobs = self.jobs.filter(function(j) { return j.id !== job.id; });
OpenFangToast.success('Schedule "' + jobName + '" deleted');
} catch(e) {
OpenFangToast.error('Failed to delete schedule: ' + (e.message || e));
}
});
},
async runNow(job) {
this.runningJobId = job.id;
try {
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
if (result.status === 'completed') {
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
job.last_run = new Date().toISOString();
} else {
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Run Now is not yet available for cron jobs');
}
this.runningJobId = '';
},
// ── Trigger helpers ──
triggerType(pattern) {
if (!pattern) return 'unknown';
if (typeof pattern === 'string') return pattern;
var keys = Object.keys(pattern);
if (keys.length === 0) return 'unknown';
var key = keys[0];
var names = {
lifecycle: 'Lifecycle',
agent_spawned: 'Agent Spawned',
agent_terminated: 'Agent Terminated',
system: 'System',
system_keyword: 'System Keyword',
memory_update: 'Memory Update',
memory_key_pattern: 'Memory Key',
all: 'All Events',
content_match: 'Content Match'
};
return names[key] || key.replace(/_/g, ' ');
},
async toggleTrigger(trigger) {
try {
var newState = !trigger.enabled;
await OpenFangAPI.put('/api/triggers/' + trigger.id, { enabled: newState });
trigger.enabled = newState;
OpenFangToast.success('Trigger ' + (newState ? 'enabled' : 'disabled'));
} catch(e) {
OpenFangToast.error('Failed to toggle trigger: ' + (e.message || e));
}
},
deleteTrigger(trigger) {
var self = this;
OpenFangToast.confirm('Delete Trigger', 'Delete this trigger? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/triggers/' + trigger.id);
self.triggers = self.triggers.filter(function(t) { return t.id !== trigger.id; });
OpenFangToast.success('Trigger deleted');
} catch(e) {
OpenFangToast.error('Failed to delete trigger: ' + (e.message || e));
}
});
},
// ── Utility ──
get availableAgents() {
return Alpine.store('app').agents || [];
},
agentName(agentId) {
if (!agentId) return '(any)';
var agents = this.availableAgents;
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === agentId) return agents[i].name;
}
if (agentId.length > 12) return agentId.substring(0, 8) + '...';
return agentId;
},
describeCron(expr) {
if (!expr) return '';
// Handle non-cron schedule descriptions
if (expr.indexOf('every ') === 0) return expr;
if (expr.indexOf('at ') === 0) return 'One-time: ' + expr.substring(3);
var map = {
'* * * * *': 'Every minute',
'*/2 * * * *': 'Every 2 minutes',
'*/5 * * * *': 'Every 5 minutes',
'*/10 * * * *': 'Every 10 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Every hour',
'0 */2 * * *': 'Every 2 hours',
'0 */4 * * *': 'Every 4 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 6 * * *': 'Daily at 6:00 AM',
'0 9 * * *': 'Daily at 9:00 AM',
'0 12 * * *': 'Daily at noon',
'0 18 * * *': 'Daily at 6:00 PM',
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
'0 9 * * 1': 'Mondays at 9:00 AM',
'0 0 * * 0': 'Sundays at midnight',
'0 0 1 * *': '1st of every month',
'0 0 * * 1': 'Mondays at midnight'
};
if (map[expr]) return map[expr];
var parts = expr.split(' ');
if (parts.length !== 5) return expr;
var min = parts[0];
var hour = parts[1];
var dom = parts[2];
var mon = parts[3];
var dow = parts[4];
if (min.indexOf('*/') === 0 && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + min.substring(2) + ' minutes';
}
if (min === '0' && hour.indexOf('*/') === 0 && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + hour.substring(2) + ' hours';
}
var dowNames = { '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', '4': 'Thu', '5': 'Fri', '6': 'Sat', '7': 'Sun',
'1-5': 'Weekdays', '0,6': 'Weekends', '6,0': 'Weekends' };
if (dom === '*' && mon === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
var h = parseInt(hour, 10);
var m = parseInt(min, 10);
var ampm = h >= 12 ? 'PM' : 'AM';
var h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);
var mStr = m < 10 ? '0' + m : '' + m;
var timeStr = h12 + ':' + mStr + ' ' + ampm;
if (dow === '*') return 'Daily at ' + timeStr;
var dowLabel = dowNames[dow] || ('DoW ' + dow);
return dowLabel + ' at ' + timeStr;
}
return expr;
},
applyCronPreset(preset) {
this.newJob.cron = preset.cron;
},
formatTime(ts) {
if (!ts) return '-';
try {
var d = new Date(ts);
if (isNaN(d.getTime())) return '-';
return d.toLocaleString();
} catch(e) { return '-'; }
},
relativeTime(ts) {
if (!ts) return 'never';
try {
var diff = Date.now() - new Date(ts).getTime();
if (isNaN(diff)) return 'never';
if (diff < 0) {
// Future time
var absDiff = Math.abs(diff);
if (absDiff < 60000) return 'in <1m';
if (absDiff < 3600000) return 'in ' + Math.floor(absDiff / 60000) + 'm';
if (absDiff < 86400000) return 'in ' + Math.floor(absDiff / 3600000) + 'h';
return 'in ' + Math.floor(absDiff / 86400000) + 'd';
}
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
return Math.floor(diff / 86400000) + 'd ago';
} catch(e) { return 'never'; }
},
jobCount() {
var enabled = 0;
for (var i = 0; i < this.jobs.length; i++) {
if (this.jobs[i].enabled) enabled++;
}
return enabled;
},
triggerCount() {
var enabled = 0;
for (var i = 0; i < this.triggers.length; i++) {
if (this.triggers[i].enabled) enabled++;
}
return enabled;
}
};
}

View File

@@ -0,0 +1,147 @@
// OpenFang Sessions Page — Session listing + Memory tab
'use strict';
function sessionsPage() {
return {
tab: 'sessions',
// -- Sessions state --
sessions: [],
searchFilter: '',
loading: true,
loadError: '',
// -- Memory state --
memAgentId: '',
kvPairs: [],
showAdd: false,
newKey: '',
newValue: '""',
editingKey: null,
editingValue: '',
memLoading: false,
memLoadError: '',
// -- Sessions methods --
async loadSessions() {
this.loading = true;
this.loadError = '';
try {
var data = await OpenFangAPI.get('/api/sessions');
var sessions = data.sessions || [];
var agents = Alpine.store('app').agents;
var agentMap = {};
agents.forEach(function(a) { agentMap[a.id] = a.name; });
sessions.forEach(function(s) {
s.agent_name = agentMap[s.agent_id] || '';
});
this.sessions = sessions;
} catch(e) {
this.sessions = [];
this.loadError = e.message || 'Could not load sessions.';
}
this.loading = false;
},
async loadData() { return this.loadSessions(); },
get filteredSessions() {
var f = this.searchFilter.toLowerCase();
if (!f) return this.sessions;
return this.sessions.filter(function(s) {
return (s.agent_name || '').toLowerCase().indexOf(f) !== -1 ||
(s.agent_id || '').toLowerCase().indexOf(f) !== -1;
});
},
openInChat(session) {
var agents = Alpine.store('app').agents;
var agent = agents.find(function(a) { return a.id === session.agent_id; });
if (agent) {
Alpine.store('app').pendingAgent = agent;
}
location.hash = 'agents';
},
deleteSession(sessionId) {
var self = this;
OpenFangToast.confirm('Delete Session', 'This will permanently remove the session and its messages.', async function() {
try {
await OpenFangAPI.del('/api/sessions/' + sessionId);
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
OpenFangToast.success('Session deleted');
} catch(e) {
OpenFangToast.error('Failed to delete session: ' + e.message);
}
});
},
// -- Memory methods --
async loadKv() {
if (!this.memAgentId) { this.kvPairs = []; return; }
this.memLoading = true;
this.memLoadError = '';
try {
var data = await OpenFangAPI.get('/api/memory/agents/' + this.memAgentId + '/kv');
this.kvPairs = data.kv_pairs || [];
} catch(e) {
this.kvPairs = [];
this.memLoadError = e.message || 'Could not load memory data.';
}
this.memLoading = false;
},
async addKey() {
if (!this.memAgentId || !this.newKey.trim()) return;
var value;
try { value = JSON.parse(this.newValue); } catch(e) { value = this.newValue; }
try {
await OpenFangAPI.put('/api/memory/agents/' + this.memAgentId + '/kv/' + encodeURIComponent(this.newKey), { value: value });
this.showAdd = false;
OpenFangToast.success('Key "' + this.newKey + '" saved');
this.newKey = '';
this.newValue = '""';
await this.loadKv();
} catch(e) {
OpenFangToast.error('Failed to save key: ' + e.message);
}
},
deleteKey(key) {
var self = this;
OpenFangToast.confirm('Delete Key', 'Delete key "' + key + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
OpenFangToast.success('Key "' + key + '" deleted');
await self.loadKv();
} catch(e) {
OpenFangToast.error('Failed to delete key: ' + e.message);
}
});
},
startEdit(kv) {
this.editingKey = kv.key;
this.editingValue = typeof kv.value === 'object' ? JSON.stringify(kv.value, null, 2) : String(kv.value);
},
cancelEdit() {
this.editingKey = null;
this.editingValue = '';
},
async saveEdit() {
if (!this.editingKey || !this.memAgentId) return;
var value;
try { value = JSON.parse(this.editingValue); } catch(e) { value = this.editingValue; }
try {
await OpenFangAPI.put('/api/memory/agents/' + this.memAgentId + '/kv/' + encodeURIComponent(this.editingKey), { value: value });
OpenFangToast.success('Key "' + this.editingKey + '" updated');
this.editingKey = null;
this.editingValue = '';
await this.loadKv();
} catch(e) {
OpenFangToast.error('Failed to save: ' + e.message);
}
}
};
}

View File

@@ -0,0 +1,669 @@
// OpenFang Settings Page — Provider Hub, Model Catalog, Config, Tools + Security, Network, Migration tabs
'use strict';
function settingsPage() {
return {
tab: 'providers',
sysInfo: {},
usageData: [],
tools: [],
config: {},
providers: [],
models: [],
toolSearch: '',
modelSearch: '',
modelProviderFilter: '',
modelTierFilter: '',
showCustomModelForm: false,
customModelId: '',
customModelProvider: 'openrouter',
customModelContext: 128000,
customModelMaxOutput: 8192,
customModelStatus: '',
providerKeyInputs: {},
providerUrlInputs: {},
providerUrlSaving: {},
providerTesting: {},
providerTestResults: {},
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
loading: true,
loadError: '',
// -- Dynamic config state --
configSchema: null,
configValues: {},
configDirty: {},
configSaving: {},
// -- Security state --
securityData: null,
secLoading: false,
verifyingChain: false,
chainResult: null,
coreFeatures: [
{
name: 'Path Traversal Prevention', key: 'path_traversal',
description: 'Blocks directory escape attacks (../) in all file operations. Two-phase validation: syntactic rejection of path components, then canonicalization to normalize symlinks.',
threat: 'Directory escape, privilege escalation via symlinks',
impl: 'host_functions.rs — safe_resolve_path() + safe_resolve_parent()'
},
{
name: 'SSRF Protection', key: 'ssrf_protection',
description: 'Blocks outbound requests to private IPs, localhost, and cloud metadata endpoints (AWS/GCP/Azure). Validates DNS resolution results to defeat rebinding attacks.',
threat: 'Internal network reconnaissance, cloud credential theft',
impl: 'host_functions.rs — is_ssrf_target() + is_private_ip()'
},
{
name: 'Capability-Based Access Control', key: 'capability_system',
description: 'Deny-by-default permission system. Every agent operation (file I/O, network, shell, memory, spawn) requires an explicit capability grant in the manifest.',
threat: 'Unauthorized resource access, sandbox escape',
impl: 'host_functions.rs — check_capability() on every host function'
},
{
name: 'Privilege Escalation Prevention', key: 'privilege_escalation_prevention',
description: 'When a parent agent spawns a child, the kernel enforces child capabilities are a subset of parent capabilities. No agent can grant rights it does not have.',
threat: 'Capability escalation through agent spawning chains',
impl: 'kernel_handle.rs — spawn_agent_checked()'
},
{
name: 'Subprocess Environment Isolation', key: 'subprocess_isolation',
description: 'Child processes (shell tools) inherit only a safe allow-list of environment variables. API keys, database passwords, and secrets are never leaked to subprocesses.',
threat: 'Secret exfiltration via child process environment',
impl: 'subprocess_sandbox.rs — env_clear() + SAFE_ENV_VARS'
},
{
name: 'Security Headers', key: 'security_headers',
description: 'Every HTTP response includes CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, and X-XSS-Protection headers.',
threat: 'XSS, clickjacking, MIME sniffing, content injection',
impl: 'middleware.rs — security_headers()'
},
{
name: 'Wire Protocol Authentication', key: 'wire_hmac_auth',
description: 'Agent-to-agent OFP connections use HMAC-SHA256 mutual authentication with nonce-based handshake and constant-time signature comparison (subtle crate).',
threat: 'Man-in-the-middle attacks on mesh network',
impl: 'peer.rs — hmac_sign() + hmac_verify()'
},
{
name: 'Request ID Tracking', key: 'request_id_tracking',
description: 'Every API request receives a unique UUID (x-request-id header) and is logged with method, path, status code, and latency for full traceability.',
threat: 'Untraceable actions, forensic blind spots',
impl: 'middleware.rs — request_logging()'
}
],
configurableFeatures: [
{
name: 'API Rate Limiting', key: 'rate_limiter',
description: 'GCRA (Generic Cell Rate Algorithm) with cost-aware tokens. Different endpoints cost different amounts — spawning an agent costs 50 tokens, health check costs 1.',
configHint: 'Hard-coded: 500 tokens/minute per IP. Edit rate_limiter.rs to tune.',
valueKey: 'rate_limiter'
},
{
name: 'WebSocket Connection Limits', key: 'websocket_limits',
description: 'Per-IP connection cap prevents connection exhaustion. Idle timeout closes abandoned connections. Message rate limiting prevents flooding.',
configHint: 'Hard-coded: 5 connections/IP, 30min idle timeout, 64KB max message. Edit ws.rs to tune.',
valueKey: 'websocket_limits'
},
{
name: 'WASM Dual Metering', key: 'wasm_sandbox',
description: 'WASM modules run with two independent resource limits: fuel metering (CPU instruction count) and epoch interruption (wall-clock timeout with watchdog thread).',
configHint: 'Default: 1M fuel units, 30s timeout. Configurable per-agent via SandboxConfig.',
valueKey: 'wasm_sandbox'
},
{
name: 'Bearer Token Authentication', key: 'auth',
description: 'All non-health endpoints require Authorization: Bearer header. When no API key is configured, all requests are restricted to localhost only.',
configHint: 'Set api_key in ~/.openfang/config.toml for remote access. Empty = localhost only.',
valueKey: 'auth'
}
],
monitoringFeatures: [
{
name: 'Merkle Audit Trail', key: 'audit_trail',
description: 'Every security-critical action is appended to an immutable, tamper-evident log. Each entry is cryptographically linked to the previous via SHA-256 hash chain.',
configHint: 'Always active. Verify chain integrity from the Audit Log page.',
valueKey: 'audit_trail'
},
{
name: 'Information Flow Taint Tracking', key: 'taint_tracking',
description: 'Labels data by provenance (ExternalNetwork, UserInput, PII, Secret, UntrustedAgent) and blocks unsafe flows: external data cannot reach shell_exec, secrets cannot reach network.',
configHint: 'Always active. Prevents data flow attacks automatically.',
valueKey: 'taint_tracking'
},
{
name: 'Ed25519 Manifest Signing', key: 'manifest_signing',
description: 'Agent manifests can be cryptographically signed with Ed25519. Verify manifest integrity before loading to prevent supply chain tampering.',
configHint: 'Available for use. Sign manifests with ed25519-dalek for verification.',
valueKey: 'manifest_signing'
}
],
// -- Peers state --
peers: [],
peersLoading: false,
peersLoadError: '',
_peerPollTimer: null,
// -- Migration state --
migStep: 'intro',
detecting: false,
scanning: false,
migrating: false,
sourcePath: '',
targetPath: '',
scanResult: null,
migResult: null,
// -- Settings load --
async loadSettings() {
this.loading = true;
this.loadError = '';
try {
await Promise.all([
this.loadSysInfo(),
this.loadUsage(),
this.loadTools(),
this.loadConfig(),
this.loadProviders(),
this.loadModels()
]);
} catch(e) {
this.loadError = e.message || 'Could not load settings.';
}
this.loading = false;
},
async loadData() { return this.loadSettings(); },
async loadSysInfo() {
try {
var ver = await OpenFangAPI.get('/api/version');
var status = await OpenFangAPI.get('/api/status');
this.sysInfo = {
version: ver.version || '-',
platform: ver.platform || '-',
arch: ver.arch || '-',
uptime_seconds: status.uptime_seconds || 0,
agent_count: status.agent_count || 0,
default_provider: status.default_provider || '-',
default_model: status.default_model || '-'
};
} catch(e) { throw e; }
},
async loadUsage() {
try {
var data = await OpenFangAPI.get('/api/usage');
this.usageData = data.agents || [];
} catch(e) { this.usageData = []; }
},
async loadTools() {
try {
var data = await OpenFangAPI.get('/api/tools');
this.tools = data.tools || [];
} catch(e) { this.tools = []; }
},
async loadConfig() {
try {
this.config = await OpenFangAPI.get('/api/config');
} catch(e) { this.config = {}; }
},
async loadProviders() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
for (var i = 0; i < this.providers.length; i++) {
var p = this.providers[i];
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url;
}
}
} catch(e) { this.providers = []; }
},
async loadModels() {
try {
var data = await OpenFangAPI.get('/api/models');
this.models = data.models || [];
} catch(e) { this.models = []; }
},
async addCustomModel() {
var id = this.customModelId.trim();
if (!id) return;
this.customModelStatus = 'Adding...';
try {
await OpenFangAPI.post('/api/models/custom', {
id: id,
provider: this.customModelProvider || 'openrouter',
context_window: this.customModelContext || 128000,
max_output_tokens: this.customModelMaxOutput || 8192,
});
this.customModelStatus = 'Added!';
this.customModelId = '';
this.showCustomModelForm = false;
await this.loadModels();
} catch(e) {
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
}
},
async loadConfigSchema() {
try {
var results = await Promise.all([
OpenFangAPI.get('/api/config/schema').catch(function() { return {}; }),
OpenFangAPI.get('/api/config')
]);
this.configSchema = results[0].sections || null;
this.configValues = results[1] || {};
} catch(e) { /* silent */ }
},
isConfigDirty(section, field) {
return this.configDirty[section + '.' + field] === true;
},
markConfigDirty(section, field) {
this.configDirty[section + '.' + field] = true;
},
async saveConfigField(section, field, value) {
var key = section + '.' + field;
this.configSaving[key] = true;
try {
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
this.configDirty[key] = false;
OpenFangToast.success('Saved ' + key);
} catch(e) {
OpenFangToast.error('Failed to save: ' + e.message);
}
this.configSaving[key] = false;
},
get filteredTools() {
var q = this.toolSearch.toLowerCase().trim();
if (!q) return this.tools;
return this.tools.filter(function(t) {
return t.name.toLowerCase().indexOf(q) !== -1 ||
(t.description || '').toLowerCase().indexOf(q) !== -1;
});
},
get filteredModels() {
var self = this;
return this.models.filter(function(m) {
if (self.modelProviderFilter && m.provider !== self.modelProviderFilter) return false;
if (self.modelTierFilter && m.tier !== self.modelTierFilter) return false;
if (self.modelSearch) {
var q = self.modelSearch.toLowerCase();
if (m.id.toLowerCase().indexOf(q) === -1 &&
(m.display_name || '').toLowerCase().indexOf(q) === -1) return false;
}
return true;
});
},
get uniqueProviderNames() {
var seen = {};
this.models.forEach(function(m) { seen[m.provider] = true; });
return Object.keys(seen).sort();
},
get uniqueTiers() {
var seen = {};
this.models.forEach(function(m) { if (m.tier) seen[m.tier] = true; });
return Object.keys(seen).sort();
},
providerAuthClass(p) {
if (p.auth_status === 'configured') return 'auth-configured';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'auth-not-set';
return 'auth-no-key';
},
providerAuthText(p) {
if (p.auth_status === 'configured') return 'Configured';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
return 'No Key Needed';
},
providerCardClass(p) {
if (p.auth_status === 'configured') return 'configured';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'not-configured';
return 'no-key';
},
tierBadgeClass(tier) {
if (!tier) return '';
var t = tier.toLowerCase();
if (t === 'frontier') return 'tier-frontier';
if (t === 'smart') return 'tier-smart';
if (t === 'balanced') return 'tier-balanced';
if (t === 'fast') return 'tier-fast';
return '';
},
formatCost(cost) {
if (!cost && cost !== 0) return '-';
return '$' + cost.toFixed(4);
},
formatContext(ctx) {
if (!ctx) return '-';
if (ctx >= 1000000) return (ctx / 1000000).toFixed(1) + 'M';
if (ctx >= 1000) return Math.round(ctx / 1000) + 'K';
return String(ctx);
},
formatUptime(secs) {
if (!secs) return '-';
var h = Math.floor(secs / 3600);
var m = Math.floor((secs % 3600) / 60);
var s = secs % 60;
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
},
async saveProviderKey(provider) {
var key = this.providerKeyInputs[provider.id];
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
try {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
OpenFangToast.success('API key saved for ' + provider.display_name);
this.providerKeyInputs[provider.id] = '';
await this.loadProviders();
await this.loadModels();
} catch(e) {
OpenFangToast.error('Failed to save key: ' + e.message);
}
},
async removeProviderKey(provider) {
try {
await OpenFangAPI.del('/api/providers/' + encodeURIComponent(provider.id) + '/key');
OpenFangToast.success('API key removed for ' + provider.display_name);
await this.loadProviders();
await this.loadModels();
} catch(e) {
OpenFangToast.error('Failed to remove key: ' + e.message);
}
},
async startCopilotOAuth() {
this.copilotOAuth.polling = true;
this.copilotOAuth.userCode = '';
try {
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
this.copilotOAuth.userCode = resp.user_code;
this.copilotOAuth.verificationUri = resp.verification_uri;
this.copilotOAuth.pollId = resp.poll_id;
this.copilotOAuth.interval = resp.interval || 5;
window.open(resp.verification_uri, '_blank');
this.pollCopilotOAuth();
} catch(e) {
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
this.copilotOAuth.polling = false;
}
},
pollCopilotOAuth() {
var self = this;
setTimeout(async function() {
if (!self.copilotOAuth.pollId) return;
try {
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
if (resp.status === 'complete') {
OpenFangToast.success('GitHub Copilot authenticated successfully!');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
await self.loadProviders();
await self.loadModels();
} else if (resp.status === 'pending') {
if (resp.interval) self.copilotOAuth.interval = resp.interval;
self.pollCopilotOAuth();
} else if (resp.status === 'expired') {
OpenFangToast.error('Device code expired. Please try again.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else if (resp.status === 'denied') {
OpenFangToast.error('Access denied by user.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else {
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
} catch(e) {
OpenFangToast.error('Poll error: ' + e.message);
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
}, self.copilotOAuth.interval * 1000);
},
async testProvider(provider) {
this.providerTesting[provider.id] = true;
this.providerTestResults[provider.id] = null;
try {
var result = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/test', {});
this.providerTestResults[provider.id] = result;
if (result.status === 'ok') {
OpenFangToast.success(provider.display_name + ' connected (' + (result.latency_ms || '?') + 'ms)');
} else {
OpenFangToast.error(provider.display_name + ': ' + (result.error || 'Connection failed'));
}
} catch(e) {
this.providerTestResults[provider.id] = { status: 'error', error: e.message };
OpenFangToast.error('Test failed: ' + e.message);
}
this.providerTesting[provider.id] = false;
},
async saveProviderUrl(provider) {
var url = this.providerUrlInputs[provider.id];
if (!url || !url.trim()) { OpenFangToast.error('Please enter a base URL'); return; }
url = url.trim();
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
OpenFangToast.error('URL must start with http:// or https://'); return;
}
this.providerUrlSaving[provider.id] = true;
try {
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(provider.id) + '/url', { base_url: url });
if (result.reachable) {
OpenFangToast.success(provider.display_name + ' URL saved &mdash; reachable (' + (result.latency_ms || '?') + 'ms)');
} else {
OpenFangToast.warning(provider.display_name + ' URL saved but not reachable');
}
await this.loadProviders();
} catch(e) {
OpenFangToast.error('Failed to save URL: ' + e.message);
}
this.providerUrlSaving[provider.id] = false;
},
// -- Security methods --
async loadSecurity() {
this.secLoading = true;
try {
this.securityData = await OpenFangAPI.get('/api/security');
} catch(e) {
this.securityData = null;
}
this.secLoading = false;
},
isActive(key) {
if (!this.securityData) return true;
var core = this.securityData.core_protections || {};
if (core[key] !== undefined) return core[key];
return true;
},
getConfigValue(key) {
if (!this.securityData) return null;
var cfg = this.securityData.configurable || {};
return cfg[key] || null;
},
getMonitoringValue(key) {
if (!this.securityData) return null;
var mon = this.securityData.monitoring || {};
return mon[key] || null;
},
formatConfigValue(feature) {
var val = this.getConfigValue(feature.valueKey);
if (!val) return feature.configHint;
switch (feature.valueKey) {
case 'rate_limiter':
return 'Algorithm: ' + (val.algorithm || 'GCRA') + ' | ' + (val.tokens_per_minute || 500) + ' tokens/min per IP';
case 'websocket_limits':
return 'Max ' + (val.max_per_ip || 5) + ' conn/IP | ' + Math.round((val.idle_timeout_secs || 1800) / 60) + 'min idle timeout | ' + Math.round((val.max_message_size || 65536) / 1024) + 'KB max msg';
case 'wasm_sandbox':
return 'Fuel: ' + (val.fuel_metering ? 'ON' : 'OFF') + ' | Epoch: ' + (val.epoch_interruption ? 'ON' : 'OFF') + ' | Timeout: ' + (val.default_timeout_secs || 30) + 's';
case 'auth':
return 'Mode: ' + (val.mode || 'unknown') + (val.api_key_set ? ' (key configured)' : ' (no key set)');
default:
return feature.configHint;
}
},
formatMonitoringValue(feature) {
var val = this.getMonitoringValue(feature.valueKey);
if (!val) return feature.configHint;
switch (feature.valueKey) {
case 'audit_trail':
return (val.enabled ? 'Active' : 'Disabled') + ' | ' + (val.algorithm || 'SHA-256') + ' | ' + (val.entry_count || 0) + ' entries logged';
case 'taint_tracking':
var labels = val.tracked_labels || [];
return (val.enabled ? 'Active' : 'Disabled') + ' | Tracking: ' + labels.join(', ');
case 'manifest_signing':
return 'Algorithm: ' + (val.algorithm || 'Ed25519') + ' | ' + (val.available ? 'Available' : 'Not available');
default:
return feature.configHint;
}
},
async verifyAuditChain() {
this.verifyingChain = true;
this.chainResult = null;
try {
var res = await OpenFangAPI.get('/api/audit/verify');
this.chainResult = res;
} catch(e) {
this.chainResult = { valid: false, error: e.message };
}
this.verifyingChain = false;
},
// -- Peers methods --
async loadPeers() {
this.peersLoading = true;
this.peersLoadError = '';
try {
var data = await OpenFangAPI.get('/api/peers');
this.peers = (data.peers || []).map(function(p) {
return {
node_id: p.node_id,
node_name: p.node_name,
address: p.address,
state: p.state,
agent_count: (p.agents || []).length,
protocol_version: p.protocol_version || 1
};
});
} catch(e) {
this.peers = [];
this.peersLoadError = e.message || 'Could not load peers.';
}
this.peersLoading = false;
},
startPeerPolling() {
var self = this;
this.stopPeerPolling();
this._peerPollTimer = setInterval(async function() {
if (self.tab !== 'network') { self.stopPeerPolling(); return; }
try {
var data = await OpenFangAPI.get('/api/peers');
self.peers = (data.peers || []).map(function(p) {
return {
node_id: p.node_id,
node_name: p.node_name,
address: p.address,
state: p.state,
agent_count: (p.agents || []).length,
protocol_version: p.protocol_version || 1
};
});
} catch(e) { /* silent */ }
}, 15000);
},
stopPeerPolling() {
if (this._peerPollTimer) { clearInterval(this._peerPollTimer); this._peerPollTimer = null; }
},
// -- Migration methods --
async autoDetect() {
this.detecting = true;
try {
var data = await OpenFangAPI.get('/api/migrate/detect');
if (data.detected && data.scan) {
this.sourcePath = data.path;
this.scanResult = data.scan;
this.migStep = 'preview';
} else {
this.migStep = 'not_found';
}
} catch(e) {
this.migStep = 'not_found';
}
this.detecting = false;
},
async scanPath() {
if (!this.sourcePath) return;
this.scanning = true;
try {
var data = await OpenFangAPI.post('/api/migrate/scan', { path: this.sourcePath });
if (data.error) {
OpenFangToast.error('Scan error: ' + data.error);
this.scanning = false;
return;
}
this.scanResult = data;
this.migStep = 'preview';
} catch(e) {
OpenFangToast.error('Scan failed: ' + e.message);
}
this.scanning = false;
},
async runMigration(dryRun) {
this.migrating = true;
try {
var target = this.targetPath;
if (!target) target = '';
var data = await OpenFangAPI.post('/api/migrate', {
source: 'openclaw',
source_dir: this.sourcePath || (this.scanResult ? this.scanResult.path : ''),
target_dir: target,
dry_run: dryRun
});
this.migResult = data;
this.migStep = 'result';
} catch(e) {
this.migResult = { status: 'failed', error: e.message };
this.migStep = 'result';
}
this.migrating = false;
},
destroy() {
this.stopPeerPolling();
}
};
}

View File

@@ -0,0 +1,299 @@
// OpenFang Skills Page — OpenClaw/ClawHub ecosystem + local skills + MCP servers
'use strict';
function skillsPage() {
return {
tab: 'installed',
skills: [],
loading: true,
loadError: '',
// ClawHub state
clawhubSearch: '',
clawhubResults: [],
clawhubBrowseResults: [],
clawhubLoading: false,
clawhubError: '',
clawhubSort: 'trending',
clawhubNextCursor: null,
installingSlug: null,
installResult: null,
_searchTimer: null,
// Skill detail modal
skillDetail: null,
detailLoading: false,
// MCP servers
mcpServers: [],
mcpLoading: false,
// Category definitions from the OpenClaw ecosystem
categories: [
{ id: 'coding', name: 'Coding & IDEs' },
{ id: 'git', name: 'Git & GitHub' },
{ id: 'web', name: 'Web & Frontend' },
{ id: 'devops', name: 'DevOps & Cloud' },
{ id: 'browser', name: 'Browser & Automation' },
{ id: 'search', name: 'Search & Research' },
{ id: 'ai', name: 'AI & LLMs' },
{ id: 'data', name: 'Data & Analytics' },
{ id: 'productivity', name: 'Productivity' },
{ id: 'communication', name: 'Communication' },
{ id: 'media', name: 'Media & Streaming' },
{ id: 'notes', name: 'Notes & PKM' },
{ id: 'security', name: 'Security' },
{ id: 'cli', name: 'CLI Utilities' },
{ id: 'marketing', name: 'Marketing & Sales' },
{ id: 'finance', name: 'Finance' },
{ id: 'smart-home', name: 'Smart Home & IoT' },
{ id: 'docs', name: 'PDF & Documents' },
],
runtimeBadge: function(rt) {
var r = (rt || '').toLowerCase();
if (r === 'python' || r === 'py') return { text: 'PY', cls: 'runtime-badge-py' };
if (r === 'node' || r === 'nodejs' || r === 'js' || r === 'javascript') return { text: 'JS', cls: 'runtime-badge-js' };
if (r === 'wasm' || r === 'webassembly') return { text: 'WASM', cls: 'runtime-badge-wasm' };
if (r === 'prompt_only' || r === 'prompt' || r === 'promptonly') return { text: 'PROMPT', cls: 'runtime-badge-prompt' };
return { text: r.toUpperCase().substring(0, 4), cls: 'runtime-badge-prompt' };
},
sourceBadge: function(source) {
if (!source) return { text: 'Local', cls: 'badge-dim' };
switch (source.type) {
case 'clawhub': return { text: 'ClawHub', cls: 'badge-info' };
case 'openclaw': return { text: 'OpenClaw', cls: 'badge-info' };
case 'bundled': return { text: 'Built-in', cls: 'badge-success' };
default: return { text: 'Local', cls: 'badge-dim' };
}
},
formatDownloads: function(n) {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
},
async loadSkills() {
this.loading = true;
this.loadError = '';
try {
var data = await OpenFangAPI.get('/api/skills');
this.skills = (data.skills || []).map(function(s) {
return {
name: s.name,
description: s.description || '',
version: s.version || '',
author: s.author || '',
runtime: s.runtime || 'unknown',
tools_count: s.tools_count || 0,
tags: s.tags || [],
enabled: s.enabled !== false,
source: s.source || { type: 'local' },
has_prompt_context: !!s.has_prompt_context
};
});
} catch(e) {
this.skills = [];
this.loadError = e.message || 'Could not load skills.';
}
this.loading = false;
},
async loadData() {
await this.loadSkills();
},
// Debounced search — fires 350ms after user stops typing
onSearchInput() {
if (this._searchTimer) clearTimeout(this._searchTimer);
var q = this.clawhubSearch.trim();
if (!q) {
this.clawhubResults = [];
this.clawhubError = '';
return;
}
var self = this;
this._searchTimer = setTimeout(function() { self.searchClawHub(); }, 350);
},
// ClawHub search
async searchClawHub() {
if (!this.clawhubSearch.trim()) {
this.clawhubResults = [];
return;
}
this.clawhubLoading = true;
this.clawhubError = '';
try {
var data = await OpenFangAPI.get('/api/clawhub/search?q=' + encodeURIComponent(this.clawhubSearch.trim()) + '&limit=20');
this.clawhubResults = data.items || [];
if (data.error) this.clawhubError = data.error;
} catch(e) {
this.clawhubResults = [];
this.clawhubError = e.message || 'Search failed';
}
this.clawhubLoading = false;
},
// Clear search and go back to browse
clearSearch() {
this.clawhubSearch = '';
this.clawhubResults = [];
this.clawhubError = '';
if (this._searchTimer) clearTimeout(this._searchTimer);
},
// ClawHub browse by sort
async browseClawHub(sort) {
this.clawhubSort = sort || 'trending';
this.clawhubLoading = true;
this.clawhubError = '';
this.clawhubNextCursor = null;
try {
var data = await OpenFangAPI.get('/api/clawhub/browse?sort=' + this.clawhubSort + '&limit=20');
this.clawhubBrowseResults = data.items || [];
this.clawhubNextCursor = data.next_cursor || null;
if (data.error) this.clawhubError = data.error;
} catch(e) {
this.clawhubBrowseResults = [];
this.clawhubError = e.message || 'Browse failed';
}
this.clawhubLoading = false;
},
// ClawHub load more results
async loadMoreClawHub() {
if (!this.clawhubNextCursor || this.clawhubLoading) return;
this.clawhubLoading = true;
try {
var data = await OpenFangAPI.get('/api/clawhub/browse?sort=' + this.clawhubSort + '&limit=20&cursor=' + encodeURIComponent(this.clawhubNextCursor));
this.clawhubBrowseResults = this.clawhubBrowseResults.concat(data.items || []);
this.clawhubNextCursor = data.next_cursor || null;
} catch(e) {
// silently fail on load more
}
this.clawhubLoading = false;
},
// Show skill detail
async showSkillDetail(slug) {
this.detailLoading = true;
this.skillDetail = null;
this.installResult = null;
try {
var data = await OpenFangAPI.get('/api/clawhub/skill/' + encodeURIComponent(slug));
this.skillDetail = data;
} catch(e) {
OpenFangToast.error('Failed to load skill details');
}
this.detailLoading = false;
},
closeDetail() {
this.skillDetail = null;
this.installResult = null;
},
// Install from ClawHub
async installFromClawHub(slug) {
this.installingSlug = slug;
this.installResult = null;
try {
var data = await OpenFangAPI.post('/api/clawhub/install', { slug: slug });
this.installResult = data;
if (data.warnings && data.warnings.length > 0) {
OpenFangToast.success('Skill "' + data.name + '" installed with ' + data.warnings.length + ' warning(s)');
} else {
OpenFangToast.success('Skill "' + data.name + '" installed successfully');
}
// Update installed state in detail modal if open
if (this.skillDetail && this.skillDetail.slug === slug) {
this.skillDetail.installed = true;
}
await this.loadSkills();
} catch(e) {
var msg = e.message || 'Install failed';
if (msg.includes('already_installed')) {
OpenFangToast.error('Skill is already installed');
} else if (msg.includes('SecurityBlocked')) {
OpenFangToast.error('Skill blocked by security scan');
} else {
OpenFangToast.error('Install failed: ' + msg);
}
}
this.installingSlug = null;
},
// Uninstall
uninstallSkill: function(name) {
var self = this;
OpenFangToast.confirm('Uninstall Skill', 'Uninstall skill "' + name + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
OpenFangToast.success('Skill "' + name + '" uninstalled');
await self.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
}
});
},
// Create prompt-only skill
async createDemoSkill(skill) {
try {
await OpenFangAPI.post('/api/skills/create', {
name: skill.name,
description: skill.description,
runtime: 'prompt_only',
prompt_context: skill.prompt_context || skill.description
});
OpenFangToast.success('Skill "' + skill.name + '" created');
this.tab = 'installed';
await this.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to create skill: ' + e.message);
}
},
// Load MCP servers
async loadMcpServers() {
this.mcpLoading = true;
try {
var data = await OpenFangAPI.get('/api/mcp/servers');
this.mcpServers = data;
} catch(e) {
this.mcpServers = { configured: [], connected: [], total_configured: 0, total_connected: 0 };
}
this.mcpLoading = false;
},
// Category search on ClawHub
searchCategory: function(cat) {
this.clawhubSearch = cat.name;
this.searchClawHub();
},
// Quick start skills (prompt-only, zero deps)
quickStartSkills: [
{ name: 'code-review-guide', description: 'Adds code review best practices and checklist to agent context.', prompt_context: 'You are an expert code reviewer. When reviewing code:\n1. Check for bugs and logic errors\n2. Evaluate code style and readability\n3. Look for security vulnerabilities\n4. Suggest performance improvements\n5. Verify error handling\n6. Check test coverage' },
{ name: 'writing-style', description: 'Configurable writing style guide for content generation.', prompt_context: 'Follow these writing guidelines:\n- Use clear, concise language\n- Prefer active voice over passive voice\n- Keep paragraphs short (3-4 sentences)\n- Use bullet points for lists\n- Maintain consistent tone throughout' },
{ name: 'api-design', description: 'REST API design patterns and conventions.', prompt_context: 'When designing REST APIs:\n- Use nouns for resources, not verbs\n- Use HTTP methods correctly (GET, POST, PUT, DELETE)\n- Return appropriate status codes\n- Use pagination for list endpoints\n- Version your API\n- Document all endpoints' },
{ name: 'security-checklist', description: 'OWASP-aligned security review checklist.', prompt_context: 'Security review checklist (OWASP aligned):\n- Input validation on all user inputs\n- Output encoding to prevent XSS\n- Parameterized queries to prevent SQL injection\n- Authentication and session management\n- Access control checks\n- CSRF protection\n- Security headers\n- Error handling without information leakage' },
],
// Check if skill is installed by slug
isSkillInstalled: function(slug) {
return this.skills.some(function(s) {
return s.source && s.source.type === 'clawhub' && s.source.slug === slug;
});
},
// Check if skill is installed by name
isSkillInstalledByName: function(name) {
return this.skills.some(function(s) { return s.name === name; });
},
};
}

View File

@@ -0,0 +1,251 @@
// OpenFang Analytics Page — Full usage analytics with per-model and per-agent breakdowns
// Includes Cost Dashboard with donut chart, bar chart, projections, and provider breakdown.
'use strict';
function analyticsPage() {
return {
tab: 'summary',
summary: {},
byModel: [],
byAgent: [],
loading: true,
loadError: '',
// Cost tab state
dailyCosts: [],
todayCost: 0,
firstEventDate: null,
// Chart colors for providers (stable palette)
_chartColors: [
'#FF5C00', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6',
'#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316',
'#6366F1', '#14B8A6', '#E11D48', '#A855F7', '#22D3EE'
],
async loadUsage() {
this.loading = true;
this.loadError = '';
try {
await Promise.all([
this.loadSummary(),
this.loadByModel(),
this.loadByAgent(),
this.loadDailyCosts()
]);
} catch(e) {
this.loadError = e.message || 'Could not load usage data.';
}
this.loading = false;
},
async loadData() { return this.loadUsage(); },
async loadSummary() {
try {
this.summary = await OpenFangAPI.get('/api/usage/summary');
} catch(e) {
this.summary = { total_input_tokens: 0, total_output_tokens: 0, total_cost_usd: 0, call_count: 0, total_tool_calls: 0 };
throw e;
}
},
async loadByModel() {
try {
var data = await OpenFangAPI.get('/api/usage/by-model');
this.byModel = data.models || [];
} catch(e) { this.byModel = []; }
},
async loadByAgent() {
try {
var data = await OpenFangAPI.get('/api/usage');
this.byAgent = data.agents || [];
} catch(e) { this.byAgent = []; }
},
async loadDailyCosts() {
try {
var data = await OpenFangAPI.get('/api/usage/daily');
this.dailyCosts = data.days || [];
this.todayCost = data.today_cost_usd || 0;
this.firstEventDate = data.first_event_date || null;
} catch(e) {
this.dailyCosts = [];
this.todayCost = 0;
this.firstEventDate = null;
}
},
formatTokens(n) {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(2) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
},
formatCost(c) {
if (!c) return '$0.00';
if (c < 0.01) return '$' + c.toFixed(4);
return '$' + c.toFixed(2);
},
maxTokens() {
var max = 0;
this.byModel.forEach(function(m) {
var t = (m.total_input_tokens || 0) + (m.total_output_tokens || 0);
if (t > max) max = t;
});
return max || 1;
},
barWidth(m) {
var t = (m.total_input_tokens || 0) + (m.total_output_tokens || 0);
return Math.max(2, Math.round((t / this.maxTokens()) * 100)) + '%';
},
// ── Cost tab helpers ──
avgCostPerMessage() {
var count = this.summary.call_count || 0;
if (count === 0) return 0;
return (this.summary.total_cost_usd || 0) / count;
},
projectedMonthlyCost() {
if (!this.firstEventDate || !this.summary.total_cost_usd) return 0;
var first = new Date(this.firstEventDate);
var now = new Date();
var diffMs = now.getTime() - first.getTime();
var diffDays = diffMs / (1000 * 60 * 60 * 24);
if (diffDays < 1) diffDays = 1;
return (this.summary.total_cost_usd / diffDays) * 30;
},
// ── Provider aggregation from byModel data ──
costByProvider() {
var providerMap = {};
var self = this;
this.byModel.forEach(function(m) {
var provider = self._extractProvider(m.model);
if (!providerMap[provider]) {
providerMap[provider] = { provider: provider, cost: 0, tokens: 0, calls: 0 };
}
providerMap[provider].cost += (m.total_cost_usd || 0);
providerMap[provider].tokens += (m.total_input_tokens || 0) + (m.total_output_tokens || 0);
providerMap[provider].calls += (m.call_count || 0);
});
var result = [];
for (var key in providerMap) {
if (providerMap.hasOwnProperty(key)) {
result.push(providerMap[key]);
}
}
result.sort(function(a, b) { return b.cost - a.cost; });
return result;
},
_extractProvider(modelName) {
if (!modelName) return 'Unknown';
var lower = modelName.toLowerCase();
if (lower.indexOf('claude') !== -1 || lower.indexOf('haiku') !== -1 || lower.indexOf('sonnet') !== -1 || lower.indexOf('opus') !== -1) return 'Anthropic';
if (lower.indexOf('gemini') !== -1 || lower.indexOf('gemma') !== -1) return 'Google';
if (lower.indexOf('gpt') !== -1 || lower.indexOf('o1') !== -1 || lower.indexOf('o3') !== -1 || lower.indexOf('o4') !== -1) return 'OpenAI';
if (lower.indexOf('llama') !== -1 || lower.indexOf('mixtral') !== -1 || lower.indexOf('groq') !== -1) return 'Groq';
if (lower.indexOf('deepseek') !== -1) return 'DeepSeek';
if (lower.indexOf('mistral') !== -1) return 'Mistral';
if (lower.indexOf('command') !== -1 || lower.indexOf('cohere') !== -1) return 'Cohere';
if (lower.indexOf('grok') !== -1) return 'xAI';
if (lower.indexOf('jamba') !== -1) return 'AI21';
if (lower.indexOf('qwen') !== -1) return 'Together';
return 'Other';
},
// ── Donut chart (stroke-dasharray on circles) ──
donutSegments() {
var providers = this.costByProvider();
var total = 0;
var colors = this._chartColors;
providers.forEach(function(p) { total += p.cost; });
if (total === 0) return [];
var segments = [];
var offset = 0;
var circumference = 2 * Math.PI * 60; // r=60
for (var i = 0; i < providers.length; i++) {
var pct = providers[i].cost / total;
var dashLen = pct * circumference;
segments.push({
provider: providers[i].provider,
cost: providers[i].cost,
percent: Math.round(pct * 100),
color: colors[i % colors.length],
dasharray: dashLen + ' ' + (circumference - dashLen),
dashoffset: -offset,
circumference: circumference
});
offset += dashLen;
}
return segments;
},
// ── Bar chart (last 7 days) ──
barChartData() {
var days = this.dailyCosts;
if (!days || days.length === 0) return [];
var maxCost = 0;
days.forEach(function(d) { if (d.cost_usd > maxCost) maxCost = d.cost_usd; });
if (maxCost === 0) maxCost = 1;
var dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var result = [];
for (var i = 0; i < days.length; i++) {
var d = new Date(days[i].date + 'T12:00:00');
var dayName = dayNames[d.getDay()] || '?';
var heightPct = Math.max(2, Math.round((days[i].cost_usd / maxCost) * 120));
result.push({
date: days[i].date,
dayName: dayName,
cost: days[i].cost_usd,
tokens: days[i].tokens,
calls: days[i].calls,
barHeight: heightPct
});
}
return result;
},
// ── Cost by model table (sorted by cost descending) ──
costByModelSorted() {
var models = this.byModel.slice();
models.sort(function(a, b) { return (b.total_cost_usd || 0) - (a.total_cost_usd || 0); });
return models;
},
maxModelCost() {
var max = 0;
this.byModel.forEach(function(m) {
if ((m.total_cost_usd || 0) > max) max = m.total_cost_usd;
});
return max || 1;
},
costBarWidth(m) {
return Math.max(2, Math.round(((m.total_cost_usd || 0) / this.maxModelCost()) * 100)) + '%';
},
modelTier(modelName) {
if (!modelName) return 'unknown';
var lower = modelName.toLowerCase();
if (lower.indexOf('opus') !== -1 || lower.indexOf('o1') !== -1 || lower.indexOf('o3') !== -1 || lower.indexOf('deepseek-r1') !== -1) return 'frontier';
if (lower.indexOf('sonnet') !== -1 || lower.indexOf('gpt-4') !== -1 || lower.indexOf('gemini-2.5') !== -1 || lower.indexOf('gemini-1.5-pro') !== -1) return 'smart';
if (lower.indexOf('haiku') !== -1 || lower.indexOf('gpt-3.5') !== -1 || lower.indexOf('flash') !== -1 || lower.indexOf('mixtral') !== -1) return 'balanced';
if (lower.indexOf('llama') !== -1 || lower.indexOf('groq') !== -1 || lower.indexOf('gemma') !== -1) return 'fast';
return 'balanced';
}
};
}

View File

@@ -0,0 +1,544 @@
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
'use strict';
function wizardPage() {
return {
step: 1,
totalSteps: 6,
loading: false,
error: '',
// Step 2: Provider setup
providers: [],
selectedProvider: '',
apiKeyInput: '',
testingProvider: false,
testResult: null,
savingKey: false,
keySaved: false,
// Step 3: Agent creation
templates: [
{
id: 'assistant',
name: 'General Assistant',
description: 'A versatile helper for everyday tasks, answering questions, and providing recommendations.',
icon: 'GA',
category: 'General',
provider: 'deepseek',
model: 'deepseek-chat',
profile: 'balanced',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
},
{
id: 'coder',
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
icon: 'CH',
category: 'Development',
provider: 'deepseek',
model: 'deepseek-chat',
profile: 'precise',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
},
{
id: 'researcher',
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
icon: 'RS',
category: 'Research',
provider: 'gemini',
model: 'gemini-2.5-flash',
profile: 'balanced',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
},
{
id: 'writer',
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
icon: 'WR',
category: 'Writing',
provider: 'deepseek',
model: 'deepseek-chat',
profile: 'creative',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
},
{
id: 'data-analyst',
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
icon: 'DA',
category: 'Development',
provider: 'gemini',
model: 'gemini-2.5-flash',
profile: 'precise',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
},
{
id: 'devops',
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
icon: 'DO',
category: 'Development',
provider: 'deepseek',
model: 'deepseek-chat',
profile: 'precise',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
},
{
id: 'support',
name: 'Customer Support',
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
icon: 'CS',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'balanced',
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
},
{
id: 'tutor',
name: 'Tutor',
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
icon: 'TU',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'balanced',
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
},
{
id: 'api-designer',
name: 'API Designer',
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
icon: 'AD',
category: 'Development',
provider: 'deepseek',
model: 'deepseek-chat',
profile: 'precise',
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
},
{
id: 'meeting-notes',
name: 'Meeting Notes',
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
icon: 'MN',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'precise',
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
}
],
selectedTemplate: 0,
agentName: 'my-assistant',
creatingAgent: false,
createdAgent: null,
// Step 3: Category filtering
templateCategory: 'All',
get templateCategories() {
var cats = { 'All': true };
this.templates.forEach(function(t) { if (t.category) cats[t.category] = true; });
return Object.keys(cats);
},
get filteredTemplates() {
var cat = this.templateCategory;
if (cat === 'All') return this.templates;
return this.templates.filter(function(t) { return t.category === cat; });
},
// Step 3: Profile/tool descriptions
profileDescriptions: {
minimal: { label: 'Minimal', desc: 'Read-only file access' },
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
research: { label: 'Research', desc: 'Web search + file read/write' },
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
full: { label: 'Full', desc: 'All 35+ tools' }
},
profileInfo: function(name) { return this.profileDescriptions[name] || { label: name, desc: '' }; },
// Step 4: Try It chat
tryItMessages: [],
tryItInput: '',
tryItSending: false,
suggestedMessages: {
'General': ['What can you help me with?', 'Tell me a fun fact', 'Summarize the latest AI news'],
'Development': ['Write a Python hello world', 'Explain async/await', 'Review this code snippet'],
'Research': ['Explain quantum computing simply', 'Compare React vs Vue', 'What are the latest trends in AI?'],
'Writing': ['Help me write a professional email', 'Improve this paragraph', 'Write a blog intro about AI'],
'Business': ['Draft a meeting agenda', 'How do I handle a complaint?', 'Create a project status update']
},
get currentSuggestions() {
var tpl = this.templates[this.selectedTemplate];
var cat = tpl ? tpl.category : 'General';
return this.suggestedMessages[cat] || this.suggestedMessages['General'];
},
async sendTryItMessage(text) {
if (!text || !text.trim() || !this.createdAgent || this.tryItSending) return;
text = text.trim();
this.tryItInput = '';
this.tryItMessages.push({ role: 'user', text: text });
this.tryItSending = true;
try {
var res = await OpenFangAPI.post('/api/agents/' + this.createdAgent.id + '/message', { message: text });
this.tryItMessages.push({ role: 'agent', text: res.response || '(no response)' });
localStorage.setItem('of-first-msg', 'true');
} catch(e) {
this.tryItMessages.push({ role: 'agent', text: 'Error: ' + (e.message || 'Could not reach agent') });
}
this.tryItSending = false;
},
// Step 5: Channel setup (optional)
channelType: '',
channelOptions: [
{
name: 'telegram',
display_name: 'Telegram',
icon: 'TG',
description: 'Connect your agent to a Telegram bot for messaging.',
token_label: 'Bot Token',
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
token_env: 'TELEGRAM_BOT_TOKEN',
help: 'Create a bot via @BotFather on Telegram to get your token.'
},
{
name: 'discord',
display_name: 'Discord',
icon: 'DC',
description: 'Connect your agent to a Discord server via bot token.',
token_label: 'Bot Token',
token_placeholder: 'MTIz...abc',
token_env: 'DISCORD_BOT_TOKEN',
help: 'Create a Discord application at discord.com/developers and add a bot.'
},
{
name: 'slack',
display_name: 'Slack',
icon: 'SL',
description: 'Connect your agent to a Slack workspace.',
token_label: 'Bot Token',
token_placeholder: 'xoxb-...',
token_env: 'SLACK_BOT_TOKEN',
help: 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
}
],
channelToken: '',
configuringChannel: false,
channelConfigured: false,
// Step 5: Summary
setupSummary: {
provider: '',
agent: '',
channel: ''
},
// ── Lifecycle ──
async loadData() {
this.loading = true;
this.error = '';
try {
await this.loadProviders();
} catch(e) {
this.error = e.message || 'Could not load setup data.';
}
this.loading = false;
},
// ── Navigation ──
nextStep() {
if (this.step === 3 && !this.createdAgent) {
// Skip "Try It" if no agent was created
this.step = 5;
} else if (this.step < this.totalSteps) {
this.step++;
}
},
prevStep() {
if (this.step === 5 && !this.createdAgent) {
// Skip back past "Try It" if no agent was created
this.step = 3;
} else if (this.step > 1) {
this.step--;
}
},
goToStep(n) {
if (n >= 1 && n <= this.totalSteps) {
if (n === 4 && !this.createdAgent) return; // Can't go to Try It without agent
this.step = n;
}
},
stepLabel(n) {
var labels = ['Welcome', 'Provider', 'Agent', 'Try It', 'Channel', 'Done'];
return labels[n - 1] || '';
},
get canGoNext() {
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
if (this.step === 3) return this.agentName.trim().length > 0;
return true;
},
get hasConfiguredProvider() {
var self = this;
return this.providers.some(function(p) {
return p.auth_status === 'configured';
});
},
// ── Step 2: Providers ──
async loadProviders() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
// Pre-select first unconfigured provider, or first one
var unconfigured = this.providers.filter(function(p) {
return p.auth_status !== 'configured' && p.api_key_env;
});
if (unconfigured.length > 0) {
this.selectedProvider = unconfigured[0].id;
} else if (this.providers.length > 0) {
this.selectedProvider = this.providers[0].id;
}
} catch(e) { this.providers = []; }
},
get selectedProviderObj() {
var self = this;
var match = this.providers.filter(function(p) { return p.id === self.selectedProvider; });
return match.length > 0 ? match[0] : null;
},
get popularProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) >= 0;
}).sort(function(a, b) {
return popular.indexOf(a.id) - popular.indexOf(b.id);
});
},
get otherProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) < 0;
});
},
selectProvider(id) {
this.selectedProvider = id;
this.apiKeyInput = '';
this.testResult = null;
this.keySaved = false;
},
providerHelp: function(id) {
var help = {
anthropic: { url: 'https://console.anthropic.com/settings/keys', text: 'Get your key from the Anthropic Console' },
openai: { url: 'https://platform.openai.com/api-keys', text: 'Get your key from the OpenAI Platform' },
gemini: { url: 'https://aistudio.google.com/apikey', text: 'Get your key from Google AI Studio' },
groq: { url: 'https://console.groq.com/keys', text: 'Get your key from the Groq Console (free tier available)' },
deepseek: { url: 'https://platform.deepseek.com/api_keys', text: 'Get your key from the DeepSeek Platform (very affordable)' },
openrouter: { url: 'https://openrouter.ai/keys', text: 'Get your key from OpenRouter (access 100+ models with one key)' },
mistral: { url: 'https://console.mistral.ai/api-keys', text: 'Get your key from the Mistral Console' },
together: { url: 'https://api.together.xyz/settings/api-keys', text: 'Get your key from Together AI' },
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
};
return help[id] || null;
},
providerIsConfigured(p) {
return p && p.auth_status === 'configured';
},
async saveKey() {
var provider = this.selectedProviderObj;
if (!provider) return;
var key = this.apiKeyInput.trim();
if (!key) {
OpenFangToast.error('Please enter an API key');
return;
}
this.savingKey = true;
try {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key });
this.apiKeyInput = '';
this.keySaved = true;
this.setupSummary.provider = provider.display_name;
OpenFangToast.success('API key saved for ' + provider.display_name);
await this.loadProviders();
// Auto-test after saving
await this.testKey();
} catch(e) {
OpenFangToast.error('Failed to save key: ' + e.message);
}
this.savingKey = false;
},
async testKey() {
var provider = this.selectedProviderObj;
if (!provider) return;
this.testingProvider = true;
this.testResult = null;
try {
var result = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/test', {});
this.testResult = result;
if (result.status === 'ok') {
OpenFangToast.success(provider.display_name + ' connected (' + (result.latency_ms || '?') + 'ms)');
} else {
OpenFangToast.error(provider.display_name + ': ' + (result.error || 'Connection failed'));
}
} catch(e) {
this.testResult = { status: 'error', error: e.message };
OpenFangToast.error('Test failed: ' + e.message);
}
this.testingProvider = false;
},
// ── Step 3: Agent creation ──
selectTemplate(index) {
this.selectedTemplate = index;
var tpl = this.templates[index];
if (tpl) {
this.agentName = tpl.name.toLowerCase().replace(/\s+/g, '-');
}
},
async createAgent() {
var tpl = this.templates[this.selectedTemplate];
if (!tpl) return;
var name = this.agentName.trim();
if (!name) {
OpenFangToast.error('Please enter a name for your agent');
return;
}
// Use the provider the user just configured, or the template default
var provider = tpl.provider;
var model = tpl.model;
if (this.selectedProviderObj && this.providerIsConfigured(this.selectedProviderObj)) {
provider = this.selectedProviderObj.id;
// Use a sensible default model for the provider
model = this.defaultModelForProvider(provider) || tpl.model;
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'name = "' + model + '"\n\n';
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
this.creatingAgent = true;
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
if (res.agent_id) {
this.createdAgent = { id: res.agent_id, name: res.name || name };
this.setupSummary.agent = res.name || name;
OpenFangToast.success('Agent "' + (res.name || name) + '" created');
await Alpine.store('app').refreshAgents();
} else {
OpenFangToast.error('Failed: ' + (res.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Failed to create agent: ' + e.message);
}
this.creatingAgent = false;
},
defaultModelForProvider(providerId) {
var defaults = {
anthropic: 'claude-sonnet-4-20250514',
openai: 'gpt-4o',
gemini: 'gemini-2.5-flash',
groq: 'llama-3.3-70b-versatile',
deepseek: 'deepseek-chat',
openrouter: 'openrouter/auto',
mistral: 'mistral-large-latest',
together: 'meta-llama/Llama-3-70b-chat-hf',
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
perplexity: 'llama-3.1-sonar-large-128k-online',
cohere: 'command-r-plus',
xai: 'grok-2'
};
return defaults[providerId] || '';
},
// ── Step 5: Channel setup ──
selectChannel(name) {
if (this.channelType === name) {
this.channelType = '';
this.channelToken = '';
} else {
this.channelType = name;
this.channelToken = '';
}
},
get selectedChannelObj() {
var self = this;
var match = this.channelOptions.filter(function(ch) { return ch.name === self.channelType; });
return match.length > 0 ? match[0] : null;
},
async configureChannel() {
var ch = this.selectedChannelObj;
if (!ch) return;
var token = this.channelToken.trim();
if (!token) {
OpenFangToast.error('Please enter the ' + ch.token_label);
return;
}
this.configuringChannel = true;
try {
var fields = {};
fields[ch.token_env.toLowerCase()] = token;
fields.token = token;
await OpenFangAPI.post('/api/channels/' + ch.name + '/configure', { fields: fields });
this.channelConfigured = true;
this.setupSummary.channel = ch.display_name;
OpenFangToast.success(ch.display_name + ' configured and activated.');
} catch(e) {
OpenFangToast.error('Failed: ' + (e.message || 'Unknown error'));
}
this.configuringChannel = false;
},
// ── Step 6: Finish ──
finish() {
localStorage.setItem('openfang-onboarded', 'true');
Alpine.store('app').showOnboarding = false;
// Navigate to agents with chat if an agent was created, otherwise overview
if (this.createdAgent) {
var agent = this.createdAgent;
Alpine.store('app').pendingAgent = { id: agent.id, name: agent.name, model_provider: '?', model_name: '?' };
window.location.hash = 'agents';
} else {
window.location.hash = 'overview';
}
},
finishAndDismiss() {
localStorage.setItem('openfang-onboarded', 'true');
Alpine.store('app').showOnboarding = false;
window.location.hash = 'overview';
}
};
}

View File

@@ -0,0 +1,435 @@
// OpenFang Visual Workflow Builder — Drag-and-drop workflow designer
'use strict';
function workflowBuilder() {
return {
// -- Canvas state --
nodes: [],
connections: [],
selectedNode: null,
selectedConnection: null,
dragging: null,
dragOffset: { x: 0, y: 0 },
connecting: null, // { fromId, fromPort }
connectPreview: null, // { x, y } mouse position during connect drag
canvasOffset: { x: 0, y: 0 },
canvasDragging: false,
canvasDragStart: { x: 0, y: 0 },
zoom: 1,
nextId: 1,
workflowName: '',
workflowDescription: '',
showSaveModal: false,
showNodeEditor: false,
showTomlPreview: false,
tomlOutput: '',
agents: [],
_canvasEl: null,
// Node types with their configs
nodeTypes: [
{ type: 'agent', label: 'Agent Step', color: '#6366f1', icon: 'A', ports: { in: 1, out: 1 } },
{ type: 'parallel', label: 'Parallel Fan-out', color: '#f59e0b', icon: 'P', ports: { in: 1, out: 3 } },
{ type: 'condition', label: 'Condition', color: '#10b981', icon: '?', ports: { in: 1, out: 2 } },
{ type: 'loop', label: 'Loop', color: '#ef4444', icon: 'L', ports: { in: 1, out: 1 } },
{ type: 'collect', label: 'Collect', color: '#8b5cf6', icon: 'C', ports: { in: 3, out: 1 } },
{ type: 'start', label: 'Start', color: '#22c55e', icon: 'S', ports: { in: 0, out: 1 } },
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
],
async init() {
var self = this;
// Load agents for the agent step dropdown
try {
var list = await OpenFangAPI.get('/api/agents');
self.agents = Array.isArray(list) ? list : [];
} catch(_) {
self.agents = [];
}
// Add default start node
self.addNode('start', 60, 200);
},
// ── Node Management ──────────────────────────────────
addNode: function(type, x, y) {
var def = null;
for (var i = 0; i < this.nodeTypes.length; i++) {
if (this.nodeTypes[i].type === type) { def = this.nodeTypes[i]; break; }
}
if (!def) return;
var node = {
id: 'node-' + this.nextId++,
type: type,
label: def.label,
color: def.color,
icon: def.icon,
x: x || 200,
y: y || 200,
width: 180,
height: 70,
ports: { in: def.ports.in, out: def.ports.out },
config: {}
};
if (type === 'agent') {
node.config = { agent_name: '', prompt: '{{input}}', model: '' };
} else if (type === 'condition') {
node.config = { expression: '', true_label: 'Yes', false_label: 'No' };
} else if (type === 'loop') {
node.config = { max_iterations: 5, until: '' };
} else if (type === 'parallel') {
node.config = { fan_count: 3 };
} else if (type === 'collect') {
node.config = { strategy: 'all' };
}
this.nodes.push(node);
return node;
},
deleteNode: function(nodeId) {
this.connections = this.connections.filter(function(c) {
return c.from !== nodeId && c.to !== nodeId;
});
this.nodes = this.nodes.filter(function(n) { return n.id !== nodeId; });
if (this.selectedNode && this.selectedNode.id === nodeId) {
this.selectedNode = null;
this.showNodeEditor = false;
}
},
duplicateNode: function(node) {
var newNode = this.addNode(node.type, node.x + 30, node.y + 30);
if (newNode) {
newNode.config = JSON.parse(JSON.stringify(node.config));
newNode.label = node.label + ' copy';
}
},
getNode: function(id) {
for (var i = 0; i < this.nodes.length; i++) {
if (this.nodes[i].id === id) return this.nodes[i];
}
return null;
},
// ── Port Positions ───────────────────────────────────
getInputPortPos: function(node, portIndex) {
var total = node.ports.in;
var spacing = node.width / (total + 1);
return { x: node.x + spacing * (portIndex + 1), y: node.y };
},
getOutputPortPos: function(node, portIndex) {
var total = node.ports.out;
var spacing = node.width / (total + 1);
return { x: node.x + spacing * (portIndex + 1), y: node.y + node.height };
},
// ── Connection Management ────────────────────────────
startConnect: function(nodeId, portIndex, e) {
e.stopPropagation();
this.connecting = { fromId: nodeId, fromPort: portIndex };
var node = this.getNode(nodeId);
var pos = this.getOutputPortPos(node, portIndex);
this.connectPreview = { x: pos.x, y: pos.y };
},
endConnect: function(nodeId, portIndex, e) {
e.stopPropagation();
if (!this.connecting) return;
if (this.connecting.fromId === nodeId) {
this.connecting = null;
this.connectPreview = null;
return;
}
// Check for duplicate
var fromId = this.connecting.fromId;
var fromPort = this.connecting.fromPort;
var dup = false;
for (var i = 0; i < this.connections.length; i++) {
var c = this.connections[i];
if (c.from === fromId && c.fromPort === fromPort && c.to === nodeId && c.toPort === portIndex) {
dup = true;
break;
}
}
if (!dup) {
this.connections.push({
id: 'conn-' + this.nextId++,
from: fromId,
fromPort: fromPort,
to: nodeId,
toPort: portIndex
});
}
this.connecting = null;
this.connectPreview = null;
},
deleteConnection: function(connId) {
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
this.selectedConnection = null;
},
// ── Drag Handling ────────────────────────────────────
onNodeMouseDown: function(node, e) {
e.stopPropagation();
this.selectedNode = node;
this.selectedConnection = null;
this.dragging = node.id;
var rect = this._getCanvasRect();
this.dragOffset = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - node.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - node.y
};
},
onCanvasMouseDown: function(e) {
if (e.target.closest('.wf-node') || e.target.closest('.wf-port')) return;
this.selectedNode = null;
this.selectedConnection = null;
this.showNodeEditor = false;
// Start canvas pan
this.canvasDragging = true;
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
},
onCanvasMouseMove: function(e) {
var rect = this._getCanvasRect();
if (this.dragging) {
var node = this.getNode(this.dragging);
if (node) {
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
}
} else if (this.connecting) {
this.connectPreview = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
};
} else if (this.canvasDragging) {
this.canvasOffset = {
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
y: (e.clientY - this.canvasDragStart.y) / this.zoom
};
}
},
onCanvasMouseUp: function() {
this.dragging = null;
this.connecting = null;
this.connectPreview = null;
this.canvasDragging = false;
},
onCanvasWheel: function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? -0.05 : 0.05;
this.zoom = Math.max(0.3, Math.min(2, this.zoom + delta));
},
_getCanvasRect: function() {
if (!this._canvasEl) {
this._canvasEl = document.getElementById('wf-canvas');
}
return this._canvasEl ? this._canvasEl.getBoundingClientRect() : { left: 0, top: 0 };
},
// ── Connection Path ──────────────────────────────────
getConnectionPath: function(conn) {
var fromNode = this.getNode(conn.from);
var toNode = this.getNode(conn.to);
if (!fromNode || !toNode) return '';
var from = this.getOutputPortPos(fromNode, conn.fromPort);
var to = this.getInputPortPos(toNode, conn.toPort);
var dy = Math.abs(to.y - from.y);
var cp = Math.max(40, dy * 0.5);
return 'M ' + from.x + ' ' + from.y + ' C ' + from.x + ' ' + (from.y + cp) + ' ' + to.x + ' ' + (to.y - cp) + ' ' + to.x + ' ' + to.y;
},
getPreviewPath: function() {
if (!this.connecting || !this.connectPreview) return '';
var fromNode = this.getNode(this.connecting.fromId);
if (!fromNode) return '';
var from = this.getOutputPortPos(fromNode, this.connecting.fromPort);
var to = this.connectPreview;
var dy = Math.abs(to.y - from.y);
var cp = Math.max(40, dy * 0.5);
return 'M ' + from.x + ' ' + from.y + ' C ' + from.x + ' ' + (from.y + cp) + ' ' + to.x + ' ' + (to.y - cp) + ' ' + to.x + ' ' + to.y;
},
// ── Node editor ──────────────────────────────────────
editNode: function(node) {
this.selectedNode = node;
this.showNodeEditor = true;
},
// ── TOML Generation ──────────────────────────────────
generateToml: function() {
var self = this;
var lines = [];
lines.push('[workflow]');
lines.push('name = "' + (this.workflowName || 'untitled') + '"');
lines.push('description = "' + (this.workflowDescription || '') + '"');
lines.push('');
// Topological sort the nodes (skip start/end for step generation)
var stepNodes = this.nodes.filter(function(n) {
return n.type !== 'start' && n.type !== 'end';
});
for (var i = 0; i < stepNodes.length; i++) {
var node = stepNodes[i];
lines.push('[[workflow.steps]]');
lines.push('name = "' + (node.label || 'step-' + (i + 1)) + '"');
if (node.type === 'agent') {
lines.push('type = "agent"');
if (node.config.agent_name) lines.push('agent_name = "' + node.config.agent_name + '"');
lines.push('prompt = "' + (node.config.prompt || '{{input}}') + '"');
if (node.config.model) lines.push('model = "' + node.config.model + '"');
} else if (node.type === 'parallel') {
lines.push('type = "fan_out"');
lines.push('fan_count = ' + (node.config.fan_count || 3));
} else if (node.type === 'condition') {
lines.push('type = "conditional"');
lines.push('expression = "' + (node.config.expression || '') + '"');
} else if (node.type === 'loop') {
lines.push('type = "loop"');
lines.push('max_iterations = ' + (node.config.max_iterations || 5));
if (node.config.until) lines.push('until = "' + node.config.until + '"');
} else if (node.type === 'collect') {
lines.push('type = "collect"');
lines.push('strategy = "' + (node.config.strategy || 'all') + '"');
}
// Find what this node connects to
var outConns = self.connections.filter(function(c) { return c.from === node.id; });
if (outConns.length === 1) {
var target = self.getNode(outConns[0].to);
if (target && target.type !== 'end') {
lines.push('next = "' + target.label + '"');
}
} else if (outConns.length > 1 && node.type === 'condition') {
for (var j = 0; j < outConns.length; j++) {
var t2 = self.getNode(outConns[j].to);
if (t2 && t2.type !== 'end') {
var branchLabel = j === 0 ? 'true' : 'false';
lines.push('next_' + branchLabel + ' = "' + t2.label + '"');
}
}
} else if (outConns.length > 1 && node.type === 'parallel') {
var targets = [];
for (var k = 0; k < outConns.length; k++) {
var t3 = self.getNode(outConns[k].to);
if (t3 && t3.type !== 'end') targets.push('"' + t3.label + '"');
}
if (targets.length) lines.push('fan_targets = [' + targets.join(', ') + ']');
}
lines.push('');
}
this.tomlOutput = lines.join('\n');
this.showTomlPreview = true;
},
// ── Save Workflow ────────────────────────────────────
async saveWorkflow() {
var steps = [];
var stepNodes = this.nodes.filter(function(n) {
return n.type !== 'start' && n.type !== 'end';
});
for (var i = 0; i < stepNodes.length; i++) {
var node = stepNodes[i];
var step = {
name: node.label || 'step-' + (i + 1),
mode: node.type === 'parallel' ? 'fan_out' : node.type === 'loop' ? 'loop' : 'sequential'
};
if (node.type === 'agent') {
step.agent_name = node.config.agent_name || '';
step.prompt = node.config.prompt || '{{input}}';
}
steps.push(step);
}
try {
await OpenFangAPI.post('/api/workflows', {
name: this.workflowName || 'untitled',
description: this.workflowDescription || '',
steps: steps
});
OpenFangToast.success('Workflow saved!');
this.showSaveModal = false;
} catch(e) {
OpenFangToast.error('Failed to save: ' + e.message);
}
},
// ── Palette drop ─────────────────────────────────────
onPaletteDragStart: function(type, e) {
e.dataTransfer.setData('text/plain', type);
e.dataTransfer.effectAllowed = 'copy';
},
onCanvasDrop: function(e) {
e.preventDefault();
var type = e.dataTransfer.getData('text/plain');
if (!type) return;
var rect = this._getCanvasRect();
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
this.addNode(type, x - 90, y - 35);
},
onCanvasDragOver: function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
},
// ── Auto Layout ──────────────────────────────────────
autoLayout: function() {
// Simple top-to-bottom layout
var y = 40;
var x = 200;
for (var i = 0; i < this.nodes.length; i++) {
this.nodes[i].x = x;
this.nodes[i].y = y;
y += 120;
}
},
// ── Clear ────────────────────────────────────────────
clearCanvas: function() {
this.nodes = [];
this.connections = [];
this.selectedNode = null;
this.nextId = 1;
this.addNode('start', 60, 200);
},
// ── Zoom controls ────────────────────────────────────
zoomIn: function() {
this.zoom = Math.min(2, this.zoom + 0.1);
},
zoomOut: function() {
this.zoom = Math.max(0.3, this.zoom - 0.1);
},
zoomReset: function() {
this.zoom = 1;
this.canvasOffset = { x: 0, y: 0 };
}
};
}

View File

@@ -0,0 +1,79 @@
// OpenFang Workflows Page — Workflow builder + run history
'use strict';
function workflowsPage() {
return {
// -- Workflows state --
workflows: [],
showCreateModal: false,
runModal: null,
runInput: '',
runResult: '',
running: false,
loading: true,
loadError: '',
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
// -- Workflows methods --
async loadWorkflows() {
this.loading = true;
this.loadError = '';
try {
this.workflows = await OpenFangAPI.get('/api/workflows');
} catch(e) {
this.workflows = [];
this.loadError = e.message || 'Could not load workflows.';
}
this.loading = false;
},
async loadData() { return this.loadWorkflows(); },
async createWorkflow() {
var steps = this.newWf.steps.map(function(s) {
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
});
try {
var wfName = this.newWf.name;
await OpenFangAPI.post('/api/workflows', { name: wfName, description: this.newWf.description, steps: steps });
this.showCreateModal = false;
this.newWf = { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] };
OpenFangToast.success('Workflow "' + wfName + '" created');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to create workflow: ' + e.message);
}
},
showRunModal(wf) {
this.runModal = wf;
this.runInput = '';
this.runResult = '';
},
async executeWorkflow() {
if (!this.runModal) return;
this.running = true;
this.runResult = '';
try {
var res = await OpenFangAPI.post('/api/workflows/' + this.runModal.id + '/run', { input: this.runInput });
this.runResult = res.output || JSON.stringify(res, null, 2);
OpenFangToast.success('Workflow completed');
} catch(e) {
this.runResult = 'Error: ' + e.message;
OpenFangToast.error('Workflow failed: ' + e.message);
}
this.running = false;
},
async viewRuns(wf) {
try {
var runs = await OpenFangAPI.get('/api/workflows/' + wf.id + '/runs');
this.runResult = JSON.stringify(runs, null, 2);
this.runModal = wf;
} catch(e) {
OpenFangToast.error('Failed to load run history: ' + e.message);
}
}
};
}