初始化提交
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
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:
321
crates/openfang-api/static/js/api.js
Normal file
321
crates/openfang-api/static/js/api.js
Normal 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
|
||||
};
|
||||
})();
|
||||
319
crates/openfang-api/static/js/app.js
Normal file
319
crates/openfang-api/static/js/app.js
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
582
crates/openfang-api/static/js/pages/agents.js
Normal file
582
crates/openfang-api/static/js/pages/agents.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
66
crates/openfang-api/static/js/pages/approvals.js
Normal file
66
crates/openfang-api/static/js/pages/approvals.js
Normal 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';
|
||||
}
|
||||
};
|
||||
}
|
||||
300
crates/openfang-api/static/js/pages/channels.js
Normal file
300
crates/openfang-api/static/js/pages/channels.js
Normal 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; }
|
||||
}
|
||||
};
|
||||
}
|
||||
1072
crates/openfang-api/static/js/pages/chat.js
Normal file
1072
crates/openfang-api/static/js/pages/chat.js
Normal file
File diff suppressed because it is too large
Load Diff
504
crates/openfang-api/static/js/pages/hands.js
Normal file
504
crates/openfang-api/static/js/pages/hands.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
255
crates/openfang-api/static/js/pages/logs.js
Normal file
255
crates/openfang-api/static/js/pages/logs.js
Normal 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; }
|
||||
}
|
||||
};
|
||||
}
|
||||
292
crates/openfang-api/static/js/pages/overview.js
Normal file
292
crates/openfang-api/static/js/pages/overview.js
Normal 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';
|
||||
}
|
||||
};
|
||||
}
|
||||
393
crates/openfang-api/static/js/pages/scheduler.js
Normal file
393
crates/openfang-api/static/js/pages/scheduler.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
147
crates/openfang-api/static/js/pages/sessions.js
Normal file
147
crates/openfang-api/static/js/pages/sessions.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
669
crates/openfang-api/static/js/pages/settings.js
Normal file
669
crates/openfang-api/static/js/pages/settings.js
Normal 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 — 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
299
crates/openfang-api/static/js/pages/skills.js
Normal file
299
crates/openfang-api/static/js/pages/skills.js
Normal 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; });
|
||||
},
|
||||
};
|
||||
}
|
||||
251
crates/openfang-api/static/js/pages/usage.js
Normal file
251
crates/openfang-api/static/js/pages/usage.js
Normal 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';
|
||||
}
|
||||
};
|
||||
}
|
||||
544
crates/openfang-api/static/js/pages/wizard.js
Normal file
544
crates/openfang-api/static/js/pages/wizard.js
Normal 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';
|
||||
}
|
||||
};
|
||||
}
|
||||
435
crates/openfang-api/static/js/pages/workflow-builder.js
Normal file
435
crates/openfang-api/static/js/pages/workflow-builder.js
Normal 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 };
|
||||
}
|
||||
};
|
||||
}
|
||||
79
crates/openfang-api/static/js/pages/workflows.js
Normal file
79
crates/openfang-api/static/js/pages/workflows.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user