otterly 0.3.0 → 0.3.1
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/dist/cli.js +7 -0
- package/dist/server/index.js +14 -0
- package/dist/server/playground.d.ts +1 -0
- package/dist/server/playground.js +1227 -0
- package/dist/server/routes-native.js +1 -1
- package/dist/server/swagger.d.ts +51 -0
- package/dist/server/swagger.js +38 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1227 @@
|
|
|
1
|
+
// Interactive API playground — self-contained HTML served at GET /playground.
|
|
2
|
+
// Zero external dependencies. Dark mode. GitHub-dark palette.
|
|
3
|
+
function playgroundCSS() {
|
|
4
|
+
return `
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #0d1117;
|
|
7
|
+
--surface: #161b22;
|
|
8
|
+
--surface-raised: #1c2129;
|
|
9
|
+
--border: #30363d;
|
|
10
|
+
--text: #e6edf3;
|
|
11
|
+
--text-muted: #8b949e;
|
|
12
|
+
--accent: #58a6ff;
|
|
13
|
+
--accent-hover: #79c0ff;
|
|
14
|
+
--success: #3fb950;
|
|
15
|
+
--error: #f85149;
|
|
16
|
+
--warning: #d29922;
|
|
17
|
+
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
|
|
18
|
+
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
19
|
+
--radius: 6px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
23
|
+
|
|
24
|
+
body {
|
|
25
|
+
font-family: var(--font-sans);
|
|
26
|
+
background: var(--bg);
|
|
27
|
+
color: var(--text);
|
|
28
|
+
line-height: 1.5;
|
|
29
|
+
min-height: 100vh;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Header */
|
|
33
|
+
.header {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
padding: 12px 20px;
|
|
38
|
+
border-bottom: 1px solid var(--border);
|
|
39
|
+
background: var(--surface);
|
|
40
|
+
}
|
|
41
|
+
.header-left {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 10px;
|
|
45
|
+
font-size: 16px;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
}
|
|
48
|
+
.header-right {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 8px;
|
|
52
|
+
}
|
|
53
|
+
.api-key-input {
|
|
54
|
+
background: var(--bg);
|
|
55
|
+
border: 1px solid var(--border);
|
|
56
|
+
border-radius: var(--radius);
|
|
57
|
+
color: var(--text);
|
|
58
|
+
padding: 6px 10px;
|
|
59
|
+
font-size: 13px;
|
|
60
|
+
font-family: var(--font-mono);
|
|
61
|
+
width: 220px;
|
|
62
|
+
}
|
|
63
|
+
.api-key-input:focus { outline: none; border-color: var(--accent); }
|
|
64
|
+
|
|
65
|
+
/* Tabs */
|
|
66
|
+
.tabs {
|
|
67
|
+
display: flex;
|
|
68
|
+
gap: 0;
|
|
69
|
+
border-bottom: 1px solid var(--border);
|
|
70
|
+
background: var(--surface);
|
|
71
|
+
padding: 0 20px;
|
|
72
|
+
overflow-x: auto;
|
|
73
|
+
}
|
|
74
|
+
.tab {
|
|
75
|
+
padding: 10px 16px;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
color: var(--text-muted);
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
font-weight: 500;
|
|
80
|
+
border-bottom: 2px solid transparent;
|
|
81
|
+
transition: color 0.15s, border-color 0.15s;
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
background: none;
|
|
84
|
+
border-top: none;
|
|
85
|
+
border-left: none;
|
|
86
|
+
border-right: none;
|
|
87
|
+
font-family: var(--font-sans);
|
|
88
|
+
}
|
|
89
|
+
.tab:hover { color: var(--text); }
|
|
90
|
+
.tab.active {
|
|
91
|
+
color: var(--accent);
|
|
92
|
+
border-bottom-color: var(--accent);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Main */
|
|
96
|
+
.main {
|
|
97
|
+
max-width: 960px;
|
|
98
|
+
margin: 0 auto;
|
|
99
|
+
padding: 20px;
|
|
100
|
+
}
|
|
101
|
+
.panel { display: none; }
|
|
102
|
+
.panel.active { display: block; }
|
|
103
|
+
|
|
104
|
+
/* Request panel */
|
|
105
|
+
.endpoint {
|
|
106
|
+
font-family: var(--font-mono);
|
|
107
|
+
font-size: 14px;
|
|
108
|
+
color: var(--accent);
|
|
109
|
+
margin-bottom: 16px;
|
|
110
|
+
}
|
|
111
|
+
.endpoint .method {
|
|
112
|
+
background: var(--accent);
|
|
113
|
+
color: var(--bg);
|
|
114
|
+
padding: 2px 8px;
|
|
115
|
+
border-radius: 3px;
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
font-size: 12px;
|
|
118
|
+
margin-right: 8px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.section-header {
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
font-weight: 600;
|
|
124
|
+
text-transform: uppercase;
|
|
125
|
+
letter-spacing: 0.5px;
|
|
126
|
+
color: var(--text-muted);
|
|
127
|
+
margin-bottom: 8px;
|
|
128
|
+
cursor: pointer;
|
|
129
|
+
user-select: none;
|
|
130
|
+
}
|
|
131
|
+
.section-header::before {
|
|
132
|
+
content: '\\25B6';
|
|
133
|
+
display: inline-block;
|
|
134
|
+
margin-right: 6px;
|
|
135
|
+
font-size: 10px;
|
|
136
|
+
transition: transform 0.15s;
|
|
137
|
+
}
|
|
138
|
+
.section-header.open::before {
|
|
139
|
+
transform: rotate(90deg);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.collapsible { display: none; margin-bottom: 16px; }
|
|
143
|
+
.collapsible.open { display: block; }
|
|
144
|
+
|
|
145
|
+
.header-row {
|
|
146
|
+
display: flex;
|
|
147
|
+
gap: 8px;
|
|
148
|
+
margin-bottom: 8px;
|
|
149
|
+
}
|
|
150
|
+
.header-row input {
|
|
151
|
+
flex: 1;
|
|
152
|
+
background: var(--bg);
|
|
153
|
+
border: 1px solid var(--border);
|
|
154
|
+
border-radius: var(--radius);
|
|
155
|
+
color: var(--text);
|
|
156
|
+
padding: 6px 10px;
|
|
157
|
+
font-size: 13px;
|
|
158
|
+
font-family: var(--font-mono);
|
|
159
|
+
}
|
|
160
|
+
.header-row input:focus { outline: none; border-color: var(--accent); }
|
|
161
|
+
.header-row .label {
|
|
162
|
+
min-width: 110px;
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
color: var(--text-muted);
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
textarea {
|
|
170
|
+
width: 100%;
|
|
171
|
+
min-height: 200px;
|
|
172
|
+
background: var(--bg);
|
|
173
|
+
border: 1px solid var(--border);
|
|
174
|
+
border-radius: var(--radius);
|
|
175
|
+
color: var(--text);
|
|
176
|
+
padding: 12px;
|
|
177
|
+
font-size: 13px;
|
|
178
|
+
font-family: var(--font-mono);
|
|
179
|
+
line-height: 1.5;
|
|
180
|
+
resize: vertical;
|
|
181
|
+
tab-size: 2;
|
|
182
|
+
}
|
|
183
|
+
textarea:focus { outline: none; border-color: var(--accent); }
|
|
184
|
+
|
|
185
|
+
.btn-row {
|
|
186
|
+
display: flex;
|
|
187
|
+
gap: 8px;
|
|
188
|
+
margin-top: 12px;
|
|
189
|
+
}
|
|
190
|
+
.btn {
|
|
191
|
+
padding: 8px 20px;
|
|
192
|
+
border: none;
|
|
193
|
+
border-radius: var(--radius);
|
|
194
|
+
font-size: 13px;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
font-family: var(--font-sans);
|
|
198
|
+
transition: opacity 0.15s;
|
|
199
|
+
}
|
|
200
|
+
.btn:hover { opacity: 0.85; }
|
|
201
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
202
|
+
.btn-primary {
|
|
203
|
+
background: var(--accent);
|
|
204
|
+
color: var(--bg);
|
|
205
|
+
}
|
|
206
|
+
.btn-secondary {
|
|
207
|
+
background: var(--surface-raised);
|
|
208
|
+
color: var(--text);
|
|
209
|
+
border: 1px solid var(--border);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Response */
|
|
213
|
+
.response-bar {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 12px;
|
|
217
|
+
margin-top: 24px;
|
|
218
|
+
margin-bottom: 8px;
|
|
219
|
+
font-size: 13px;
|
|
220
|
+
}
|
|
221
|
+
.response-bar .status-badge {
|
|
222
|
+
padding: 2px 8px;
|
|
223
|
+
border-radius: 3px;
|
|
224
|
+
font-weight: 600;
|
|
225
|
+
font-size: 12px;
|
|
226
|
+
font-family: var(--font-mono);
|
|
227
|
+
}
|
|
228
|
+
.response-bar .status-badge.ok { background: var(--success); color: var(--bg); }
|
|
229
|
+
.response-bar .status-badge.err { background: var(--error); color: #fff; }
|
|
230
|
+
.response-bar .duration { color: var(--text-muted); }
|
|
231
|
+
|
|
232
|
+
.response-tabs {
|
|
233
|
+
display: flex;
|
|
234
|
+
gap: 0;
|
|
235
|
+
margin-bottom: 0;
|
|
236
|
+
}
|
|
237
|
+
.response-tab {
|
|
238
|
+
padding: 6px 14px;
|
|
239
|
+
cursor: pointer;
|
|
240
|
+
font-size: 12px;
|
|
241
|
+
color: var(--text-muted);
|
|
242
|
+
background: var(--surface);
|
|
243
|
+
border: 1px solid var(--border);
|
|
244
|
+
border-bottom: none;
|
|
245
|
+
border-radius: var(--radius) var(--radius) 0 0;
|
|
246
|
+
font-family: var(--font-sans);
|
|
247
|
+
}
|
|
248
|
+
.response-tab.active {
|
|
249
|
+
color: var(--text);
|
|
250
|
+
background: var(--bg);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.response-body {
|
|
254
|
+
background: var(--bg);
|
|
255
|
+
border: 1px solid var(--border);
|
|
256
|
+
border-radius: 0 var(--radius) var(--radius) var(--radius);
|
|
257
|
+
padding: 16px;
|
|
258
|
+
font-family: var(--font-mono);
|
|
259
|
+
font-size: 13px;
|
|
260
|
+
white-space: pre-wrap;
|
|
261
|
+
word-break: break-word;
|
|
262
|
+
max-height: 500px;
|
|
263
|
+
overflow-y: auto;
|
|
264
|
+
position: relative;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* Streaming */
|
|
268
|
+
.stream-output {
|
|
269
|
+
background: var(--bg);
|
|
270
|
+
border: 1px solid var(--border);
|
|
271
|
+
border-radius: var(--radius);
|
|
272
|
+
padding: 16px;
|
|
273
|
+
font-family: var(--font-mono);
|
|
274
|
+
font-size: 13px;
|
|
275
|
+
white-space: pre-wrap;
|
|
276
|
+
word-break: break-word;
|
|
277
|
+
max-height: 500px;
|
|
278
|
+
overflow-y: auto;
|
|
279
|
+
margin-top: 16px;
|
|
280
|
+
position: relative;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.cursor-blink {
|
|
284
|
+
display: inline-block;
|
|
285
|
+
width: 8px;
|
|
286
|
+
height: 16px;
|
|
287
|
+
background: var(--accent);
|
|
288
|
+
animation: blink 1s step-end infinite;
|
|
289
|
+
vertical-align: text-bottom;
|
|
290
|
+
}
|
|
291
|
+
@keyframes blink {
|
|
292
|
+
50% { opacity: 0; }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.tool-call-block {
|
|
296
|
+
border-left: 3px solid var(--warning);
|
|
297
|
+
margin: 8px 0;
|
|
298
|
+
padding: 8px 12px;
|
|
299
|
+
background: var(--surface);
|
|
300
|
+
border-radius: 0 var(--radius) var(--radius) 0;
|
|
301
|
+
}
|
|
302
|
+
.tool-call-header {
|
|
303
|
+
font-weight: 600;
|
|
304
|
+
font-size: 12px;
|
|
305
|
+
color: var(--warning);
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
user-select: none;
|
|
308
|
+
}
|
|
309
|
+
.tool-call-header::before {
|
|
310
|
+
content: '\\25B6 ';
|
|
311
|
+
font-size: 10px;
|
|
312
|
+
}
|
|
313
|
+
.tool-call-block.open .tool-call-header::before {
|
|
314
|
+
content: '\\25BC ';
|
|
315
|
+
}
|
|
316
|
+
.tool-call-body {
|
|
317
|
+
display: none;
|
|
318
|
+
margin-top: 6px;
|
|
319
|
+
font-size: 12px;
|
|
320
|
+
color: var(--text-muted);
|
|
321
|
+
}
|
|
322
|
+
.tool-call-block.open .tool-call-body { display: block; }
|
|
323
|
+
|
|
324
|
+
.tool-result-ok { border-left-color: var(--success); }
|
|
325
|
+
.tool-result-err { border-left-color: var(--error); }
|
|
326
|
+
|
|
327
|
+
.summary-bar {
|
|
328
|
+
display: flex;
|
|
329
|
+
gap: 16px;
|
|
330
|
+
margin-top: 12px;
|
|
331
|
+
padding: 8px 12px;
|
|
332
|
+
background: var(--surface);
|
|
333
|
+
border-radius: var(--radius);
|
|
334
|
+
font-size: 12px;
|
|
335
|
+
color: var(--text-muted);
|
|
336
|
+
}
|
|
337
|
+
.summary-bar span { font-family: var(--font-mono); }
|
|
338
|
+
|
|
339
|
+
.jump-bottom {
|
|
340
|
+
position: sticky;
|
|
341
|
+
bottom: 8px;
|
|
342
|
+
float: right;
|
|
343
|
+
padding: 4px 12px;
|
|
344
|
+
font-size: 11px;
|
|
345
|
+
background: var(--accent);
|
|
346
|
+
color: var(--bg);
|
|
347
|
+
border: none;
|
|
348
|
+
border-radius: var(--radius);
|
|
349
|
+
cursor: pointer;
|
|
350
|
+
display: none;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* Status dashboard */
|
|
354
|
+
.status-grid {
|
|
355
|
+
display: grid;
|
|
356
|
+
grid-template-columns: 1fr 1fr;
|
|
357
|
+
gap: 12px;
|
|
358
|
+
}
|
|
359
|
+
.status-card {
|
|
360
|
+
background: var(--surface);
|
|
361
|
+
border: 1px solid var(--border);
|
|
362
|
+
border-radius: var(--radius);
|
|
363
|
+
padding: 16px;
|
|
364
|
+
}
|
|
365
|
+
.status-card h3 {
|
|
366
|
+
font-size: 12px;
|
|
367
|
+
text-transform: uppercase;
|
|
368
|
+
letter-spacing: 0.5px;
|
|
369
|
+
color: var(--text-muted);
|
|
370
|
+
margin-bottom: 8px;
|
|
371
|
+
}
|
|
372
|
+
.status-card .value {
|
|
373
|
+
font-size: 24px;
|
|
374
|
+
font-weight: 700;
|
|
375
|
+
font-family: var(--font-mono);
|
|
376
|
+
}
|
|
377
|
+
.status-card .sub {
|
|
378
|
+
font-size: 12px;
|
|
379
|
+
color: var(--text-muted);
|
|
380
|
+
margin-top: 4px;
|
|
381
|
+
}
|
|
382
|
+
.cb-closed { color: var(--success); }
|
|
383
|
+
.cb-open { color: var(--error); }
|
|
384
|
+
.cb-half-open { color: var(--warning); }
|
|
385
|
+
|
|
386
|
+
/* WebSocket */
|
|
387
|
+
.ws-controls {
|
|
388
|
+
display: flex;
|
|
389
|
+
gap: 8px;
|
|
390
|
+
margin-bottom: 12px;
|
|
391
|
+
align-items: center;
|
|
392
|
+
}
|
|
393
|
+
.ws-status {
|
|
394
|
+
width: 10px;
|
|
395
|
+
height: 10px;
|
|
396
|
+
border-radius: 50%;
|
|
397
|
+
background: var(--error);
|
|
398
|
+
display: inline-block;
|
|
399
|
+
}
|
|
400
|
+
.ws-status.connected { background: var(--success); }
|
|
401
|
+
.ws-status.connecting { background: var(--warning); }
|
|
402
|
+
|
|
403
|
+
.ws-log {
|
|
404
|
+
background: var(--bg);
|
|
405
|
+
border: 1px solid var(--border);
|
|
406
|
+
border-radius: var(--radius);
|
|
407
|
+
padding: 12px;
|
|
408
|
+
font-family: var(--font-mono);
|
|
409
|
+
font-size: 13px;
|
|
410
|
+
max-height: 400px;
|
|
411
|
+
overflow-y: auto;
|
|
412
|
+
margin-bottom: 12px;
|
|
413
|
+
}
|
|
414
|
+
.ws-msg { margin-bottom: 6px; }
|
|
415
|
+
.ws-msg.sent { color: var(--accent); }
|
|
416
|
+
.ws-msg.received { color: var(--success); }
|
|
417
|
+
.ws-msg.system { color: var(--text-muted); font-style: italic; }
|
|
418
|
+
|
|
419
|
+
.ws-input-row {
|
|
420
|
+
display: flex;
|
|
421
|
+
gap: 8px;
|
|
422
|
+
}
|
|
423
|
+
.ws-input-row textarea {
|
|
424
|
+
min-height: 60px;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* Utilities */
|
|
428
|
+
.hidden { display: none !important; }
|
|
429
|
+
.mt-16 { margin-top: 16px; }
|
|
430
|
+
.mb-8 { margin-bottom: 8px; }
|
|
431
|
+
`;
|
|
432
|
+
}
|
|
433
|
+
function playgroundJS(port) {
|
|
434
|
+
return `
|
|
435
|
+
(function() {
|
|
436
|
+
const PORT = ${port};
|
|
437
|
+
const BASE = location.origin;
|
|
438
|
+
const WS_BASE = BASE.replace(/^http/, 'ws');
|
|
439
|
+
|
|
440
|
+
// Request templates per tab
|
|
441
|
+
const TEMPLATES = {
|
|
442
|
+
run: JSON.stringify({ prompt: "What is 2 + 2?", options: { maxTurns: 1 } }, null, 2),
|
|
443
|
+
stream: JSON.stringify({ prompt: "Write a haiku about coding", options: { maxTurns: 1 } }, null, 2),
|
|
444
|
+
chat: JSON.stringify({
|
|
445
|
+
model: "claude-sonnet-4-20250514",
|
|
446
|
+
messages: [{ role: "user", content: "Hello! What can you do?" }],
|
|
447
|
+
stream: false
|
|
448
|
+
}, null, 2),
|
|
449
|
+
ws: JSON.stringify({ type: "start", prompt: "Hello from WebSocket" }, null, 2)
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Endpoints per tab
|
|
453
|
+
const ENDPOINTS = {
|
|
454
|
+
status: { method: 'GET', path: '/api/status' },
|
|
455
|
+
run: { method: 'POST', path: '/api/run' },
|
|
456
|
+
stream: { method: 'POST', path: '/api/stream' },
|
|
457
|
+
chat: { method: 'POST', path: '/v1/chat/completions' },
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// State
|
|
461
|
+
const state = {
|
|
462
|
+
activeTab: 'status',
|
|
463
|
+
apiKey: localStorage.getItem('otterly_api_key') || '',
|
|
464
|
+
sessionId: '',
|
|
465
|
+
ws: null,
|
|
466
|
+
wsConnected: false,
|
|
467
|
+
activeAbort: null,
|
|
468
|
+
statusInterval: null,
|
|
469
|
+
autoScroll: true,
|
|
470
|
+
responseMode: 'formatted', // 'formatted' | 'raw'
|
|
471
|
+
history: [],
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// DOM refs
|
|
475
|
+
const $ = (sel) => document.querySelector(sel);
|
|
476
|
+
const $$ = (sel) => document.querySelectorAll(sel);
|
|
477
|
+
|
|
478
|
+
// Init
|
|
479
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
480
|
+
// Restore API key
|
|
481
|
+
const keyInput = $('#api-key');
|
|
482
|
+
keyInput.value = state.apiKey;
|
|
483
|
+
keyInput.addEventListener('input', (e) => {
|
|
484
|
+
state.apiKey = e.target.value;
|
|
485
|
+
localStorage.setItem('otterly_api_key', state.apiKey);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Tab switching
|
|
489
|
+
$$('.tab').forEach((tab) => {
|
|
490
|
+
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Section headers (collapsible)
|
|
494
|
+
$$('.section-header').forEach((h) => {
|
|
495
|
+
h.addEventListener('click', () => {
|
|
496
|
+
h.classList.toggle('open');
|
|
497
|
+
const target = h.nextElementSibling;
|
|
498
|
+
if (target) target.classList.toggle('open');
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Send buttons
|
|
503
|
+
$('#send-run')?.addEventListener('click', () => sendRequest('run'));
|
|
504
|
+
$('#send-stream')?.addEventListener('click', () => sendStream());
|
|
505
|
+
$('#send-chat')?.addEventListener('click', () => sendChat());
|
|
506
|
+
|
|
507
|
+
// Cancel buttons
|
|
508
|
+
$$('.btn-cancel').forEach((btn) => {
|
|
509
|
+
btn.addEventListener('click', cancelRequest);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Response tab switching
|
|
513
|
+
$$('.response-tab').forEach((t) => {
|
|
514
|
+
t.addEventListener('click', (e) => {
|
|
515
|
+
const panel = e.target.closest('.panel');
|
|
516
|
+
panel.querySelectorAll('.response-tab').forEach((rt) => rt.classList.remove('active'));
|
|
517
|
+
e.target.classList.add('active');
|
|
518
|
+
state.responseMode = e.target.dataset.mode;
|
|
519
|
+
// Re-render if we have data
|
|
520
|
+
const formatted = panel.querySelector('.response-formatted');
|
|
521
|
+
const raw = panel.querySelector('.response-raw');
|
|
522
|
+
if (formatted && raw) {
|
|
523
|
+
formatted.classList.toggle('hidden', state.responseMode !== 'formatted');
|
|
524
|
+
raw.classList.toggle('hidden', state.responseMode !== 'raw');
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// WebSocket controls
|
|
530
|
+
$('#ws-connect')?.addEventListener('click', wsConnect);
|
|
531
|
+
$('#ws-disconnect')?.addEventListener('click', wsDisconnect);
|
|
532
|
+
$('#ws-send')?.addEventListener('click', wsSend);
|
|
533
|
+
|
|
534
|
+
// Jump to bottom
|
|
535
|
+
$$('.jump-bottom').forEach((btn) => {
|
|
536
|
+
btn.addEventListener('click', () => {
|
|
537
|
+
const container = btn.closest('.stream-output') || btn.closest('.ws-log');
|
|
538
|
+
if (container) {
|
|
539
|
+
container.scrollTop = container.scrollHeight;
|
|
540
|
+
state.autoScroll = true;
|
|
541
|
+
btn.style.display = 'none';
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Auto-scroll detection for stream outputs
|
|
547
|
+
$$('.stream-output, .ws-log').forEach((el) => {
|
|
548
|
+
el.addEventListener('scroll', () => {
|
|
549
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
|
550
|
+
state.autoScroll = atBottom;
|
|
551
|
+
const jumpBtn = el.querySelector('.jump-bottom');
|
|
552
|
+
if (jumpBtn) jumpBtn.style.display = atBottom ? 'none' : 'block';
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Start with status tab
|
|
557
|
+
switchTab('status');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
function switchTab(tab) {
|
|
561
|
+
state.activeTab = tab;
|
|
562
|
+
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
|
|
563
|
+
$$('.panel').forEach((p) => p.classList.toggle('active', p.id === 'panel-' + tab));
|
|
564
|
+
|
|
565
|
+
// Start/stop status polling
|
|
566
|
+
if (tab === 'status') {
|
|
567
|
+
fetchStatus();
|
|
568
|
+
state.statusInterval = setInterval(fetchStatus, 5000);
|
|
569
|
+
} else if (state.statusInterval) {
|
|
570
|
+
clearInterval(state.statusInterval);
|
|
571
|
+
state.statusInterval = null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function getHeaders() {
|
|
576
|
+
const h = { 'Content-Type': 'application/json' };
|
|
577
|
+
if (state.apiKey) h['Authorization'] = 'Bearer ' + state.apiKey;
|
|
578
|
+
const sid = $('#session-id-' + state.activeTab);
|
|
579
|
+
if (sid && sid.value) h['X-Session-Id'] = sid.value;
|
|
580
|
+
return h;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ── Status ──
|
|
584
|
+
async function fetchStatus() {
|
|
585
|
+
try {
|
|
586
|
+
const res = await fetch(BASE + '/api/status');
|
|
587
|
+
const data = await res.json();
|
|
588
|
+
renderStatus(data);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
$('#status-content').innerHTML = '<div style="color:var(--error)">Failed to connect: ' + escHtml(err.message) + '</div>';
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function renderStatus(data) {
|
|
595
|
+
const cb = data.circuitBreaker || 'closed';
|
|
596
|
+
const cbClass = cb === 'closed' ? 'cb-closed' : cb === 'open' ? 'cb-open' : 'cb-half-open';
|
|
597
|
+
const q = data.queue || {};
|
|
598
|
+
$('#status-content').innerHTML =
|
|
599
|
+
'<div class="status-grid">' +
|
|
600
|
+
'<div class="status-card"><h3>Status</h3><div class="value" style="color:var(--success)">' + escHtml(data.status) + '</div></div>' +
|
|
601
|
+
'<div class="status-card"><h3>Version</h3><div class="value">' + escHtml(data.version) + '</div></div>' +
|
|
602
|
+
'<div class="status-card"><h3>Active Sessions</h3><div class="value">' + data.activeSessions + '</div></div>' +
|
|
603
|
+
'<div class="status-card"><h3>Circuit Breaker</h3><div class="value ' + cbClass + '">' + escHtml(cb) + '</div></div>' +
|
|
604
|
+
'<div class="status-card"><h3>Queue Active</h3><div class="value">' + (q.active || 0) + '</div>' +
|
|
605
|
+
'<div class="sub">Max concurrent: ' + (q.maxConcurrent || '-') + '</div></div>' +
|
|
606
|
+
'<div class="status-card"><h3>Queue Waiting</h3><div class="value">' + (q.queued || 0) + '</div>' +
|
|
607
|
+
'<div class="sub">Max size: ' + (q.maxQueueSize || '-') + '</div></div>' +
|
|
608
|
+
'</div>';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── Run (one-shot) ──
|
|
612
|
+
async function sendRequest(tab) {
|
|
613
|
+
const bodyEl = $('#body-' + tab);
|
|
614
|
+
const resBar = $('#res-bar-' + tab);
|
|
615
|
+
const resFormatted = $('#res-formatted-' + tab);
|
|
616
|
+
const resRaw = $('#res-raw-' + tab);
|
|
617
|
+
const sendBtn = $('#send-' + tab);
|
|
618
|
+
|
|
619
|
+
let body;
|
|
620
|
+
try {
|
|
621
|
+
body = JSON.parse(bodyEl.value);
|
|
622
|
+
} catch (e) {
|
|
623
|
+
resBar.innerHTML = '<span class="status-badge err">JSON Error</span> <span>' + escHtml(e.message) + '</span>';
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
sendBtn.disabled = true;
|
|
628
|
+
resBar.innerHTML = '<span class="status-badge" style="background:var(--warning);color:var(--bg)">Pending</span>';
|
|
629
|
+
resFormatted.textContent = '';
|
|
630
|
+
resRaw.textContent = '';
|
|
631
|
+
|
|
632
|
+
const startTime = Date.now();
|
|
633
|
+
const controller = new AbortController();
|
|
634
|
+
state.activeAbort = controller;
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
const ep = ENDPOINTS[tab];
|
|
638
|
+
const res = await fetch(BASE + ep.path, {
|
|
639
|
+
method: ep.method,
|
|
640
|
+
headers: getHeaders(),
|
|
641
|
+
body: JSON.stringify(body),
|
|
642
|
+
signal: controller.signal,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const duration = Date.now() - startTime;
|
|
646
|
+
const raw = await res.text();
|
|
647
|
+
let parsed;
|
|
648
|
+
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
|
649
|
+
|
|
650
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
651
|
+
resBar.innerHTML =
|
|
652
|
+
'<span class="status-badge ' + (ok ? 'ok' : 'err') + '">' + res.status + ' ' + (res.statusText || '') + '</span>' +
|
|
653
|
+
'<span class="duration">' + duration + 'ms</span>';
|
|
654
|
+
|
|
655
|
+
resFormatted.textContent = typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : raw;
|
|
656
|
+
resRaw.textContent = raw;
|
|
657
|
+
|
|
658
|
+
// Update session ID from response
|
|
659
|
+
const newSid = res.headers.get('X-Session-Id');
|
|
660
|
+
const sidInput = $('#session-id-' + tab);
|
|
661
|
+
if (newSid && sidInput) sidInput.value = newSid;
|
|
662
|
+
} catch (err) {
|
|
663
|
+
if (err.name === 'AbortError') {
|
|
664
|
+
resBar.innerHTML = '<span class="status-badge" style="background:var(--text-muted);color:var(--bg)">Cancelled</span>';
|
|
665
|
+
} else {
|
|
666
|
+
resBar.innerHTML = '<span class="status-badge err">Error</span> <span>' + escHtml(err.message) + '</span>';
|
|
667
|
+
}
|
|
668
|
+
} finally {
|
|
669
|
+
sendBtn.disabled = false;
|
|
670
|
+
state.activeAbort = null;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── Stream (NDJSON) ──
|
|
675
|
+
async function sendStream() {
|
|
676
|
+
const bodyEl = $('#body-stream');
|
|
677
|
+
const output = $('#stream-output');
|
|
678
|
+
const textArea = $('#stream-text');
|
|
679
|
+
const rawArea = $('#stream-raw');
|
|
680
|
+
const summary = $('#stream-summary');
|
|
681
|
+
const sendBtn = $('#send-stream');
|
|
682
|
+
const rawToggle = $('#stream-raw-toggle');
|
|
683
|
+
|
|
684
|
+
let body;
|
|
685
|
+
try {
|
|
686
|
+
body = JSON.parse(bodyEl.value);
|
|
687
|
+
} catch (e) {
|
|
688
|
+
output.classList.remove('hidden');
|
|
689
|
+
textArea.textContent = 'JSON parse error: ' + e.message;
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
sendBtn.disabled = true;
|
|
694
|
+
output.classList.remove('hidden');
|
|
695
|
+
textArea.innerHTML = '<span class="cursor-blink"></span>';
|
|
696
|
+
rawArea.textContent = '';
|
|
697
|
+
summary.classList.add('hidden');
|
|
698
|
+
state.autoScroll = true;
|
|
699
|
+
|
|
700
|
+
const controller = new AbortController();
|
|
701
|
+
state.activeAbort = controller;
|
|
702
|
+
let showRaw = false;
|
|
703
|
+
rawToggle.textContent = 'Raw';
|
|
704
|
+
rawToggle.onclick = () => {
|
|
705
|
+
showRaw = !showRaw;
|
|
706
|
+
rawToggle.textContent = showRaw ? 'Formatted' : 'Raw';
|
|
707
|
+
textArea.classList.toggle('hidden', showRaw);
|
|
708
|
+
rawArea.classList.toggle('hidden', !showRaw);
|
|
709
|
+
};
|
|
710
|
+
rawArea.classList.add('hidden');
|
|
711
|
+
|
|
712
|
+
let fullText = '';
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
const res = await fetch(BASE + '/api/stream', {
|
|
716
|
+
method: 'POST',
|
|
717
|
+
headers: getHeaders(),
|
|
718
|
+
body: JSON.stringify(body),
|
|
719
|
+
signal: controller.signal,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const reader = res.body.getReader();
|
|
723
|
+
const decoder = new TextDecoder();
|
|
724
|
+
let buffer = '';
|
|
725
|
+
|
|
726
|
+
while (true) {
|
|
727
|
+
const { done, value } = await reader.read();
|
|
728
|
+
if (done) break;
|
|
729
|
+
|
|
730
|
+
buffer += decoder.decode(value, { stream: true });
|
|
731
|
+
const lines = buffer.split('\\n');
|
|
732
|
+
buffer = lines.pop(); // keep incomplete line
|
|
733
|
+
|
|
734
|
+
for (const line of lines) {
|
|
735
|
+
if (!line.trim()) continue;
|
|
736
|
+
rawArea.textContent += line + '\\n';
|
|
737
|
+
|
|
738
|
+
let event;
|
|
739
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
740
|
+
|
|
741
|
+
if (event.type === 'text_delta') {
|
|
742
|
+
fullText += event.text || '';
|
|
743
|
+
textArea.innerHTML = escHtml(fullText) + '<span class="cursor-blink"></span>';
|
|
744
|
+
} else if (event.type === 'tool_use') {
|
|
745
|
+
const block = document.createElement('div');
|
|
746
|
+
block.className = 'tool-call-block';
|
|
747
|
+
block.innerHTML =
|
|
748
|
+
'<div class="tool-call-header">' + escHtml(event.name || 'tool_use') + '</div>' +
|
|
749
|
+
'<div class="tool-call-body"><pre>' + escHtml(JSON.stringify(event.input || {}, null, 2)) + '</pre></div>';
|
|
750
|
+
block.querySelector('.tool-call-header').onclick = () => block.classList.toggle('open');
|
|
751
|
+
textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
|
|
752
|
+
} else if (event.type === 'tool_result') {
|
|
753
|
+
const block = document.createElement('div');
|
|
754
|
+
block.className = 'tool-call-block ' + (event.isError ? 'tool-result-err' : 'tool-result-ok');
|
|
755
|
+
block.innerHTML =
|
|
756
|
+
'<div class="tool-call-header">Result' + (event.isError ? ' (error)' : '') + '</div>' +
|
|
757
|
+
'<div class="tool-call-body"><pre>' + escHtml(typeof event.content === 'string' ? event.content : JSON.stringify(event.content, null, 2)) + '</pre></div>';
|
|
758
|
+
block.querySelector('.tool-call-header').onclick = () => block.classList.toggle('open');
|
|
759
|
+
textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
|
|
760
|
+
} else if (event.type === 'result') {
|
|
761
|
+
// Remove cursor
|
|
762
|
+
const cur = textArea.querySelector('.cursor-blink');
|
|
763
|
+
if (cur) cur.remove();
|
|
764
|
+
// Show summary
|
|
765
|
+
summary.classList.remove('hidden');
|
|
766
|
+
summary.innerHTML =
|
|
767
|
+
'<span>Cost: $' + (event.cost || 0).toFixed(4) + '</span>' +
|
|
768
|
+
'<span>Duration: ' + (event.duration || 0) + 'ms</span>' +
|
|
769
|
+
(event.usage ? '<span>Tokens: ' + (event.usage.inputTokens || 0) + ' in / ' + (event.usage.outputTokens || 0) + ' out</span>' : '');
|
|
770
|
+
|
|
771
|
+
// Update session ID
|
|
772
|
+
const sidInput = $('#session-id-stream');
|
|
773
|
+
if (event.sessionId && sidInput) sidInput.value = event.sessionId;
|
|
774
|
+
} else if (event.type === 'session_init' && event.sessionId) {
|
|
775
|
+
const sidInput = $('#session-id-stream');
|
|
776
|
+
if (sidInput) sidInput.value = event.sessionId;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (state.autoScroll) output.scrollTop = output.scrollHeight;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (err.name !== 'AbortError') {
|
|
784
|
+
textArea.innerHTML = fullText + '\\n<span style="color:var(--error)">Error: ' + escHtml(err.message) + '</span>';
|
|
785
|
+
} else {
|
|
786
|
+
const cur = textArea.querySelector('.cursor-blink');
|
|
787
|
+
if (cur) cur.remove();
|
|
788
|
+
textArea.innerHTML += '\\n<span style="color:var(--text-muted)">[Cancelled]</span>';
|
|
789
|
+
}
|
|
790
|
+
} finally {
|
|
791
|
+
sendBtn.disabled = false;
|
|
792
|
+
state.activeAbort = null;
|
|
793
|
+
// Remove cursor if still present
|
|
794
|
+
const cur = textArea.querySelector('.cursor-blink');
|
|
795
|
+
if (cur) cur.remove();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ── Chat (OpenAI) ──
|
|
800
|
+
async function sendChat() {
|
|
801
|
+
const bodyEl = $('#body-chat');
|
|
802
|
+
const sendBtn = $('#send-chat');
|
|
803
|
+
|
|
804
|
+
let body;
|
|
805
|
+
try {
|
|
806
|
+
body = JSON.parse(bodyEl.value);
|
|
807
|
+
} catch (e) {
|
|
808
|
+
$('#res-bar-chat').innerHTML = '<span class="status-badge err">JSON Error</span> <span>' + escHtml(e.message) + '</span>';
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const isStream = body.stream === true;
|
|
813
|
+
|
|
814
|
+
if (isStream) {
|
|
815
|
+
await sendChatStream(body, sendBtn);
|
|
816
|
+
} else {
|
|
817
|
+
// Non-streaming: reuse sendRequest-style logic
|
|
818
|
+
sendBtn.disabled = true;
|
|
819
|
+
const resBar = $('#res-bar-chat');
|
|
820
|
+
const resFormatted = $('#res-formatted-chat');
|
|
821
|
+
const resRaw = $('#res-raw-chat');
|
|
822
|
+
resBar.innerHTML = '<span class="status-badge" style="background:var(--warning);color:var(--bg)">Pending</span>';
|
|
823
|
+
resFormatted.textContent = '';
|
|
824
|
+
resRaw.textContent = '';
|
|
825
|
+
|
|
826
|
+
// Show standard response, hide streaming
|
|
827
|
+
$('#chat-response-standard').classList.remove('hidden');
|
|
828
|
+
$('#chat-stream-output').classList.add('hidden');
|
|
829
|
+
|
|
830
|
+
const startTime = Date.now();
|
|
831
|
+
const controller = new AbortController();
|
|
832
|
+
state.activeAbort = controller;
|
|
833
|
+
|
|
834
|
+
try {
|
|
835
|
+
const res = await fetch(BASE + '/v1/chat/completions', {
|
|
836
|
+
method: 'POST',
|
|
837
|
+
headers: getHeaders(),
|
|
838
|
+
body: JSON.stringify(body),
|
|
839
|
+
signal: controller.signal,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const duration = Date.now() - startTime;
|
|
843
|
+
const raw = await res.text();
|
|
844
|
+
let parsed;
|
|
845
|
+
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
|
846
|
+
|
|
847
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
848
|
+
resBar.innerHTML =
|
|
849
|
+
'<span class="status-badge ' + (ok ? 'ok' : 'err') + '">' + res.status + ' ' + (res.statusText || '') + '</span>' +
|
|
850
|
+
'<span class="duration">' + duration + 'ms</span>';
|
|
851
|
+
|
|
852
|
+
resFormatted.textContent = typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : raw;
|
|
853
|
+
resRaw.textContent = raw;
|
|
854
|
+
} catch (err) {
|
|
855
|
+
if (err.name === 'AbortError') {
|
|
856
|
+
resBar.innerHTML = '<span class="status-badge" style="background:var(--text-muted);color:var(--bg)">Cancelled</span>';
|
|
857
|
+
} else {
|
|
858
|
+
resBar.innerHTML = '<span class="status-badge err">Error</span> <span>' + escHtml(err.message) + '</span>';
|
|
859
|
+
}
|
|
860
|
+
} finally {
|
|
861
|
+
sendBtn.disabled = false;
|
|
862
|
+
state.activeAbort = null;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function sendChatStream(body, sendBtn) {
|
|
868
|
+
const output = $('#chat-stream-output');
|
|
869
|
+
const textArea = $('#chat-stream-text');
|
|
870
|
+
const summary = $('#chat-stream-summary');
|
|
871
|
+
|
|
872
|
+
sendBtn.disabled = true;
|
|
873
|
+
// Hide standard response, show streaming
|
|
874
|
+
$('#chat-response-standard').classList.add('hidden');
|
|
875
|
+
output.classList.remove('hidden');
|
|
876
|
+
textArea.innerHTML = '<span class="cursor-blink"></span>';
|
|
877
|
+
summary.classList.add('hidden');
|
|
878
|
+
state.autoScroll = true;
|
|
879
|
+
|
|
880
|
+
const controller = new AbortController();
|
|
881
|
+
state.activeAbort = controller;
|
|
882
|
+
let fullText = '';
|
|
883
|
+
const startTime = Date.now();
|
|
884
|
+
|
|
885
|
+
try {
|
|
886
|
+
const res = await fetch(BASE + '/v1/chat/completions', {
|
|
887
|
+
method: 'POST',
|
|
888
|
+
headers: getHeaders(),
|
|
889
|
+
body: JSON.stringify(body),
|
|
890
|
+
signal: controller.signal,
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const reader = res.body.getReader();
|
|
894
|
+
const decoder = new TextDecoder();
|
|
895
|
+
let buffer = '';
|
|
896
|
+
|
|
897
|
+
while (true) {
|
|
898
|
+
const { done, value } = await reader.read();
|
|
899
|
+
if (done) break;
|
|
900
|
+
|
|
901
|
+
buffer += decoder.decode(value, { stream: true });
|
|
902
|
+
const lines = buffer.split('\\n');
|
|
903
|
+
buffer = lines.pop();
|
|
904
|
+
|
|
905
|
+
for (const line of lines) {
|
|
906
|
+
if (!line.startsWith('data: ')) continue;
|
|
907
|
+
const data = line.slice(6);
|
|
908
|
+
if (data === '[DONE]') {
|
|
909
|
+
const cur = textArea.querySelector('.cursor-blink');
|
|
910
|
+
if (cur) cur.remove();
|
|
911
|
+
const duration = Date.now() - startTime;
|
|
912
|
+
summary.classList.remove('hidden');
|
|
913
|
+
summary.innerHTML = '<span>Duration: ' + duration + 'ms</span><span>Stream complete</span>';
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
let event;
|
|
918
|
+
try { event = JSON.parse(data); } catch { continue; }
|
|
919
|
+
|
|
920
|
+
const delta = event.choices?.[0]?.delta;
|
|
921
|
+
if (delta?.content) {
|
|
922
|
+
fullText += delta.content;
|
|
923
|
+
textArea.innerHTML = escHtml(fullText) + '<span class="cursor-blink"></span>';
|
|
924
|
+
}
|
|
925
|
+
// Tool calls in streaming
|
|
926
|
+
if (delta?.tool_calls) {
|
|
927
|
+
for (const tc of delta.tool_calls) {
|
|
928
|
+
if (tc.function?.name) {
|
|
929
|
+
const block = document.createElement('div');
|
|
930
|
+
block.className = 'tool-call-block';
|
|
931
|
+
block.innerHTML =
|
|
932
|
+
'<div class="tool-call-header">' + escHtml(tc.function.name) + '</div>' +
|
|
933
|
+
'<div class="tool-call-body"><pre>' + escHtml(tc.function.arguments || '') + '</pre></div>';
|
|
934
|
+
block.querySelector('.tool-call-header').onclick = () => block.classList.toggle('open');
|
|
935
|
+
textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (state.autoScroll) output.scrollTop = output.scrollHeight;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
} catch (err) {
|
|
944
|
+
if (err.name !== 'AbortError') {
|
|
945
|
+
textArea.innerHTML = fullText + '\\n<span style="color:var(--error)">Error: ' + escHtml(err.message) + '</span>';
|
|
946
|
+
} else {
|
|
947
|
+
const cur = textArea.querySelector('.cursor-blink');
|
|
948
|
+
if (cur) cur.remove();
|
|
949
|
+
textArea.innerHTML += '\\n<span style="color:var(--text-muted)">[Cancelled]</span>';
|
|
950
|
+
}
|
|
951
|
+
} finally {
|
|
952
|
+
sendBtn.disabled = false;
|
|
953
|
+
state.activeAbort = null;
|
|
954
|
+
const cur = textArea.querySelector('.cursor-blink');
|
|
955
|
+
if (cur) cur.remove();
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ── WebSocket ──
|
|
960
|
+
function wsConnect() {
|
|
961
|
+
if (state.ws) return;
|
|
962
|
+
const log = $('#ws-log');
|
|
963
|
+
const dot = $('#ws-dot');
|
|
964
|
+
|
|
965
|
+
dot.className = 'ws-status connecting';
|
|
966
|
+
wsLog('Connecting to ' + WS_BASE + '/ws ...', 'system');
|
|
967
|
+
|
|
968
|
+
const ws = new WebSocket(WS_BASE + '/ws');
|
|
969
|
+
state.ws = ws;
|
|
970
|
+
|
|
971
|
+
ws.onopen = () => {
|
|
972
|
+
state.wsConnected = true;
|
|
973
|
+
dot.className = 'ws-status connected';
|
|
974
|
+
wsLog('Connected', 'system');
|
|
975
|
+
$('#ws-connect').disabled = true;
|
|
976
|
+
$('#ws-disconnect').disabled = false;
|
|
977
|
+
$('#ws-send').disabled = false;
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
ws.onmessage = (e) => {
|
|
981
|
+
let display;
|
|
982
|
+
try {
|
|
983
|
+
const parsed = JSON.parse(e.data);
|
|
984
|
+
display = JSON.stringify(parsed, null, 2);
|
|
985
|
+
} catch {
|
|
986
|
+
display = e.data;
|
|
987
|
+
}
|
|
988
|
+
wsLog(display, 'received');
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
ws.onerror = () => {
|
|
992
|
+
wsLog('Connection error', 'system');
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
ws.onclose = (e) => {
|
|
996
|
+
state.ws = null;
|
|
997
|
+
state.wsConnected = false;
|
|
998
|
+
dot.className = 'ws-status';
|
|
999
|
+
wsLog('Disconnected (code: ' + e.code + ')', 'system');
|
|
1000
|
+
$('#ws-connect').disabled = false;
|
|
1001
|
+
$('#ws-disconnect').disabled = true;
|
|
1002
|
+
$('#ws-send').disabled = true;
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function wsDisconnect() {
|
|
1007
|
+
if (state.ws) {
|
|
1008
|
+
state.ws.close();
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function wsSend() {
|
|
1013
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
|
|
1014
|
+
const input = $('#ws-input');
|
|
1015
|
+
const msg = input.value.trim();
|
|
1016
|
+
if (!msg) return;
|
|
1017
|
+
|
|
1018
|
+
state.ws.send(msg);
|
|
1019
|
+
wsLog(msg, 'sent');
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function wsLog(text, type) {
|
|
1023
|
+
const log = $('#ws-log');
|
|
1024
|
+
const div = document.createElement('div');
|
|
1025
|
+
div.className = 'ws-msg ' + type;
|
|
1026
|
+
const prefix = type === 'sent' ? '\\u25B6 ' : type === 'received' ? '\\u25C0 ' : '\\u2022 ';
|
|
1027
|
+
div.textContent = prefix + text;
|
|
1028
|
+
log.appendChild(div);
|
|
1029
|
+
log.scrollTop = log.scrollHeight;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ── Utilities ──
|
|
1033
|
+
function cancelRequest() {
|
|
1034
|
+
if (state.activeAbort) {
|
|
1035
|
+
state.activeAbort.abort();
|
|
1036
|
+
state.activeAbort = null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function escHtml(str) {
|
|
1041
|
+
if (typeof str !== 'string') str = String(str);
|
|
1042
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1043
|
+
}
|
|
1044
|
+
})();
|
|
1045
|
+
`;
|
|
1046
|
+
}
|
|
1047
|
+
function playgroundHTML() {
|
|
1048
|
+
return `
|
|
1049
|
+
<div class="header">
|
|
1050
|
+
<div class="header-left">
|
|
1051
|
+
<span>\u{1F9A6}</span>
|
|
1052
|
+
<span>otterly playground</span>
|
|
1053
|
+
</div>
|
|
1054
|
+
<div class="header-right">
|
|
1055
|
+
<input type="password" id="api-key" class="api-key-input" placeholder="API Key (optional)">
|
|
1056
|
+
</div>
|
|
1057
|
+
</div>
|
|
1058
|
+
|
|
1059
|
+
<div class="tabs">
|
|
1060
|
+
<button class="tab active" data-tab="status">Status</button>
|
|
1061
|
+
<button class="tab" data-tab="run">Run</button>
|
|
1062
|
+
<button class="tab" data-tab="stream">Stream</button>
|
|
1063
|
+
<button class="tab" data-tab="chat">Chat (OpenAI)</button>
|
|
1064
|
+
<button class="tab" data-tab="ws">WebSocket</button>
|
|
1065
|
+
</div>
|
|
1066
|
+
|
|
1067
|
+
<div class="main">
|
|
1068
|
+
<!-- Status Panel -->
|
|
1069
|
+
<div class="panel active" id="panel-status">
|
|
1070
|
+
<div class="endpoint"><span class="method">GET</span>/api/status</div>
|
|
1071
|
+
<div id="status-content"><div style="color:var(--text-muted)">Loading...</div></div>
|
|
1072
|
+
</div>
|
|
1073
|
+
|
|
1074
|
+
<!-- Run Panel -->
|
|
1075
|
+
<div class="panel" id="panel-run">
|
|
1076
|
+
<div class="endpoint"><span class="method">POST</span>/api/run</div>
|
|
1077
|
+
|
|
1078
|
+
<div class="section-header">Headers</div>
|
|
1079
|
+
<div class="collapsible">
|
|
1080
|
+
<div class="header-row">
|
|
1081
|
+
<span class="label">X-Session-Id</span>
|
|
1082
|
+
<input type="text" id="session-id-run" placeholder="auto-populated from response">
|
|
1083
|
+
</div>
|
|
1084
|
+
</div>
|
|
1085
|
+
|
|
1086
|
+
<div class="mb-8" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Request Body</div>
|
|
1087
|
+
<textarea id="body-run"></textarea>
|
|
1088
|
+
|
|
1089
|
+
<div class="btn-row">
|
|
1090
|
+
<button class="btn btn-primary" id="send-run">Send</button>
|
|
1091
|
+
<button class="btn btn-secondary btn-cancel">Cancel</button>
|
|
1092
|
+
</div>
|
|
1093
|
+
|
|
1094
|
+
<div class="response-bar" id="res-bar-run"></div>
|
|
1095
|
+
<div class="response-tabs">
|
|
1096
|
+
<button class="response-tab active" data-mode="formatted">Formatted</button>
|
|
1097
|
+
<button class="response-tab" data-mode="raw">Raw</button>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div class="response-body">
|
|
1100
|
+
<pre id="res-formatted-run"></pre>
|
|
1101
|
+
<pre id="res-raw-run" class="hidden"></pre>
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
|
|
1105
|
+
<!-- Stream Panel -->
|
|
1106
|
+
<div class="panel" id="panel-stream">
|
|
1107
|
+
<div class="endpoint"><span class="method">POST</span>/api/stream</div>
|
|
1108
|
+
|
|
1109
|
+
<div class="section-header">Headers</div>
|
|
1110
|
+
<div class="collapsible">
|
|
1111
|
+
<div class="header-row">
|
|
1112
|
+
<span class="label">X-Session-Id</span>
|
|
1113
|
+
<input type="text" id="session-id-stream" placeholder="auto-populated from response">
|
|
1114
|
+
</div>
|
|
1115
|
+
</div>
|
|
1116
|
+
|
|
1117
|
+
<div class="mb-8" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Request Body</div>
|
|
1118
|
+
<textarea id="body-stream"></textarea>
|
|
1119
|
+
|
|
1120
|
+
<div class="btn-row">
|
|
1121
|
+
<button class="btn btn-primary" id="send-stream">Send</button>
|
|
1122
|
+
<button class="btn btn-secondary btn-cancel">Cancel</button>
|
|
1123
|
+
<button class="btn btn-secondary" id="stream-raw-toggle">Raw</button>
|
|
1124
|
+
</div>
|
|
1125
|
+
|
|
1126
|
+
<div class="stream-output hidden" id="stream-output">
|
|
1127
|
+
<div id="stream-text"></div>
|
|
1128
|
+
<pre id="stream-raw" class="hidden"></pre>
|
|
1129
|
+
<button class="jump-bottom">Jump to bottom</button>
|
|
1130
|
+
</div>
|
|
1131
|
+
<div class="summary-bar hidden" id="stream-summary"></div>
|
|
1132
|
+
</div>
|
|
1133
|
+
|
|
1134
|
+
<!-- Chat (OpenAI) Panel -->
|
|
1135
|
+
<div class="panel" id="panel-chat">
|
|
1136
|
+
<div class="endpoint"><span class="method">POST</span>/v1/chat/completions</div>
|
|
1137
|
+
|
|
1138
|
+
<div class="section-header">Headers</div>
|
|
1139
|
+
<div class="collapsible">
|
|
1140
|
+
<div class="header-row">
|
|
1141
|
+
<span class="label">X-Session-Id</span>
|
|
1142
|
+
<input type="text" id="session-id-chat" placeholder="auto-populated from response">
|
|
1143
|
+
</div>
|
|
1144
|
+
</div>
|
|
1145
|
+
|
|
1146
|
+
<div class="mb-8" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Request Body</div>
|
|
1147
|
+
<textarea id="body-chat"></textarea>
|
|
1148
|
+
|
|
1149
|
+
<div class="btn-row">
|
|
1150
|
+
<button class="btn btn-primary" id="send-chat">Send</button>
|
|
1151
|
+
<button class="btn btn-secondary btn-cancel">Cancel</button>
|
|
1152
|
+
</div>
|
|
1153
|
+
|
|
1154
|
+
<!-- Standard (non-streaming) response -->
|
|
1155
|
+
<div id="chat-response-standard">
|
|
1156
|
+
<div class="response-bar" id="res-bar-chat"></div>
|
|
1157
|
+
<div class="response-tabs">
|
|
1158
|
+
<button class="response-tab active" data-mode="formatted">Formatted</button>
|
|
1159
|
+
<button class="response-tab" data-mode="raw">Raw</button>
|
|
1160
|
+
</div>
|
|
1161
|
+
<div class="response-body">
|
|
1162
|
+
<pre id="res-formatted-chat"></pre>
|
|
1163
|
+
<pre id="res-raw-chat" class="hidden"></pre>
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
|
|
1167
|
+
<!-- Streaming response -->
|
|
1168
|
+
<div class="stream-output hidden" id="chat-stream-output">
|
|
1169
|
+
<div id="chat-stream-text"></div>
|
|
1170
|
+
<button class="jump-bottom">Jump to bottom</button>
|
|
1171
|
+
</div>
|
|
1172
|
+
<div class="summary-bar hidden" id="chat-stream-summary"></div>
|
|
1173
|
+
</div>
|
|
1174
|
+
|
|
1175
|
+
<!-- WebSocket Panel -->
|
|
1176
|
+
<div class="panel" id="panel-ws">
|
|
1177
|
+
<div class="endpoint"><span class="method" style="background:var(--success)">WS</span>/ws</div>
|
|
1178
|
+
|
|
1179
|
+
<div class="ws-controls">
|
|
1180
|
+
<span class="ws-status" id="ws-dot"></span>
|
|
1181
|
+
<button class="btn btn-primary" id="ws-connect" style="padding:6px 14px;">Connect</button>
|
|
1182
|
+
<button class="btn btn-secondary" id="ws-disconnect" disabled style="padding:6px 14px;">Disconnect</button>
|
|
1183
|
+
</div>
|
|
1184
|
+
|
|
1185
|
+
<div class="ws-log" id="ws-log"></div>
|
|
1186
|
+
|
|
1187
|
+
<div class="ws-input-row">
|
|
1188
|
+
<textarea id="ws-input"></textarea>
|
|
1189
|
+
<button class="btn btn-primary" id="ws-send" disabled style="align-self:flex-end;">Send</button>
|
|
1190
|
+
</div>
|
|
1191
|
+
</div>
|
|
1192
|
+
</div>
|
|
1193
|
+
`;
|
|
1194
|
+
}
|
|
1195
|
+
export function getPlaygroundHtml(port) {
|
|
1196
|
+
// Fill in templates via inline script
|
|
1197
|
+
const templateScript = `
|
|
1198
|
+
<script>
|
|
1199
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
1200
|
+
var TEMPLATES = {
|
|
1201
|
+
run: ${JSON.stringify(JSON.stringify({ prompt: "What is 2 + 2?", options: { maxTurns: 1 } }, null, 2))},
|
|
1202
|
+
stream: ${JSON.stringify(JSON.stringify({ prompt: "Write a haiku about coding", options: { maxTurns: 1 } }, null, 2))},
|
|
1203
|
+
chat: ${JSON.stringify(JSON.stringify({ model: "claude-sonnet-4-20250514", messages: [{ role: "user", content: "Hello! What can you do?" }], stream: false }, null, 2))},
|
|
1204
|
+
ws: ${JSON.stringify(JSON.stringify({ type: "start", prompt: "Hello from WebSocket" }, null, 2))}
|
|
1205
|
+
};
|
|
1206
|
+
var el;
|
|
1207
|
+
el = document.getElementById('body-run'); if (el) el.value = TEMPLATES.run;
|
|
1208
|
+
el = document.getElementById('body-stream'); if (el) el.value = TEMPLATES.stream;
|
|
1209
|
+
el = document.getElementById('body-chat'); if (el) el.value = TEMPLATES.chat;
|
|
1210
|
+
el = document.getElementById('ws-input'); if (el) el.value = TEMPLATES.ws;
|
|
1211
|
+
});
|
|
1212
|
+
</script>`;
|
|
1213
|
+
return `<!DOCTYPE html>
|
|
1214
|
+
<html lang="en">
|
|
1215
|
+
<head>
|
|
1216
|
+
<meta charset="UTF-8">
|
|
1217
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1218
|
+
<title>otterly playground</title>
|
|
1219
|
+
<style>${playgroundCSS()}</style>
|
|
1220
|
+
</head>
|
|
1221
|
+
<body>
|
|
1222
|
+
${playgroundHTML()}
|
|
1223
|
+
<script>${playgroundJS(port)}</script>
|
|
1224
|
+
${templateScript}
|
|
1225
|
+
</body>
|
|
1226
|
+
</html>`;
|
|
1227
|
+
}
|