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

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

View File

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