oc-inspector 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/bin/cli.mjs +89 -0
- package/package.json +41 -0
- package/src/config.mjs +284 -0
- package/src/dashboard.mjs +452 -0
- package/src/providers.mjs +186 -0
- package/src/proxy.mjs +356 -0
- package/src/server.mjs +207 -0
- package/src/usage-parsers.mjs +223 -0
- package/src/ws.mjs +143 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded HTML/JS/CSS dashboard for the OpenClaw Inspector.
|
|
3
|
+
*
|
|
4
|
+
* Exported as a single string for serving from the HTTP server.
|
|
5
|
+
* Features: real-time WebSocket feed, Enable/Disable toggle,
|
|
6
|
+
* provider badges, token usage display, collapsible message inspector.
|
|
7
|
+
*
|
|
8
|
+
* @module dashboard
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate the full HTML dashboard.
|
|
13
|
+
*
|
|
14
|
+
* @param {number} port - Inspector proxy port (for display).
|
|
15
|
+
* @returns {string} Complete HTML page.
|
|
16
|
+
*/
|
|
17
|
+
export function renderDashboard(port) {
|
|
18
|
+
return `<!DOCTYPE html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="utf-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
23
|
+
<title>OpenClaw Inspector</title>
|
|
24
|
+
<style>
|
|
25
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
26
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,monospace;background:#0d1117;color:#c9d1d9;font-size:13px}
|
|
27
|
+
a{color:#58a6ff;text-decoration:none}
|
|
28
|
+
|
|
29
|
+
/* Header */
|
|
30
|
+
.header{background:#161b22;border-bottom:1px solid #30363d;padding:10px 16px;display:flex;align-items:center;gap:16px;position:sticky;top:0;z-index:100}
|
|
31
|
+
.header h1{font-size:15px;color:#f0f6fc;white-space:nowrap}
|
|
32
|
+
.header h1 span{color:#f78166}
|
|
33
|
+
.stats{display:flex;gap:14px;font-size:12px;color:#8b949e;flex-wrap:wrap}
|
|
34
|
+
.stats b{color:#c9d1d9}
|
|
35
|
+
|
|
36
|
+
/* Toggle */
|
|
37
|
+
.toggle-area{margin-left:auto;display:flex;gap:8px;align-items:center}
|
|
38
|
+
.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
|
|
39
|
+
.status-dot.on{background:#3fb950}
|
|
40
|
+
.status-dot.off{background:#484f58}
|
|
41
|
+
.btn{padding:5px 12px;border-radius:6px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:12px;font-family:inherit}
|
|
42
|
+
.btn:hover{background:#30363d}
|
|
43
|
+
.btn.danger{border-color:#f85149;color:#f85149}
|
|
44
|
+
.btn.danger:hover{background:#f8514920}
|
|
45
|
+
.btn.primary{border-color:#238636;color:#3fb950;background:#238636 20}
|
|
46
|
+
.btn.primary:hover{background:#23863640}
|
|
47
|
+
.btn:disabled{opacity:.5;cursor:not-allowed}
|
|
48
|
+
|
|
49
|
+
/* Entries */
|
|
50
|
+
.entries{padding:8px}
|
|
51
|
+
.entry{background:#161b22;border:1px solid #30363d;border-radius:6px;margin-bottom:6px;overflow:hidden}
|
|
52
|
+
.entry-header{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;user-select:none}
|
|
53
|
+
.entry-header:hover{background:#1c2128}
|
|
54
|
+
.entry.expanded .entry-header{border-bottom:1px solid #30363d}
|
|
55
|
+
.badge{padding:2px 6px;border-radius:3px;font-size:11px;font-weight:600;text-transform:uppercase}
|
|
56
|
+
.badge.anthropic{background:#d4a27420;color:#d4a274}
|
|
57
|
+
.badge.openai{background:#10a37f20;color:#10a37f}
|
|
58
|
+
.badge.byteplus{background:#3b82f620;color:#60a5fa}
|
|
59
|
+
.badge.ollama{background:#8b5cf620;color:#a78bfa}
|
|
60
|
+
.badge.google{background:#ea433520;color:#f87171}
|
|
61
|
+
.badge.groq{background:#f59e0b20;color:#fbbf24}
|
|
62
|
+
.badge.default{background:#48505820;color:#8b949e}
|
|
63
|
+
.method{color:#8b949e;font-size:11px;width:36px}
|
|
64
|
+
.path{color:#8b949e;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
65
|
+
.model-name{color:#79c0ff;font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
66
|
+
.tokens{font-size:11px;color:#8b949e;white-space:nowrap}
|
|
67
|
+
.tokens b{color:#c9d1d9}
|
|
68
|
+
.duration{font-size:11px;color:#8b949e;width:60px;text-align:right}
|
|
69
|
+
.status-code{font-size:11px;font-weight:600;width:28px;text-align:center}
|
|
70
|
+
.status-code.s2{color:#3fb950}
|
|
71
|
+
.status-code.s4{color:#f85149}
|
|
72
|
+
.status-code.s5{color:#f85149}
|
|
73
|
+
.status-code.pending{color:#d29922}
|
|
74
|
+
.timestamp{font-size:11px;color:#484f58;width:70px;text-align:right}
|
|
75
|
+
|
|
76
|
+
/* Detail panel */
|
|
77
|
+
.detail{display:none;padding:12px;background:#0d1117;border-top:1px solid #21262d}
|
|
78
|
+
.entry.expanded .detail{display:block}
|
|
79
|
+
.tabs{display:flex;gap:0;margin-bottom:10px;border-bottom:1px solid #30363d}
|
|
80
|
+
.tab{padding:6px 14px;cursor:pointer;color:#8b949e;font-size:12px;border-bottom:2px solid transparent}
|
|
81
|
+
.tab:hover{color:#c9d1d9}
|
|
82
|
+
.tab.active{color:#f0f6fc;border-bottom-color:#f78166}
|
|
83
|
+
.tab-content{display:none}
|
|
84
|
+
.tab-content.active{display:block}
|
|
85
|
+
pre.json{background:#161b22;padding:10px;border-radius:4px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;font-size:12px;max-height:500px;overflow-y:auto;border:1px solid #21262d}
|
|
86
|
+
|
|
87
|
+
/* Messages view */
|
|
88
|
+
.msg{margin-bottom:8px;padding:8px 10px;border-radius:4px;border-left:3px solid #30363d}
|
|
89
|
+
.msg.system{border-color:#d29922;background:#d2992210}
|
|
90
|
+
.msg.user{border-color:#58a6ff;background:#58a6ff10}
|
|
91
|
+
.msg.assistant{border-color:#3fb950;background:#3fb95010}
|
|
92
|
+
.msg.tool,.msg.toolResult{border-color:#bc8cff;background:#bc8cff10}
|
|
93
|
+
.msg.developer{border-color:#f78166;background:#f7816610}
|
|
94
|
+
.msg-role{font-size:11px;font-weight:600;text-transform:uppercase;margin-bottom:4px}
|
|
95
|
+
.msg-role.system{color:#d29922}
|
|
96
|
+
.msg-role.user{color:#58a6ff}
|
|
97
|
+
.msg-role.assistant{color:#3fb950}
|
|
98
|
+
.msg-role.tool,.msg-role.toolResult{color:#bc8cff}
|
|
99
|
+
.msg-role.developer{color:#f78166}
|
|
100
|
+
.msg-text{white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5}
|
|
101
|
+
.tool-badge{display:inline-block;padding:1px 5px;border-radius:3px;background:#bc8cff20;color:#bc8cff;font-size:11px;font-weight:600;margin-right:4px}
|
|
102
|
+
.thinking{color:#8b949e;font-style:italic}
|
|
103
|
+
|
|
104
|
+
/* Empty state */
|
|
105
|
+
.empty{text-align:center;padding:60px;color:#484f58}
|
|
106
|
+
.empty h2{font-size:16px;margin-bottom:8px;color:#8b949e}
|
|
107
|
+
|
|
108
|
+
/* Connection */
|
|
109
|
+
.conn{font-size:11px;display:flex;align-items:center;gap:4px}
|
|
110
|
+
.conn-dot{width:6px;height:6px;border-radius:50%;background:#484f58}
|
|
111
|
+
.conn-dot.connected{background:#3fb950}
|
|
112
|
+
</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div class="header">
|
|
116
|
+
<h1><span>🏶</span> OpenClaw Inspector</h1>
|
|
117
|
+
<div class="stats">
|
|
118
|
+
<span>Requests: <b id="statReqs">0</b></span>
|
|
119
|
+
<span>Tokens: <b id="statTokens">0</b></span>
|
|
120
|
+
<span>Cost: <b id="statCost">$0.00</b></span>
|
|
121
|
+
<span class="conn"><span class="conn-dot" id="connDot"></span> <span id="connLabel">connecting</span></span>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="toggle-area">
|
|
124
|
+
<span class="status-dot off" id="statusDot"></span>
|
|
125
|
+
<span id="statusLabel" style="font-size:12px;color:#8b949e">checking...</span>
|
|
126
|
+
<button class="btn primary" id="btnEnable" disabled>Enable</button>
|
|
127
|
+
<button class="btn danger" id="btnDisable" disabled>Disable</button>
|
|
128
|
+
<button class="btn" id="btnClear">Clear</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="entries" id="entries">
|
|
132
|
+
<div class="empty" id="emptyState">
|
|
133
|
+
<h2>No requests yet</h2>
|
|
134
|
+
<p>Enable interception and send a message to your OpenClaw bot</p>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<script>
|
|
139
|
+
const PORT = ${port};
|
|
140
|
+
let ws = null;
|
|
141
|
+
let reconnectTimer = null;
|
|
142
|
+
let totalReqs = 0, totalTokens = 0, totalCost = 0;
|
|
143
|
+
const entryEls = new Map();
|
|
144
|
+
|
|
145
|
+
/* ── Provider badge class ── */
|
|
146
|
+
function badgeClass(p) {
|
|
147
|
+
const known = ['anthropic','openai','byteplus','ollama','google','groq'];
|
|
148
|
+
for (const k of known) if (p.includes(k)) return k;
|
|
149
|
+
return 'default';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ── Format helpers ── */
|
|
153
|
+
function fmtTs(ms) {
|
|
154
|
+
const d = new Date(ms);
|
|
155
|
+
return d.toLocaleTimeString('en-GB',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
|
156
|
+
}
|
|
157
|
+
function fmtDur(ms) { return ms != null ? ms < 1000 ? ms+'ms' : (ms/1000).toFixed(1)+'s' : '...'; }
|
|
158
|
+
function fmtTokens(n) { return n >= 1000 ? (n/1000).toFixed(1)+'k' : String(n); }
|
|
159
|
+
function statusClass(s) { if (!s) return 'pending'; return 's'+String(s)[0]; }
|
|
160
|
+
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
161
|
+
|
|
162
|
+
/* ── Stats ── */
|
|
163
|
+
function updateStats() {
|
|
164
|
+
document.getElementById('statReqs').textContent = totalReqs;
|
|
165
|
+
document.getElementById('statTokens').textContent = fmtTokens(totalTokens);
|
|
166
|
+
document.getElementById('statCost').textContent = '$' + totalCost.toFixed(4);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ── Create entry element ── */
|
|
170
|
+
function createEntryEl(e) {
|
|
171
|
+
const div = document.createElement('div');
|
|
172
|
+
div.className = 'entry';
|
|
173
|
+
div.dataset.id = e.id;
|
|
174
|
+
div.innerHTML = \`
|
|
175
|
+
<div class="entry-header" onclick="toggleDetail('\${e.id}')">
|
|
176
|
+
<span class="badge \${badgeClass(e.provider)}">\${escHtml(e.provider)}</span>
|
|
177
|
+
<span class="method">\${escHtml(e.method)}</span>
|
|
178
|
+
<span class="model-name">\${escHtml(e.model||'?')}</span>
|
|
179
|
+
<span class="path">\${escHtml(e.path)}</span>
|
|
180
|
+
<span class="tokens" id="tok-\${e.id}">\${renderTokens(e.usage)}</span>
|
|
181
|
+
<span class="status-code \${statusClass(e.status)}" id="st-\${e.id}">\${e.status||'...'}</span>
|
|
182
|
+
<span class="duration" id="dur-\${e.id}">\${fmtDur(e.duration)}</span>
|
|
183
|
+
<span class="timestamp">\${fmtTs(e.timestamp)}</span>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="detail" id="det-\${e.id}"></div>
|
|
186
|
+
\`;
|
|
187
|
+
return div;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderTokens(u) {
|
|
191
|
+
if (!u || (!u.inputTokens && !u.outputTokens)) return '<span style="color:#484f58">—</span>';
|
|
192
|
+
let s = 'in:<b>'+fmtTokens(u.inputTokens)+'</b> out:<b>'+fmtTokens(u.outputTokens)+'</b>';
|
|
193
|
+
if (u.cachedTokens > 0) s += ' cache:<b>'+fmtTokens(u.cachedTokens)+'</b>';
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── Update entry ── */
|
|
198
|
+
function updateEntryEl(e) {
|
|
199
|
+
const tokEl = document.getElementById('tok-'+e.id);
|
|
200
|
+
const stEl = document.getElementById('st-'+e.id);
|
|
201
|
+
const durEl = document.getElementById('dur-'+e.id);
|
|
202
|
+
if (tokEl) tokEl.innerHTML = renderTokens(e.usage);
|
|
203
|
+
if (stEl) { stEl.textContent = e.status||'...'; stEl.className = 'status-code '+statusClass(e.status); }
|
|
204
|
+
if (durEl) durEl.textContent = fmtDur(e.duration);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ── Toggle detail ── */
|
|
208
|
+
function toggleDetail(id) {
|
|
209
|
+
const entry = document.querySelector('.entry[data-id="'+id+'"]');
|
|
210
|
+
if (!entry) return;
|
|
211
|
+
if (entry.classList.contains('expanded')) {
|
|
212
|
+
entry.classList.remove('expanded');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
entry.classList.add('expanded');
|
|
216
|
+
const det = document.getElementById('det-'+id);
|
|
217
|
+
if (det && !det.dataset.loaded) {
|
|
218
|
+
det.innerHTML = '<p style="color:#8b949e;padding:8px">Loading...</p>';
|
|
219
|
+
det.dataset.loaded = '1';
|
|
220
|
+
ws?.send(JSON.stringify({action:'detail',id}));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ── Render detail panel ── */
|
|
225
|
+
function renderDetail(entry) {
|
|
226
|
+
const det = document.getElementById('det-'+entry.id);
|
|
227
|
+
if (!det) return;
|
|
228
|
+
det.dataset.loaded = '1';
|
|
229
|
+
|
|
230
|
+
// Build messages from request body
|
|
231
|
+
let messagesHtml = '';
|
|
232
|
+
const req = entry.reqBody;
|
|
233
|
+
if (req && typeof req === 'object') {
|
|
234
|
+
// System prompt
|
|
235
|
+
if (req.system) {
|
|
236
|
+
const sys = Array.isArray(req.system) ? req.system.map(b=>b.text||'').join('\\n') : String(req.system);
|
|
237
|
+
messagesHtml += renderMsg('system', sys);
|
|
238
|
+
}
|
|
239
|
+
// Messages array
|
|
240
|
+
if (Array.isArray(req.messages)) {
|
|
241
|
+
for (const m of req.messages) {
|
|
242
|
+
messagesHtml += renderMessage(m);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Response content
|
|
248
|
+
let responseHtml = '';
|
|
249
|
+
const res = entry.resBody;
|
|
250
|
+
if (typeof res === 'object' && res !== null) {
|
|
251
|
+
// Anthropic format
|
|
252
|
+
if (Array.isArray(res.content)) {
|
|
253
|
+
for (const block of res.content) {
|
|
254
|
+
if (block.type === 'text') responseHtml += renderMsg('assistant', block.text);
|
|
255
|
+
else if (block.type === 'thinking') responseHtml += '<div class="msg assistant"><div class="msg-role assistant">thinking</div><div class="msg-text thinking">'+escHtml(block.thinking||'')+'</div></div>';
|
|
256
|
+
else if (block.type === 'tool_use') responseHtml += '<div class="msg tool"><div class="msg-role tool"><span class="tool-badge">tool_call</span> '+escHtml(block.name)+'</div><pre class="json">'+escHtml(JSON.stringify(block.input,null,2))+'</pre></div>';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// OpenAI format
|
|
260
|
+
else if (Array.isArray(res.choices)) {
|
|
261
|
+
for (const c of res.choices) {
|
|
262
|
+
const msg = c.message || c.delta || {};
|
|
263
|
+
if (msg.content) responseHtml += renderMsg('assistant', msg.content);
|
|
264
|
+
if (Array.isArray(msg.tool_calls)) {
|
|
265
|
+
for (const tc of msg.tool_calls) {
|
|
266
|
+
const fn = tc.function || {};
|
|
267
|
+
let args = fn.arguments || '{}';
|
|
268
|
+
try { args = JSON.stringify(JSON.parse(args),null,2); } catch{}
|
|
269
|
+
responseHtml += '<div class="msg tool"><div class="msg-role tool"><span class="tool-badge">tool_call</span> '+escHtml(fn.name||'?')+'</div><pre class="json">'+escHtml(args)+'</pre></div>';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (typeof res === 'string' && res.includes('data:')) {
|
|
276
|
+
responseHtml += '<div class="msg assistant"><div class="msg-role assistant">streaming response</div><div class="msg-text" style="color:#8b949e">(SSE stream — '+Math.round((entry.resSize||0)/1024)+'KB captured)</div></div>';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
det.innerHTML = \`
|
|
280
|
+
<div class="tabs">
|
|
281
|
+
<div class="tab active" onclick="switchTab(this,'msgs-\${entry.id}')">Messages</div>
|
|
282
|
+
<div class="tab" onclick="switchTab(this,'req-\${entry.id}')">Request</div>
|
|
283
|
+
<div class="tab" onclick="switchTab(this,'res-\${entry.id}')">Response</div>
|
|
284
|
+
<div class="tab" onclick="switchTab(this,'hdrs-\${entry.id}')">Headers</div>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="tab-content active" id="msgs-\${entry.id}">\${messagesHtml || '<p style="color:#484f58">No messages</p>'}</div>
|
|
287
|
+
<div class="tab-content" id="req-\${entry.id}"><pre class="json">\${escHtml(typeof req==='object'?JSON.stringify(req,null,2):String(req||''))}</pre></div>
|
|
288
|
+
<div class="tab-content" id="res-\${entry.id}">\${responseHtml || '<pre class="json">'+escHtml(typeof res==='object'?JSON.stringify(res,null,2):String(res||'(empty)'))+'</pre>'}</div>
|
|
289
|
+
<div class="tab-content" id="hdrs-\${entry.id}">
|
|
290
|
+
<h4 style="color:#8b949e;margin-bottom:6px">Request Headers</h4>
|
|
291
|
+
<pre class="json">\${escHtml(JSON.stringify(entry.reqHeaders||{},null,2))}</pre>
|
|
292
|
+
<h4 style="color:#8b949e;margin:10px 0 6px">Response Headers</h4>
|
|
293
|
+
<pre class="json">\${escHtml(JSON.stringify(entry.resHeaders||{},null,2))}</pre>
|
|
294
|
+
</div>
|
|
295
|
+
\`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderMessage(m) {
|
|
299
|
+
const role = m.role || 'unknown';
|
|
300
|
+
if (typeof m.content === 'string') return renderMsg(role, m.content);
|
|
301
|
+
if (Array.isArray(m.content)) {
|
|
302
|
+
let html = '';
|
|
303
|
+
for (const block of m.content) {
|
|
304
|
+
if (block.type === 'text') html += renderMsg(role, block.text);
|
|
305
|
+
else if (block.type === 'image_url' || block.type === 'image') html += renderMsg(role, '[image]');
|
|
306
|
+
else if (block.type === 'tool_use') html += '<div class="msg tool"><div class="msg-role tool"><span class="tool-badge">tool_call</span> '+escHtml(block.name||'?')+'</div><pre class="json">'+escHtml(JSON.stringify(block.input||{},null,2))+'</pre></div>';
|
|
307
|
+
else if (block.type === 'tool_result') html += '<div class="msg toolResult"><div class="msg-role toolResult"><span class="tool-badge">tool_result</span> '+escHtml(block.tool_use_id||'')+'</div><div class="msg-text">'+escHtml(typeof block.content==='string'?block.content:JSON.stringify(block.content))+'</div></div>';
|
|
308
|
+
else if (block.type === 'thinking') html += '<div class="msg assistant"><div class="msg-role assistant">thinking</div><div class="msg-text thinking">'+escHtml(block.thinking||'').slice(0,500)+(block.thinking?.length>500?'...':'')+'</div></div>';
|
|
309
|
+
}
|
|
310
|
+
return html;
|
|
311
|
+
}
|
|
312
|
+
// tool_calls in OpenAI format
|
|
313
|
+
if (m.tool_calls) {
|
|
314
|
+
let html = renderMsg(role, m.content || '');
|
|
315
|
+
for (const tc of m.tool_calls) {
|
|
316
|
+
const fn = tc.function || {};
|
|
317
|
+
let args = fn.arguments || '{}';
|
|
318
|
+
try { args = JSON.stringify(JSON.parse(args),null,2); } catch{}
|
|
319
|
+
html += '<div class="msg tool"><div class="msg-role tool"><span class="tool-badge">tool_call</span> '+escHtml(fn.name||'?')+'</div><pre class="json">'+escHtml(args)+'</pre></div>';
|
|
320
|
+
}
|
|
321
|
+
return html;
|
|
322
|
+
}
|
|
323
|
+
// toolResult
|
|
324
|
+
if (role === 'tool') {
|
|
325
|
+
return '<div class="msg toolResult"><div class="msg-role toolResult"><span class="tool-badge">tool_result</span></div><div class="msg-text">'+escHtml(typeof m.content==='string'?m.content:JSON.stringify(m.content))+'</div></div>';
|
|
326
|
+
}
|
|
327
|
+
return renderMsg(role, JSON.stringify(m.content));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function renderMsg(role, text) {
|
|
331
|
+
const r = role === 'tool' ? 'toolResult' : role;
|
|
332
|
+
return '<div class="msg '+r+'"><div class="msg-role '+r+'">'+escHtml(role)+'</div><div class="msg-text">'+escHtml(text||'')+'</div></div>';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function switchTab(el, contentId) {
|
|
336
|
+
const parent = el.closest('.detail');
|
|
337
|
+
parent.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
|
338
|
+
parent.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
|
|
339
|
+
el.classList.add('active');
|
|
340
|
+
document.getElementById(contentId)?.classList.add('active');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* ── WebSocket ── */
|
|
344
|
+
function connect() {
|
|
345
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
346
|
+
ws = new WebSocket(proto+'//'+location.host+'/ws');
|
|
347
|
+
|
|
348
|
+
ws.onopen = () => {
|
|
349
|
+
document.getElementById('connDot').classList.add('connected');
|
|
350
|
+
document.getElementById('connLabel').textContent = 'connected';
|
|
351
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
ws.onclose = () => {
|
|
355
|
+
document.getElementById('connDot').classList.remove('connected');
|
|
356
|
+
document.getElementById('connLabel').textContent = 'reconnecting...';
|
|
357
|
+
reconnectTimer = setTimeout(connect, 2000);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
ws.onmessage = (ev) => {
|
|
361
|
+
const msg = JSON.parse(ev.data);
|
|
362
|
+
if (msg.type === 'init' || msg.type === 'new') {
|
|
363
|
+
document.getElementById('emptyState')?.remove();
|
|
364
|
+
const el = createEntryEl(msg.entry);
|
|
365
|
+
entryEls.set(msg.entry.id, el);
|
|
366
|
+
const container = document.getElementById('entries');
|
|
367
|
+
if (msg.type === 'new') {
|
|
368
|
+
container.prepend(el);
|
|
369
|
+
totalReqs++;
|
|
370
|
+
if (msg.entry.usage) {
|
|
371
|
+
totalTokens += msg.entry.usage.totalTokens || 0;
|
|
372
|
+
}
|
|
373
|
+
updateStats();
|
|
374
|
+
} else {
|
|
375
|
+
container.appendChild(el);
|
|
376
|
+
totalReqs++;
|
|
377
|
+
if (msg.entry.usage) totalTokens += msg.entry.usage.totalTokens || 0;
|
|
378
|
+
}
|
|
379
|
+
} else if (msg.type === 'update') {
|
|
380
|
+
updateEntryEl(msg.entry);
|
|
381
|
+
if (msg.entry.usage) {
|
|
382
|
+
totalTokens += msg.entry.usage.totalTokens || 0;
|
|
383
|
+
}
|
|
384
|
+
updateStats();
|
|
385
|
+
} else if (msg.type === 'detail') {
|
|
386
|
+
renderDetail(msg.entry);
|
|
387
|
+
} else if (msg.type === 'ready') {
|
|
388
|
+
updateStats();
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* ── Enable/Disable ── */
|
|
394
|
+
async function checkStatus() {
|
|
395
|
+
try {
|
|
396
|
+
const res = await fetch('/api/status');
|
|
397
|
+
const data = await res.json();
|
|
398
|
+
const dot = document.getElementById('statusDot');
|
|
399
|
+
const label = document.getElementById('statusLabel');
|
|
400
|
+
if (data.enabled) {
|
|
401
|
+
dot.className = 'status-dot on';
|
|
402
|
+
label.textContent = 'intercepting ('+data.providers.length+' providers)';
|
|
403
|
+
document.getElementById('btnEnable').disabled = true;
|
|
404
|
+
document.getElementById('btnDisable').disabled = false;
|
|
405
|
+
} else {
|
|
406
|
+
dot.className = 'status-dot off';
|
|
407
|
+
label.textContent = 'disabled';
|
|
408
|
+
document.getElementById('btnEnable').disabled = false;
|
|
409
|
+
document.getElementById('btnDisable').disabled = true;
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
document.getElementById('statusLabel').textContent = 'error';
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
document.getElementById('btnEnable').onclick = async () => {
|
|
417
|
+
document.getElementById('btnEnable').disabled = true;
|
|
418
|
+
document.getElementById('statusLabel').textContent = 'enabling...';
|
|
419
|
+
try {
|
|
420
|
+
const res = await fetch('/api/enable', { method: 'POST' });
|
|
421
|
+
const data = await res.json();
|
|
422
|
+
if (!data.ok) alert('Enable failed: ' + data.message);
|
|
423
|
+
} catch(e) { alert('Error: '+e.message); }
|
|
424
|
+
setTimeout(checkStatus, 1500);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
document.getElementById('btnDisable').onclick = async () => {
|
|
428
|
+
document.getElementById('btnDisable').disabled = true;
|
|
429
|
+
document.getElementById('statusLabel').textContent = 'disabling...';
|
|
430
|
+
try {
|
|
431
|
+
const res = await fetch('/api/disable', { method: 'POST' });
|
|
432
|
+
const data = await res.json();
|
|
433
|
+
if (!data.ok) alert('Disable failed: ' + data.message);
|
|
434
|
+
} catch(e) { alert('Error: '+e.message); }
|
|
435
|
+
setTimeout(checkStatus, 1500);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
document.getElementById('btnClear').onclick = () => {
|
|
439
|
+
document.getElementById('entries').innerHTML = '<div class="empty" id="emptyState"><h2>No requests yet</h2><p>Enable interception and send a message to your OpenClaw bot</p></div>';
|
|
440
|
+
entryEls.clear();
|
|
441
|
+
totalReqs = 0; totalTokens = 0; totalCost = 0;
|
|
442
|
+
updateStats();
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/* ── Init ── */
|
|
446
|
+
connect();
|
|
447
|
+
checkStatus();
|
|
448
|
+
setInterval(checkStatus, 10000);
|
|
449
|
+
</script>
|
|
450
|
+
</body>
|
|
451
|
+
</html>`;
|
|
452
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry for OpenClaw Inspector.
|
|
3
|
+
*
|
|
4
|
+
* Maps provider names to their upstream API base URLs and detects which
|
|
5
|
+
* providers are active by reading OpenClaw auth-profiles and config.
|
|
6
|
+
*
|
|
7
|
+
* @module providers
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Well-known base URLs for built-in OpenClaw providers.
|
|
15
|
+
*
|
|
16
|
+
* Sourced from @mariozechner/pi-ai models.generated.js and OpenClaw config
|
|
17
|
+
* defaults. Keys must match provider names used in openclaw.json and
|
|
18
|
+
* auth-profiles.json.
|
|
19
|
+
*
|
|
20
|
+
* @type {Record<string, string>}
|
|
21
|
+
*/
|
|
22
|
+
export const BUILTIN_URLS = {
|
|
23
|
+
anthropic: "https://api.anthropic.com",
|
|
24
|
+
openai: "https://api.openai.com/v1",
|
|
25
|
+
google: "https://generativelanguage.googleapis.com/v1beta",
|
|
26
|
+
"google-vertex": "https://us-central1-aiplatform.googleapis.com/v1",
|
|
27
|
+
"google-antigravity": "https://generativelanguage.googleapis.com/v1beta",
|
|
28
|
+
"google-gemini-cli": "https://generativelanguage.googleapis.com/v1beta",
|
|
29
|
+
groq: "https://api.groq.com/openai/v1",
|
|
30
|
+
mistral: "https://api.mistral.ai/v1",
|
|
31
|
+
xai: "https://api.x.ai/v1",
|
|
32
|
+
zai: "https://api.z.ai/v1",
|
|
33
|
+
openrouter: "https://openrouter.ai/api/v1",
|
|
34
|
+
cerebras: "https://api.cerebras.ai/v1",
|
|
35
|
+
huggingface: "https://api-inference.huggingface.co/v1",
|
|
36
|
+
"github-copilot": "https://api.githubcopilot.com",
|
|
37
|
+
minimax: "https://api.minimax.chat/v1",
|
|
38
|
+
"minimax-cn": "https://api.minimax.chat/v1",
|
|
39
|
+
byteplus: "https://ark.ap-southeast.bytepluses.com/api/v3",
|
|
40
|
+
"amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
41
|
+
"kimi-coding": "https://api.moonshot.cn/v1",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Detect the API type (Anthropic Messages vs OpenAI Completions) for a provider.
|
|
46
|
+
*
|
|
47
|
+
* Used to select the correct token usage parser.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} providerName - The provider identifier.
|
|
50
|
+
* @returns {"anthropic" | "openai" | "unknown"} The API family.
|
|
51
|
+
*/
|
|
52
|
+
export function detectApiFamily(providerName) {
|
|
53
|
+
const anthropicLike = new Set([
|
|
54
|
+
"anthropic",
|
|
55
|
+
"minimax",
|
|
56
|
+
"minimax-cn",
|
|
57
|
+
"zai",
|
|
58
|
+
"cloudflare-ai-gateway",
|
|
59
|
+
]);
|
|
60
|
+
if (anthropicLike.has(providerName)) return "anthropic";
|
|
61
|
+
|
|
62
|
+
const openaiLike = new Set([
|
|
63
|
+
"openai",
|
|
64
|
+
"groq",
|
|
65
|
+
"mistral",
|
|
66
|
+
"xai",
|
|
67
|
+
"openrouter",
|
|
68
|
+
"cerebras",
|
|
69
|
+
"huggingface",
|
|
70
|
+
"google",
|
|
71
|
+
"google-vertex",
|
|
72
|
+
"google-antigravity",
|
|
73
|
+
"google-gemini-cli",
|
|
74
|
+
"github-copilot",
|
|
75
|
+
]);
|
|
76
|
+
if (openaiLike.has(providerName)) return "openai";
|
|
77
|
+
|
|
78
|
+
return "unknown";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read auth-profiles.json from an OpenClaw agent directory and return the
|
|
83
|
+
* set of provider names that have credentials configured.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} openclawDir - Path to ~/.openclaw (or equivalent).
|
|
86
|
+
* @returns {Set<string>} Provider names with active auth profiles.
|
|
87
|
+
*
|
|
88
|
+
* Example:
|
|
89
|
+
* >>> detectActiveProviders("/Users/me/.openclaw")
|
|
90
|
+
* Set { "anthropic", "byteplus" }
|
|
91
|
+
*/
|
|
92
|
+
export function detectActiveProviders(openclawDir) {
|
|
93
|
+
const providers = new Set();
|
|
94
|
+
|
|
95
|
+
// Check auth-profiles in main agent dir
|
|
96
|
+
const profilePath = join(openclawDir, "agents", "main", "agent", "auth-profiles.json");
|
|
97
|
+
if (existsSync(profilePath)) {
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(readFileSync(profilePath, "utf-8"));
|
|
100
|
+
const profiles = data.profiles || {};
|
|
101
|
+
for (const key of Object.keys(profiles)) {
|
|
102
|
+
// Keys are "provider:profile" e.g. "anthropic:default"
|
|
103
|
+
const provider = key.split(":")[0];
|
|
104
|
+
if (provider) providers.add(provider);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore parse errors
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return providers;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the full provider-to-URL mapping for interception.
|
|
116
|
+
*
|
|
117
|
+
* Merges built-in providers (from auth-profiles) with custom providers
|
|
118
|
+
* (from openclaw.json models.providers section).
|
|
119
|
+
*
|
|
120
|
+
* @param {string} openclawDir - Path to ~/.openclaw.
|
|
121
|
+
* @param {Record<string, { baseUrl?: string }>} [configProviders={}]
|
|
122
|
+
* The `models.providers` object from openclaw.json.
|
|
123
|
+
* @returns {Map<string, string>} provider name -> upstream base URL.
|
|
124
|
+
*
|
|
125
|
+
* Example:
|
|
126
|
+
* >>> buildTargetMap("/Users/me/.openclaw", { byteplus: { baseUrl: "https://ark..." } })
|
|
127
|
+
* Map { "anthropic" => "https://api.anthropic.com", "byteplus" => "https://ark..." }
|
|
128
|
+
*/
|
|
129
|
+
export function buildTargetMap(openclawDir, configProviders = {}, inspectorState = null) {
|
|
130
|
+
const targets = new Map();
|
|
131
|
+
|
|
132
|
+
// 0. If we have inspector state (interception already enabled), use originals
|
|
133
|
+
if (inspectorState?.originals) {
|
|
134
|
+
for (const [name, orig] of Object.entries(inspectorState.originals)) {
|
|
135
|
+
targets.set(name, orig.baseUrl);
|
|
136
|
+
}
|
|
137
|
+
return targets;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 1. Add built-in providers that have auth configured
|
|
141
|
+
const active = detectActiveProviders(openclawDir);
|
|
142
|
+
for (const name of active) {
|
|
143
|
+
if (BUILTIN_URLS[name]) {
|
|
144
|
+
targets.set(name, BUILTIN_URLS[name]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 2. Add/override with custom providers from config
|
|
149
|
+
for (const [name, cfg] of Object.entries(configProviders)) {
|
|
150
|
+
if (cfg.baseUrl) {
|
|
151
|
+
if (isProxyUrl(cfg.baseUrl)) {
|
|
152
|
+
// Already pointing to a proxy — try to resolve real URL
|
|
153
|
+
// Check if it's our own proxy pattern: http://127.0.0.1:PORT/provider
|
|
154
|
+
if (BUILTIN_URLS[name]) {
|
|
155
|
+
targets.set(name, BUILTIN_URLS[name]);
|
|
156
|
+
}
|
|
157
|
+
// Otherwise skip — we can't determine the real upstream
|
|
158
|
+
} else {
|
|
159
|
+
targets.set(name, cfg.baseUrl);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 3. Always include ollama if it's on default port
|
|
165
|
+
if (!targets.has("ollama")) {
|
|
166
|
+
targets.set("ollama", "http://127.0.0.1:11434/v1");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return targets;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if a URL looks like it's pointing to a local proxy (not a real upstream).
|
|
174
|
+
*
|
|
175
|
+
* @param {string} url
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function isProxyUrl(url) {
|
|
179
|
+
// Matches localhost or 127.0.0.1 on non-standard ports (not 11434 which is Ollama)
|
|
180
|
+
const match = url.match(/^https?:\/\/(127\.0\.0\.1|localhost):(\d+)/);
|
|
181
|
+
if (!match) return false;
|
|
182
|
+
const port = parseInt(match[2], 10);
|
|
183
|
+
// Ollama default port is not a proxy
|
|
184
|
+
if (port === 11434) return false;
|
|
185
|
+
return true;
|
|
186
|
+
}
|