otterly 0.3.0 → 0.3.2
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 +1626 -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,1626 @@
|
|
|
1
|
+
// Interactive API playground — self-contained HTML served at GET /playground.
|
|
2
|
+
// Zero external dependencies. Dark mode. Modern dev-tool aesthetic.
|
|
3
|
+
function playgroundCSS() {
|
|
4
|
+
return `
|
|
5
|
+
:root {
|
|
6
|
+
--bg-0: #09090b;
|
|
7
|
+
--bg-1: #0f0f12;
|
|
8
|
+
--bg-2: #18181b;
|
|
9
|
+
--bg-3: #232328;
|
|
10
|
+
--border: #27272a;
|
|
11
|
+
--border-subtle: #1e1e22;
|
|
12
|
+
--text-0: #fafafa;
|
|
13
|
+
--text-1: #a1a1aa;
|
|
14
|
+
--text-2: #63636e;
|
|
15
|
+
--accent: #5b9dff;
|
|
16
|
+
--accent-soft: rgba(91, 157, 255, 0.1);
|
|
17
|
+
--accent-border: rgba(91, 157, 255, 0.25);
|
|
18
|
+
--green: #4ade80;
|
|
19
|
+
--green-soft: rgba(74, 222, 128, 0.1);
|
|
20
|
+
--green-border: rgba(74, 222, 128, 0.25);
|
|
21
|
+
--red: #f87171;
|
|
22
|
+
--red-soft: rgba(248, 113, 113, 0.1);
|
|
23
|
+
--amber: #fbbf24;
|
|
24
|
+
--amber-soft: rgba(251, 191, 36, 0.1);
|
|
25
|
+
--font-sans: -apple-system, 'Inter', system-ui, 'Segoe UI', sans-serif;
|
|
26
|
+
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
|
27
|
+
--radius: 8px;
|
|
28
|
+
--radius-sm: 6px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
32
|
+
|
|
33
|
+
body {
|
|
34
|
+
font-family: var(--font-sans);
|
|
35
|
+
background: var(--bg-0);
|
|
36
|
+
color: var(--text-0);
|
|
37
|
+
height: 100vh;
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
-webkit-font-smoothing: antialiased;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
43
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
44
|
+
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 3px; }
|
|
45
|
+
::-webkit-scrollbar-thumb:hover { background: var(--text-2); }
|
|
46
|
+
|
|
47
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
|
48
|
+
|
|
49
|
+
/* ── App layout ── */
|
|
50
|
+
.app {
|
|
51
|
+
display: grid;
|
|
52
|
+
grid-template-rows: 52px 1fr;
|
|
53
|
+
height: 100vh;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ── Header ── */
|
|
57
|
+
.header {
|
|
58
|
+
display: grid;
|
|
59
|
+
grid-template-columns: 1fr auto 1fr;
|
|
60
|
+
align-items: center;
|
|
61
|
+
padding: 0 20px;
|
|
62
|
+
background: var(--bg-1);
|
|
63
|
+
border-bottom: 1px solid var(--border);
|
|
64
|
+
z-index: 10;
|
|
65
|
+
}
|
|
66
|
+
.header-left {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 10px;
|
|
70
|
+
}
|
|
71
|
+
.logo {
|
|
72
|
+
font-size: 18px;
|
|
73
|
+
line-height: 1;
|
|
74
|
+
}
|
|
75
|
+
.logo-text {
|
|
76
|
+
font-size: 14px;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
color: var(--text-0);
|
|
79
|
+
letter-spacing: -0.3px;
|
|
80
|
+
}
|
|
81
|
+
.version-badge {
|
|
82
|
+
font-size: 11px;
|
|
83
|
+
font-family: var(--font-mono);
|
|
84
|
+
color: var(--text-2);
|
|
85
|
+
background: var(--bg-3);
|
|
86
|
+
padding: 2px 7px;
|
|
87
|
+
border-radius: 4px;
|
|
88
|
+
}
|
|
89
|
+
.header-center {
|
|
90
|
+
display: flex;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
}
|
|
93
|
+
.header-right {
|
|
94
|
+
display: flex;
|
|
95
|
+
justify-content: flex-end;
|
|
96
|
+
align-items: center;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ── Nav (segmented control) ── */
|
|
100
|
+
.nav {
|
|
101
|
+
display: inline-flex;
|
|
102
|
+
gap: 1px;
|
|
103
|
+
background: var(--bg-2);
|
|
104
|
+
border-radius: var(--radius);
|
|
105
|
+
padding: 3px;
|
|
106
|
+
}
|
|
107
|
+
.nav-item {
|
|
108
|
+
padding: 6px 16px;
|
|
109
|
+
border-radius: var(--radius-sm);
|
|
110
|
+
font-size: 13px;
|
|
111
|
+
font-weight: 500;
|
|
112
|
+
color: var(--text-1);
|
|
113
|
+
background: transparent;
|
|
114
|
+
border: none;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
transition: color 0.15s, background 0.15s;
|
|
117
|
+
font-family: var(--font-sans);
|
|
118
|
+
white-space: nowrap;
|
|
119
|
+
}
|
|
120
|
+
.nav-item:hover { color: var(--text-0); background: var(--bg-3); }
|
|
121
|
+
.nav-item.active {
|
|
122
|
+
color: var(--text-0);
|
|
123
|
+
background: var(--bg-3);
|
|
124
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.04);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── API key input ── */
|
|
128
|
+
.key-wrapper {
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
gap: 8px;
|
|
132
|
+
background: var(--bg-2);
|
|
133
|
+
border: 1px solid var(--border);
|
|
134
|
+
border-radius: var(--radius-sm);
|
|
135
|
+
padding: 0 10px;
|
|
136
|
+
height: 32px;
|
|
137
|
+
transition: border-color 0.15s;
|
|
138
|
+
}
|
|
139
|
+
.key-wrapper:focus-within { border-color: var(--accent); }
|
|
140
|
+
.key-wrapper svg { flex-shrink: 0; color: var(--text-2); }
|
|
141
|
+
.key-input {
|
|
142
|
+
background: none;
|
|
143
|
+
border: none;
|
|
144
|
+
color: var(--text-0);
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
font-family: var(--font-mono);
|
|
147
|
+
width: 160px;
|
|
148
|
+
outline: none;
|
|
149
|
+
}
|
|
150
|
+
.key-input::placeholder { color: var(--text-2); }
|
|
151
|
+
.key-input:focus { outline: none; }
|
|
152
|
+
|
|
153
|
+
/* ── Content ── */
|
|
154
|
+
.content {
|
|
155
|
+
overflow: hidden;
|
|
156
|
+
position: relative;
|
|
157
|
+
}
|
|
158
|
+
.panel { display: none; height: 100%; }
|
|
159
|
+
.panel.active { display: flex; }
|
|
160
|
+
|
|
161
|
+
/* ── Split pane ── */
|
|
162
|
+
.split-pane {
|
|
163
|
+
display: grid;
|
|
164
|
+
grid-template-columns: 1fr 1fr;
|
|
165
|
+
width: 100%;
|
|
166
|
+
height: 100%;
|
|
167
|
+
}
|
|
168
|
+
.split-pane > .pane {
|
|
169
|
+
display: flex;
|
|
170
|
+
flex-direction: column;
|
|
171
|
+
overflow: hidden;
|
|
172
|
+
}
|
|
173
|
+
.split-pane > .pane:first-child {
|
|
174
|
+
border-right: 1px solid var(--border);
|
|
175
|
+
}
|
|
176
|
+
.pane-head {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
gap: 10px;
|
|
180
|
+
padding: 12px 20px;
|
|
181
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
182
|
+
flex-shrink: 0;
|
|
183
|
+
min-height: 44px;
|
|
184
|
+
}
|
|
185
|
+
.pane-head-right {
|
|
186
|
+
margin-left: auto;
|
|
187
|
+
display: flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
gap: 6px;
|
|
190
|
+
}
|
|
191
|
+
.pane-body {
|
|
192
|
+
flex: 1;
|
|
193
|
+
overflow-y: auto;
|
|
194
|
+
padding: 16px 20px;
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-direction: column;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── Method badges ── */
|
|
200
|
+
.method-badge {
|
|
201
|
+
display: inline-flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
padding: 3px 8px;
|
|
204
|
+
border-radius: 4px;
|
|
205
|
+
font-size: 11px;
|
|
206
|
+
font-weight: 700;
|
|
207
|
+
font-family: var(--font-mono);
|
|
208
|
+
letter-spacing: 0.5px;
|
|
209
|
+
}
|
|
210
|
+
.method-get { color: var(--green); background: var(--green-soft); }
|
|
211
|
+
.method-post { color: var(--accent); background: var(--accent-soft); }
|
|
212
|
+
.method-ws { color: var(--amber); background: var(--amber-soft); }
|
|
213
|
+
.endpoint-path {
|
|
214
|
+
font-family: var(--font-mono);
|
|
215
|
+
font-size: 13px;
|
|
216
|
+
color: var(--text-1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ── Form elements ── */
|
|
220
|
+
.field-label {
|
|
221
|
+
font-size: 11px;
|
|
222
|
+
font-weight: 600;
|
|
223
|
+
text-transform: uppercase;
|
|
224
|
+
letter-spacing: 0.5px;
|
|
225
|
+
color: var(--text-2);
|
|
226
|
+
margin-bottom: 6px;
|
|
227
|
+
}
|
|
228
|
+
.field-row {
|
|
229
|
+
display: flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
gap: 8px;
|
|
232
|
+
margin-bottom: 8px;
|
|
233
|
+
}
|
|
234
|
+
.field-row .label-inline {
|
|
235
|
+
font-size: 12px;
|
|
236
|
+
font-family: var(--font-mono);
|
|
237
|
+
color: var(--text-2);
|
|
238
|
+
min-width: 90px;
|
|
239
|
+
}
|
|
240
|
+
input[type="text"] {
|
|
241
|
+
flex: 1;
|
|
242
|
+
background: var(--bg-0);
|
|
243
|
+
border: 1px solid var(--border);
|
|
244
|
+
border-radius: var(--radius-sm);
|
|
245
|
+
color: var(--text-0);
|
|
246
|
+
padding: 7px 10px;
|
|
247
|
+
font-size: 12px;
|
|
248
|
+
font-family: var(--font-mono);
|
|
249
|
+
transition: border-color 0.15s;
|
|
250
|
+
}
|
|
251
|
+
input[type="text"]:focus { outline: none; border-color: var(--accent); }
|
|
252
|
+
input[type="text"]::placeholder { color: var(--text-2); }
|
|
253
|
+
|
|
254
|
+
textarea {
|
|
255
|
+
flex: 1;
|
|
256
|
+
min-height: 120px;
|
|
257
|
+
background: var(--bg-0);
|
|
258
|
+
border: 1px solid var(--border);
|
|
259
|
+
border-radius: var(--radius-sm);
|
|
260
|
+
color: var(--text-0);
|
|
261
|
+
padding: 12px 14px;
|
|
262
|
+
font-size: 13px;
|
|
263
|
+
font-family: var(--font-mono);
|
|
264
|
+
line-height: 1.6;
|
|
265
|
+
resize: none;
|
|
266
|
+
tab-size: 2;
|
|
267
|
+
transition: border-color 0.15s;
|
|
268
|
+
}
|
|
269
|
+
textarea:focus { outline: none; border-color: var(--accent); }
|
|
270
|
+
textarea::placeholder { color: var(--text-2); }
|
|
271
|
+
|
|
272
|
+
/* ── Collapsible sections ── */
|
|
273
|
+
.section-toggle {
|
|
274
|
+
display: flex;
|
|
275
|
+
align-items: center;
|
|
276
|
+
gap: 6px;
|
|
277
|
+
font-size: 11px;
|
|
278
|
+
font-weight: 600;
|
|
279
|
+
text-transform: uppercase;
|
|
280
|
+
letter-spacing: 0.5px;
|
|
281
|
+
color: var(--text-2);
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
user-select: none;
|
|
284
|
+
padding: 6px 0;
|
|
285
|
+
margin-bottom: 4px;
|
|
286
|
+
background: none;
|
|
287
|
+
border: none;
|
|
288
|
+
font-family: var(--font-sans);
|
|
289
|
+
}
|
|
290
|
+
.section-toggle:hover { color: var(--text-1); }
|
|
291
|
+
.section-toggle svg {
|
|
292
|
+
transition: transform 0.15s;
|
|
293
|
+
}
|
|
294
|
+
.section-toggle.open svg {
|
|
295
|
+
transform: rotate(90deg);
|
|
296
|
+
}
|
|
297
|
+
.collapsible { display: none; margin-bottom: 12px; }
|
|
298
|
+
.collapsible.open { display: block; }
|
|
299
|
+
|
|
300
|
+
/* ── Buttons ── */
|
|
301
|
+
.actions {
|
|
302
|
+
display: flex;
|
|
303
|
+
gap: 8px;
|
|
304
|
+
margin-top: 12px;
|
|
305
|
+
flex-shrink: 0;
|
|
306
|
+
}
|
|
307
|
+
.btn {
|
|
308
|
+
display: inline-flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
justify-content: center;
|
|
311
|
+
gap: 6px;
|
|
312
|
+
height: 32px;
|
|
313
|
+
padding: 0 14px;
|
|
314
|
+
border: none;
|
|
315
|
+
border-radius: var(--radius-sm);
|
|
316
|
+
font-size: 13px;
|
|
317
|
+
font-weight: 500;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
font-family: var(--font-sans);
|
|
320
|
+
transition: all 0.15s;
|
|
321
|
+
}
|
|
322
|
+
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
323
|
+
.btn-primary {
|
|
324
|
+
background: var(--accent);
|
|
325
|
+
color: #fff;
|
|
326
|
+
}
|
|
327
|
+
.btn-primary:hover:not(:disabled) { background: #4a8df0; }
|
|
328
|
+
.btn-ghost {
|
|
329
|
+
background: transparent;
|
|
330
|
+
color: var(--text-1);
|
|
331
|
+
border: 1px solid var(--border);
|
|
332
|
+
}
|
|
333
|
+
.btn-ghost:hover:not(:disabled) { background: var(--bg-2); color: var(--text-0); }
|
|
334
|
+
.btn-sm {
|
|
335
|
+
height: 26px;
|
|
336
|
+
padding: 0 10px;
|
|
337
|
+
font-size: 12px;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* ── Response area ── */
|
|
341
|
+
.res-status {
|
|
342
|
+
display: flex;
|
|
343
|
+
align-items: center;
|
|
344
|
+
gap: 10px;
|
|
345
|
+
font-size: 13px;
|
|
346
|
+
}
|
|
347
|
+
.status-pill {
|
|
348
|
+
display: inline-flex;
|
|
349
|
+
align-items: center;
|
|
350
|
+
padding: 2px 8px;
|
|
351
|
+
border-radius: 4px;
|
|
352
|
+
font-size: 12px;
|
|
353
|
+
font-weight: 600;
|
|
354
|
+
font-family: var(--font-mono);
|
|
355
|
+
}
|
|
356
|
+
.status-pill.ok { color: var(--green); background: var(--green-soft); }
|
|
357
|
+
.status-pill.err { color: var(--red); background: var(--red-soft); }
|
|
358
|
+
.status-pill.pending { color: var(--amber); background: var(--amber-soft); }
|
|
359
|
+
.status-pill.cancelled { color: var(--text-2); background: var(--bg-3); }
|
|
360
|
+
.res-duration { color: var(--text-2); font-size: 12px; font-family: var(--font-mono); }
|
|
361
|
+
|
|
362
|
+
.mode-toggle {
|
|
363
|
+
display: inline-flex;
|
|
364
|
+
background: var(--bg-2);
|
|
365
|
+
border-radius: 5px;
|
|
366
|
+
padding: 2px;
|
|
367
|
+
gap: 1px;
|
|
368
|
+
}
|
|
369
|
+
.mode-toggle button {
|
|
370
|
+
padding: 3px 10px;
|
|
371
|
+
border-radius: 4px;
|
|
372
|
+
font-size: 11px;
|
|
373
|
+
font-weight: 500;
|
|
374
|
+
color: var(--text-2);
|
|
375
|
+
background: transparent;
|
|
376
|
+
border: none;
|
|
377
|
+
cursor: pointer;
|
|
378
|
+
font-family: var(--font-sans);
|
|
379
|
+
transition: all 0.15s;
|
|
380
|
+
}
|
|
381
|
+
.mode-toggle button.active { color: var(--text-0); background: var(--bg-3); }
|
|
382
|
+
.mode-toggle button:hover:not(.active) { color: var(--text-1); }
|
|
383
|
+
|
|
384
|
+
.code-output {
|
|
385
|
+
flex: 1;
|
|
386
|
+
overflow-y: auto;
|
|
387
|
+
background: var(--bg-0);
|
|
388
|
+
border: 1px solid var(--border-subtle);
|
|
389
|
+
border-radius: var(--radius-sm);
|
|
390
|
+
padding: 14px 16px;
|
|
391
|
+
font-family: var(--font-mono);
|
|
392
|
+
font-size: 13px;
|
|
393
|
+
line-height: 1.6;
|
|
394
|
+
white-space: pre-wrap;
|
|
395
|
+
word-break: break-word;
|
|
396
|
+
margin-top: 12px;
|
|
397
|
+
}
|
|
398
|
+
.code-output.empty-state {
|
|
399
|
+
display: flex;
|
|
400
|
+
align-items: center;
|
|
401
|
+
justify-content: center;
|
|
402
|
+
color: var(--text-2);
|
|
403
|
+
font-family: var(--font-sans);
|
|
404
|
+
font-size: 13px;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* ── JSON syntax highlighting ── */
|
|
408
|
+
.json-key { color: #c4c4cc; }
|
|
409
|
+
.json-string { color: #7dd3a8; }
|
|
410
|
+
.json-number { color: #8cb4ff; }
|
|
411
|
+
.json-boolean { color: #fbbf24; }
|
|
412
|
+
.json-null { color: #63636e; }
|
|
413
|
+
|
|
414
|
+
/* ── Streaming ── */
|
|
415
|
+
.stream-area {
|
|
416
|
+
flex: 1;
|
|
417
|
+
overflow-y: auto;
|
|
418
|
+
background: var(--bg-0);
|
|
419
|
+
border: 1px solid var(--border-subtle);
|
|
420
|
+
border-radius: var(--radius-sm);
|
|
421
|
+
padding: 14px 16px;
|
|
422
|
+
font-family: var(--font-mono);
|
|
423
|
+
font-size: 13px;
|
|
424
|
+
line-height: 1.6;
|
|
425
|
+
white-space: pre-wrap;
|
|
426
|
+
word-break: break-word;
|
|
427
|
+
margin-top: 12px;
|
|
428
|
+
position: relative;
|
|
429
|
+
}
|
|
430
|
+
.cursor-blink {
|
|
431
|
+
display: inline-block;
|
|
432
|
+
width: 2px;
|
|
433
|
+
height: 15px;
|
|
434
|
+
background: var(--accent);
|
|
435
|
+
animation: blink 1s step-end infinite;
|
|
436
|
+
vertical-align: text-bottom;
|
|
437
|
+
margin-left: 1px;
|
|
438
|
+
border-radius: 1px;
|
|
439
|
+
}
|
|
440
|
+
@keyframes blink { 50% { opacity: 0; } }
|
|
441
|
+
|
|
442
|
+
.tool-block {
|
|
443
|
+
border-left: 2px solid var(--amber);
|
|
444
|
+
margin: 8px 0;
|
|
445
|
+
padding: 8px 12px;
|
|
446
|
+
background: var(--bg-2);
|
|
447
|
+
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
|
448
|
+
}
|
|
449
|
+
.tool-block-head {
|
|
450
|
+
font-weight: 600;
|
|
451
|
+
font-size: 12px;
|
|
452
|
+
color: var(--amber);
|
|
453
|
+
cursor: pointer;
|
|
454
|
+
user-select: none;
|
|
455
|
+
display: flex;
|
|
456
|
+
align-items: center;
|
|
457
|
+
gap: 6px;
|
|
458
|
+
}
|
|
459
|
+
.tool-block-head svg { transition: transform 0.15s; }
|
|
460
|
+
.tool-block.open .tool-block-head svg { transform: rotate(90deg); }
|
|
461
|
+
.tool-block-body {
|
|
462
|
+
display: none;
|
|
463
|
+
margin-top: 6px;
|
|
464
|
+
font-size: 12px;
|
|
465
|
+
color: var(--text-1);
|
|
466
|
+
}
|
|
467
|
+
.tool-block.open .tool-block-body { display: block; }
|
|
468
|
+
.tool-block.result-ok { border-left-color: var(--green); }
|
|
469
|
+
.tool-block.result-ok .tool-block-head { color: var(--green); }
|
|
470
|
+
.tool-block.result-err { border-left-color: var(--red); }
|
|
471
|
+
.tool-block.result-err .tool-block-head { color: var(--red); }
|
|
472
|
+
|
|
473
|
+
.summary-bar {
|
|
474
|
+
display: flex;
|
|
475
|
+
gap: 20px;
|
|
476
|
+
padding: 10px 14px;
|
|
477
|
+
background: var(--bg-2);
|
|
478
|
+
border-radius: var(--radius-sm);
|
|
479
|
+
font-size: 12px;
|
|
480
|
+
color: var(--text-1);
|
|
481
|
+
margin-top: 10px;
|
|
482
|
+
flex-shrink: 0;
|
|
483
|
+
}
|
|
484
|
+
.summary-bar span { font-family: var(--font-mono); }
|
|
485
|
+
.summary-bar .label { color: var(--text-2); margin-right: 4px; }
|
|
486
|
+
|
|
487
|
+
.jump-btn {
|
|
488
|
+
position: sticky;
|
|
489
|
+
bottom: 8px;
|
|
490
|
+
float: right;
|
|
491
|
+
padding: 4px 10px;
|
|
492
|
+
font-size: 11px;
|
|
493
|
+
background: var(--bg-3);
|
|
494
|
+
color: var(--text-1);
|
|
495
|
+
border: 1px solid var(--border);
|
|
496
|
+
border-radius: var(--radius-sm);
|
|
497
|
+
cursor: pointer;
|
|
498
|
+
display: none;
|
|
499
|
+
font-family: var(--font-sans);
|
|
500
|
+
}
|
|
501
|
+
.jump-btn:hover { background: var(--bg-2); }
|
|
502
|
+
|
|
503
|
+
/* ── Status dashboard ── */
|
|
504
|
+
.status-panel {
|
|
505
|
+
width: 100%;
|
|
506
|
+
height: 100%;
|
|
507
|
+
overflow-y: auto;
|
|
508
|
+
padding: 32px;
|
|
509
|
+
}
|
|
510
|
+
.status-inner {
|
|
511
|
+
max-width: 720px;
|
|
512
|
+
margin: 0 auto;
|
|
513
|
+
}
|
|
514
|
+
.status-hero {
|
|
515
|
+
display: flex;
|
|
516
|
+
align-items: center;
|
|
517
|
+
gap: 12px;
|
|
518
|
+
margin-bottom: 28px;
|
|
519
|
+
}
|
|
520
|
+
.status-dot {
|
|
521
|
+
width: 10px;
|
|
522
|
+
height: 10px;
|
|
523
|
+
border-radius: 50%;
|
|
524
|
+
flex-shrink: 0;
|
|
525
|
+
}
|
|
526
|
+
.status-dot.green { background: var(--green); box-shadow: 0 0 8px rgba(74, 222, 128, 0.4); }
|
|
527
|
+
.status-dot.red { background: var(--red); box-shadow: 0 0 8px rgba(248, 113, 113, 0.4); }
|
|
528
|
+
.status-headline {
|
|
529
|
+
font-size: 16px;
|
|
530
|
+
font-weight: 600;
|
|
531
|
+
color: var(--text-0);
|
|
532
|
+
}
|
|
533
|
+
.status-sub {
|
|
534
|
+
font-size: 12px;
|
|
535
|
+
color: var(--text-2);
|
|
536
|
+
margin-top: 1px;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.metrics-row {
|
|
540
|
+
display: grid;
|
|
541
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
542
|
+
gap: 1px;
|
|
543
|
+
background: var(--border-subtle);
|
|
544
|
+
border: 1px solid var(--border);
|
|
545
|
+
border-radius: var(--radius);
|
|
546
|
+
overflow: hidden;
|
|
547
|
+
}
|
|
548
|
+
.metric {
|
|
549
|
+
background: var(--bg-1);
|
|
550
|
+
padding: 16px 18px;
|
|
551
|
+
}
|
|
552
|
+
.metric-value {
|
|
553
|
+
font-size: 20px;
|
|
554
|
+
font-weight: 600;
|
|
555
|
+
font-family: var(--font-mono);
|
|
556
|
+
color: var(--text-0);
|
|
557
|
+
line-height: 1.2;
|
|
558
|
+
}
|
|
559
|
+
.metric-label {
|
|
560
|
+
font-size: 11px;
|
|
561
|
+
font-weight: 500;
|
|
562
|
+
text-transform: uppercase;
|
|
563
|
+
letter-spacing: 0.5px;
|
|
564
|
+
color: var(--text-2);
|
|
565
|
+
margin-top: 4px;
|
|
566
|
+
}
|
|
567
|
+
.metric-sub {
|
|
568
|
+
font-size: 11px;
|
|
569
|
+
color: var(--text-2);
|
|
570
|
+
font-family: var(--font-mono);
|
|
571
|
+
margin-top: 2px;
|
|
572
|
+
}
|
|
573
|
+
.metric-value.green { color: var(--green); }
|
|
574
|
+
.metric-value.red { color: var(--red); }
|
|
575
|
+
.metric-value.amber { color: var(--amber); }
|
|
576
|
+
|
|
577
|
+
.queue-section {
|
|
578
|
+
margin-top: 20px;
|
|
579
|
+
}
|
|
580
|
+
.queue-label {
|
|
581
|
+
font-size: 11px;
|
|
582
|
+
font-weight: 600;
|
|
583
|
+
text-transform: uppercase;
|
|
584
|
+
letter-spacing: 0.5px;
|
|
585
|
+
color: var(--text-2);
|
|
586
|
+
margin-bottom: 8px;
|
|
587
|
+
}
|
|
588
|
+
.queue-bar-wrap {
|
|
589
|
+
display: flex;
|
|
590
|
+
align-items: center;
|
|
591
|
+
gap: 12px;
|
|
592
|
+
margin-bottom: 8px;
|
|
593
|
+
}
|
|
594
|
+
.queue-bar {
|
|
595
|
+
flex: 1;
|
|
596
|
+
height: 4px;
|
|
597
|
+
background: var(--bg-3);
|
|
598
|
+
border-radius: 2px;
|
|
599
|
+
overflow: hidden;
|
|
600
|
+
}
|
|
601
|
+
.queue-bar-fill {
|
|
602
|
+
height: 100%;
|
|
603
|
+
border-radius: 2px;
|
|
604
|
+
background: var(--accent);
|
|
605
|
+
transition: width 0.3s;
|
|
606
|
+
}
|
|
607
|
+
.queue-bar-fill.warn { background: var(--amber); }
|
|
608
|
+
.queue-bar-fill.crit { background: var(--red); }
|
|
609
|
+
.queue-text {
|
|
610
|
+
font-size: 12px;
|
|
611
|
+
font-family: var(--font-mono);
|
|
612
|
+
color: var(--text-1);
|
|
613
|
+
min-width: 70px;
|
|
614
|
+
text-align: right;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.status-footer {
|
|
618
|
+
margin-top: 20px;
|
|
619
|
+
font-size: 11px;
|
|
620
|
+
color: var(--text-2);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.status-error {
|
|
624
|
+
color: var(--red);
|
|
625
|
+
padding: 20px;
|
|
626
|
+
font-size: 14px;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/* ── WebSocket ── */
|
|
630
|
+
.ws-controls {
|
|
631
|
+
display: flex;
|
|
632
|
+
align-items: center;
|
|
633
|
+
gap: 10px;
|
|
634
|
+
margin-bottom: 12px;
|
|
635
|
+
}
|
|
636
|
+
.ws-dot {
|
|
637
|
+
width: 8px;
|
|
638
|
+
height: 8px;
|
|
639
|
+
border-radius: 50%;
|
|
640
|
+
background: var(--text-2);
|
|
641
|
+
flex-shrink: 0;
|
|
642
|
+
transition: background 0.2s, box-shadow 0.2s;
|
|
643
|
+
}
|
|
644
|
+
.ws-dot.connected { background: var(--green); box-shadow: 0 0 6px rgba(74,222,128,0.4); }
|
|
645
|
+
.ws-dot.connecting { background: var(--amber); box-shadow: 0 0 6px rgba(251,191,36,0.3); }
|
|
646
|
+
.ws-label {
|
|
647
|
+
font-size: 12px;
|
|
648
|
+
color: var(--text-2);
|
|
649
|
+
font-family: var(--font-mono);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.ws-log {
|
|
653
|
+
flex: 1;
|
|
654
|
+
overflow-y: auto;
|
|
655
|
+
background: var(--bg-0);
|
|
656
|
+
border: 1px solid var(--border-subtle);
|
|
657
|
+
border-radius: var(--radius-sm);
|
|
658
|
+
padding: 12px 14px;
|
|
659
|
+
font-family: var(--font-mono);
|
|
660
|
+
font-size: 12px;
|
|
661
|
+
line-height: 1.6;
|
|
662
|
+
}
|
|
663
|
+
.ws-msg { margin-bottom: 2px; }
|
|
664
|
+
.ws-msg.sent { color: var(--accent); }
|
|
665
|
+
.ws-msg.received { color: var(--green); }
|
|
666
|
+
.ws-msg.system { color: var(--text-2); }
|
|
667
|
+
.ws-msg .ws-prefix {
|
|
668
|
+
display: inline-block;
|
|
669
|
+
width: 16px;
|
|
670
|
+
text-align: center;
|
|
671
|
+
margin-right: 6px;
|
|
672
|
+
opacity: 0.6;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.ws-input-wrap {
|
|
676
|
+
display: flex;
|
|
677
|
+
gap: 8px;
|
|
678
|
+
margin-top: 12px;
|
|
679
|
+
flex-shrink: 0;
|
|
680
|
+
}
|
|
681
|
+
.ws-input-wrap textarea {
|
|
682
|
+
min-height: 50px;
|
|
683
|
+
flex: 1;
|
|
684
|
+
}
|
|
685
|
+
.ws-input-wrap .btn {
|
|
686
|
+
align-self: flex-end;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/* ── Utilities ── */
|
|
690
|
+
.hidden { display: none !important; }
|
|
691
|
+
.gap-8 { gap: 8px; }
|
|
692
|
+
.mt-12 { margin-top: 12px; }
|
|
693
|
+
.mb-6 { margin-bottom: 6px; }
|
|
694
|
+
|
|
695
|
+
/* ── Skeleton loading ── */
|
|
696
|
+
@keyframes shimmer {
|
|
697
|
+
0% { opacity: 0.5; }
|
|
698
|
+
50% { opacity: 0.2; }
|
|
699
|
+
100% { opacity: 0.5; }
|
|
700
|
+
}
|
|
701
|
+
.skeleton {
|
|
702
|
+
background: var(--bg-3);
|
|
703
|
+
border-radius: 4px;
|
|
704
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
705
|
+
}
|
|
706
|
+
`;
|
|
707
|
+
}
|
|
708
|
+
function playgroundJS(port) {
|
|
709
|
+
return `
|
|
710
|
+
(function() {
|
|
711
|
+
var PORT = ${port};
|
|
712
|
+
var BASE = location.origin;
|
|
713
|
+
var WS_BASE = BASE.replace(/^http/, 'ws');
|
|
714
|
+
|
|
715
|
+
var ENDPOINTS = {
|
|
716
|
+
run: { method: 'POST', path: '/api/run' },
|
|
717
|
+
stream: { method: 'POST', path: '/api/stream' },
|
|
718
|
+
chat: { method: 'POST', path: '/v1/chat/completions' },
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
var state = {
|
|
722
|
+
activeTab: 'status',
|
|
723
|
+
apiKey: localStorage.getItem('otterly_api_key') || '',
|
|
724
|
+
ws: null,
|
|
725
|
+
wsConnected: false,
|
|
726
|
+
activeAbort: null,
|
|
727
|
+
statusInterval: null,
|
|
728
|
+
autoScroll: true,
|
|
729
|
+
history: [],
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
var $ = function(sel) { return document.querySelector(sel); };
|
|
733
|
+
var $$ = function(sel) { return document.querySelectorAll(sel); };
|
|
734
|
+
|
|
735
|
+
var chevronSvg = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
736
|
+
|
|
737
|
+
// ── Init ──
|
|
738
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
739
|
+
var keyInput = $('#api-key');
|
|
740
|
+
keyInput.value = state.apiKey;
|
|
741
|
+
keyInput.addEventListener('input', function(e) {
|
|
742
|
+
state.apiKey = e.target.value;
|
|
743
|
+
localStorage.setItem('otterly_api_key', state.apiKey);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
$$('.nav-item').forEach(function(btn) {
|
|
747
|
+
btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
$$('.section-toggle').forEach(function(h) {
|
|
751
|
+
h.addEventListener('click', function() {
|
|
752
|
+
h.classList.toggle('open');
|
|
753
|
+
var target = h.nextElementSibling;
|
|
754
|
+
if (target) target.classList.toggle('open');
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
$('#send-run').addEventListener('click', function() { sendRequest('run'); });
|
|
759
|
+
$('#send-stream').addEventListener('click', function() { sendStream(); });
|
|
760
|
+
$('#send-chat').addEventListener('click', function() { sendChat(); });
|
|
761
|
+
|
|
762
|
+
$$('.btn-cancel').forEach(function(btn) {
|
|
763
|
+
btn.addEventListener('click', cancelRequest);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Mode toggles (formatted/raw)
|
|
767
|
+
$$('.mode-toggle').forEach(function(group) {
|
|
768
|
+
group.querySelectorAll('button').forEach(function(btn) {
|
|
769
|
+
btn.addEventListener('click', function() {
|
|
770
|
+
group.querySelectorAll('button').forEach(function(b) { b.classList.remove('active'); });
|
|
771
|
+
btn.classList.add('active');
|
|
772
|
+
var panel = group.closest('.pane') || group.closest('.panel');
|
|
773
|
+
var formatted = panel.querySelector('.response-formatted');
|
|
774
|
+
var raw = panel.querySelector('.response-raw');
|
|
775
|
+
if (formatted && raw) {
|
|
776
|
+
var mode = btn.dataset.mode;
|
|
777
|
+
formatted.classList.toggle('hidden', mode !== 'formatted');
|
|
778
|
+
raw.classList.toggle('hidden', mode !== 'raw');
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// WebSocket
|
|
785
|
+
$('#ws-connect').addEventListener('click', wsConnect);
|
|
786
|
+
$('#ws-disconnect').addEventListener('click', wsDisconnect);
|
|
787
|
+
$('#ws-send').addEventListener('click', wsSend);
|
|
788
|
+
|
|
789
|
+
// Jump buttons
|
|
790
|
+
$$('.jump-btn').forEach(function(btn) {
|
|
791
|
+
btn.addEventListener('click', function() {
|
|
792
|
+
var container = btn.closest('.stream-area') || btn.closest('.ws-log');
|
|
793
|
+
if (container) {
|
|
794
|
+
container.scrollTop = container.scrollHeight;
|
|
795
|
+
state.autoScroll = true;
|
|
796
|
+
btn.style.display = 'none';
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
$$('.stream-area, .ws-log').forEach(function(el) {
|
|
802
|
+
el.addEventListener('scroll', function() {
|
|
803
|
+
var atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
|
804
|
+
state.autoScroll = atBottom;
|
|
805
|
+
var jumpBtn = el.querySelector('.jump-btn');
|
|
806
|
+
if (jumpBtn) jumpBtn.style.display = atBottom ? 'none' : 'block';
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
switchTab('status');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
function switchTab(tab) {
|
|
814
|
+
state.activeTab = tab;
|
|
815
|
+
$$('.nav-item').forEach(function(t) { t.classList.toggle('active', t.dataset.tab === tab); });
|
|
816
|
+
$$('.panel').forEach(function(p) { p.classList.toggle('active', p.id === 'panel-' + tab); });
|
|
817
|
+
|
|
818
|
+
if (tab === 'status') {
|
|
819
|
+
fetchStatus();
|
|
820
|
+
state.statusInterval = setInterval(fetchStatus, 5000);
|
|
821
|
+
} else if (state.statusInterval) {
|
|
822
|
+
clearInterval(state.statusInterval);
|
|
823
|
+
state.statusInterval = null;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function getHeaders() {
|
|
828
|
+
var h = { 'Content-Type': 'application/json' };
|
|
829
|
+
if (state.apiKey) h['Authorization'] = 'Bearer ' + state.apiKey;
|
|
830
|
+
var sid = $('#session-id-' + state.activeTab);
|
|
831
|
+
if (sid && sid.value) h['X-Session-Id'] = sid.value;
|
|
832
|
+
return h;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ── JSON syntax highlighting ──
|
|
836
|
+
function syntaxHighlight(json) {
|
|
837
|
+
if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
|
|
838
|
+
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
839
|
+
return json.replace(
|
|
840
|
+
/("(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\"])*"(\\s*:)?|\\b(true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g,
|
|
841
|
+
function(match) {
|
|
842
|
+
var cls = 'json-number';
|
|
843
|
+
if (/^"/.test(match)) {
|
|
844
|
+
if (/:$/.test(match)) cls = 'json-key';
|
|
845
|
+
else cls = 'json-string';
|
|
846
|
+
} else if (/true|false/.test(match)) {
|
|
847
|
+
cls = 'json-boolean';
|
|
848
|
+
} else if (/null/.test(match)) {
|
|
849
|
+
cls = 'json-null';
|
|
850
|
+
}
|
|
851
|
+
return '<span class="' + cls + '">' + match + '</span>';
|
|
852
|
+
}
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ── Status ──
|
|
857
|
+
var lastStatusData = null;
|
|
858
|
+
async function fetchStatus() {
|
|
859
|
+
try {
|
|
860
|
+
var res = await fetch(BASE + '/api/status');
|
|
861
|
+
var data = await res.json();
|
|
862
|
+
lastStatusData = data;
|
|
863
|
+
renderStatus(data);
|
|
864
|
+
} catch (err) {
|
|
865
|
+
$('#status-content').innerHTML = '<div class="status-error">Failed to connect: ' + escHtml(err.message) + '</div>';
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function renderStatus(d) {
|
|
870
|
+
var isOk = d.status === 'ok';
|
|
871
|
+
var cb = d.circuitBreaker || 'closed';
|
|
872
|
+
var cbClass = cb === 'closed' ? 'green' : cb === 'open' ? 'red' : 'amber';
|
|
873
|
+
var q = d.queue || {};
|
|
874
|
+
var activeP = q.maxConcurrent ? Math.round((q.active || 0) / q.maxConcurrent * 100) : 0;
|
|
875
|
+
var queuedP = q.maxQueueSize ? Math.round((q.queued || 0) / q.maxQueueSize * 100) : 0;
|
|
876
|
+
var activeBarClass = activeP > 80 ? 'crit' : activeP > 50 ? 'warn' : '';
|
|
877
|
+
var queuedBarClass = queuedP > 80 ? 'crit' : queuedP > 50 ? 'warn' : '';
|
|
878
|
+
|
|
879
|
+
$('#status-content').innerHTML =
|
|
880
|
+
'<div class="status-inner">' +
|
|
881
|
+
'<div class="status-hero">' +
|
|
882
|
+
'<div class="status-dot ' + (isOk ? 'green' : 'red') + '"></div>' +
|
|
883
|
+
'<div>' +
|
|
884
|
+
'<div class="status-headline">' + (isOk ? 'All systems operational' : 'System issue detected') + '</div>' +
|
|
885
|
+
'<div class="status-sub">v' + escHtml(d.version || '?') + '</div>' +
|
|
886
|
+
'</div>' +
|
|
887
|
+
'</div>' +
|
|
888
|
+
'<div class="metrics-row">' +
|
|
889
|
+
'<div class="metric"><div class="metric-value">' + escHtml(d.version || '?') + '</div><div class="metric-label">Version</div></div>' +
|
|
890
|
+
'<div class="metric"><div class="metric-value">' + (d.activeSessions || 0) + '</div><div class="metric-label">Sessions</div></div>' +
|
|
891
|
+
'<div class="metric"><div class="metric-value ' + cbClass + '">' + escHtml(cb) + '</div><div class="metric-label">Circuit</div></div>' +
|
|
892
|
+
'<div class="metric"><div class="metric-value">' + (q.active || 0) + '</div><div class="metric-label">Active</div><div class="metric-sub">/ ' + (q.maxConcurrent || '?') + ' max</div></div>' +
|
|
893
|
+
'<div class="metric"><div class="metric-value">' + (q.queued || 0) + '</div><div class="metric-label">Queued</div><div class="metric-sub">/ ' + (q.maxQueueSize || '?') + ' max</div></div>' +
|
|
894
|
+
'</div>' +
|
|
895
|
+
'<div class="queue-section">' +
|
|
896
|
+
'<div class="queue-label">Queue utilization</div>' +
|
|
897
|
+
'<div class="queue-bar-wrap">' +
|
|
898
|
+
'<div style="font-size:12px;color:var(--text-2);min-width:50px">Active</div>' +
|
|
899
|
+
'<div class="queue-bar"><div class="queue-bar-fill ' + activeBarClass + '" style="width:' + Math.max(activeP, 1) + '%"></div></div>' +
|
|
900
|
+
'<div class="queue-text">' + (q.active || 0) + ' / ' + (q.maxConcurrent || '?') + '</div>' +
|
|
901
|
+
'</div>' +
|
|
902
|
+
'<div class="queue-bar-wrap">' +
|
|
903
|
+
'<div style="font-size:12px;color:var(--text-2);min-width:50px">Waiting</div>' +
|
|
904
|
+
'<div class="queue-bar"><div class="queue-bar-fill ' + queuedBarClass + '" style="width:' + Math.max(queuedP, 1) + '%"></div></div>' +
|
|
905
|
+
'<div class="queue-text">' + (q.queued || 0) + ' / ' + (q.maxQueueSize || '?') + '</div>' +
|
|
906
|
+
'</div>' +
|
|
907
|
+
'</div>' +
|
|
908
|
+
'<div class="status-footer">Auto-refreshing every 5s</div>' +
|
|
909
|
+
'</div>';
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ── Run (one-shot) ──
|
|
913
|
+
async function sendRequest(tab) {
|
|
914
|
+
var bodyEl = $('#body-' + tab);
|
|
915
|
+
var resStatus = $('#res-status-' + tab);
|
|
916
|
+
var resFormatted = $('#res-formatted-' + tab);
|
|
917
|
+
var resRaw = $('#res-raw-' + tab);
|
|
918
|
+
var sendBtn = $('#send-' + tab);
|
|
919
|
+
var emptyEl = $('#res-empty-' + tab);
|
|
920
|
+
|
|
921
|
+
var body;
|
|
922
|
+
try { body = JSON.parse(bodyEl.value); } catch (e) {
|
|
923
|
+
resStatus.innerHTML = '<span class="status-pill err">JSON Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(e.message) + '</span>';
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
sendBtn.disabled = true;
|
|
928
|
+
if (emptyEl) emptyEl.classList.add('hidden');
|
|
929
|
+
resStatus.innerHTML = '<span class="status-pill pending">Pending</span>';
|
|
930
|
+
resFormatted.innerHTML = '';
|
|
931
|
+
resRaw.textContent = '';
|
|
932
|
+
|
|
933
|
+
var startTime = Date.now();
|
|
934
|
+
var controller = new AbortController();
|
|
935
|
+
state.activeAbort = controller;
|
|
936
|
+
|
|
937
|
+
try {
|
|
938
|
+
var ep = ENDPOINTS[tab];
|
|
939
|
+
var res = await fetch(BASE + ep.path, {
|
|
940
|
+
method: ep.method,
|
|
941
|
+
headers: getHeaders(),
|
|
942
|
+
body: JSON.stringify(body),
|
|
943
|
+
signal: controller.signal,
|
|
944
|
+
});
|
|
945
|
+
var duration = Date.now() - startTime;
|
|
946
|
+
var raw = await res.text();
|
|
947
|
+
var parsed;
|
|
948
|
+
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
|
949
|
+
|
|
950
|
+
var ok = res.status >= 200 && res.status < 300;
|
|
951
|
+
resStatus.innerHTML =
|
|
952
|
+
'<span class="status-pill ' + (ok ? 'ok' : 'err') + '">' + res.status + '</span>' +
|
|
953
|
+
'<span class="res-duration">' + duration + 'ms</span>';
|
|
954
|
+
|
|
955
|
+
if (typeof parsed === 'object') {
|
|
956
|
+
resFormatted.innerHTML = syntaxHighlight(parsed);
|
|
957
|
+
} else {
|
|
958
|
+
resFormatted.textContent = raw;
|
|
959
|
+
}
|
|
960
|
+
resRaw.textContent = raw;
|
|
961
|
+
|
|
962
|
+
var newSid = res.headers.get('X-Session-Id');
|
|
963
|
+
var sidInput = $('#session-id-' + tab);
|
|
964
|
+
if (newSid && sidInput) sidInput.value = newSid;
|
|
965
|
+
} catch (err) {
|
|
966
|
+
if (err.name === 'AbortError') {
|
|
967
|
+
resStatus.innerHTML = '<span class="status-pill cancelled">Cancelled</span>';
|
|
968
|
+
} else {
|
|
969
|
+
resStatus.innerHTML = '<span class="status-pill err">Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(err.message) + '</span>';
|
|
970
|
+
}
|
|
971
|
+
} finally {
|
|
972
|
+
sendBtn.disabled = false;
|
|
973
|
+
state.activeAbort = null;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ── Stream (NDJSON) ──
|
|
978
|
+
async function sendStream() {
|
|
979
|
+
var bodyEl = $('#body-stream');
|
|
980
|
+
var output = $('#stream-output');
|
|
981
|
+
var textArea = $('#stream-text');
|
|
982
|
+
var rawArea = $('#stream-raw');
|
|
983
|
+
var summary = $('#stream-summary');
|
|
984
|
+
var sendBtn = $('#send-stream');
|
|
985
|
+
var rawToggle = $('#stream-raw-toggle');
|
|
986
|
+
|
|
987
|
+
var body;
|
|
988
|
+
try { body = JSON.parse(bodyEl.value); } catch (e) {
|
|
989
|
+
output.classList.remove('hidden');
|
|
990
|
+
textArea.textContent = 'JSON parse error: ' + e.message;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
sendBtn.disabled = true;
|
|
995
|
+
output.classList.remove('hidden');
|
|
996
|
+
textArea.innerHTML = '<span class="cursor-blink"></span>';
|
|
997
|
+
rawArea.textContent = '';
|
|
998
|
+
summary.classList.add('hidden');
|
|
999
|
+
state.autoScroll = true;
|
|
1000
|
+
|
|
1001
|
+
var controller = new AbortController();
|
|
1002
|
+
state.activeAbort = controller;
|
|
1003
|
+
var showRaw = false;
|
|
1004
|
+
rawToggle.textContent = 'Raw';
|
|
1005
|
+
rawToggle.onclick = function() {
|
|
1006
|
+
showRaw = !showRaw;
|
|
1007
|
+
rawToggle.textContent = showRaw ? 'Formatted' : 'Raw';
|
|
1008
|
+
textArea.classList.toggle('hidden', showRaw);
|
|
1009
|
+
rawArea.classList.toggle('hidden', !showRaw);
|
|
1010
|
+
};
|
|
1011
|
+
rawArea.classList.add('hidden');
|
|
1012
|
+
|
|
1013
|
+
var fullText = '';
|
|
1014
|
+
|
|
1015
|
+
try {
|
|
1016
|
+
var res = await fetch(BASE + '/api/stream', {
|
|
1017
|
+
method: 'POST',
|
|
1018
|
+
headers: getHeaders(),
|
|
1019
|
+
body: JSON.stringify(body),
|
|
1020
|
+
signal: controller.signal,
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
var reader = res.body.getReader();
|
|
1024
|
+
var decoder = new TextDecoder();
|
|
1025
|
+
var buffer = '';
|
|
1026
|
+
|
|
1027
|
+
while (true) {
|
|
1028
|
+
var chunk = await reader.read();
|
|
1029
|
+
if (chunk.done) break;
|
|
1030
|
+
|
|
1031
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
1032
|
+
var lines = buffer.split('\\n');
|
|
1033
|
+
buffer = lines.pop();
|
|
1034
|
+
|
|
1035
|
+
for (var i = 0; i < lines.length; i++) {
|
|
1036
|
+
var line = lines[i];
|
|
1037
|
+
if (!line.trim()) continue;
|
|
1038
|
+
rawArea.textContent += line + '\\n';
|
|
1039
|
+
|
|
1040
|
+
var event;
|
|
1041
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
1042
|
+
|
|
1043
|
+
if (event.type === 'text_delta') {
|
|
1044
|
+
fullText += event.text || '';
|
|
1045
|
+
textArea.innerHTML = escHtml(fullText) + '<span class="cursor-blink"></span>';
|
|
1046
|
+
} else if (event.type === 'tool_use') {
|
|
1047
|
+
var block = makeToolBlock(event.name || 'tool_use', JSON.stringify(event.input || {}, null, 2));
|
|
1048
|
+
textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
|
|
1049
|
+
} else if (event.type === 'tool_result') {
|
|
1050
|
+
var content = typeof event.content === 'string' ? event.content : JSON.stringify(event.content, null, 2);
|
|
1051
|
+
var block2 = makeToolBlock('Result' + (event.isError ? ' (error)' : ''), content, event.isError ? 'result-err' : 'result-ok');
|
|
1052
|
+
textArea.insertBefore(block2, textArea.querySelector('.cursor-blink'));
|
|
1053
|
+
} else if (event.type === 'result') {
|
|
1054
|
+
var cur = textArea.querySelector('.cursor-blink');
|
|
1055
|
+
if (cur) cur.remove();
|
|
1056
|
+
summary.classList.remove('hidden');
|
|
1057
|
+
summary.innerHTML =
|
|
1058
|
+
'<span><span class="label">Cost</span>$' + (event.cost || 0).toFixed(4) + '</span>' +
|
|
1059
|
+
'<span><span class="label">Duration</span>' + (event.duration || 0) + 'ms</span>' +
|
|
1060
|
+
(event.usage ? '<span><span class="label">Tokens</span>' + (event.usage.inputTokens || 0) + ' in / ' + (event.usage.outputTokens || 0) + ' out</span>' : '');
|
|
1061
|
+
var sidInput = $('#session-id-stream');
|
|
1062
|
+
if (event.sessionId && sidInput) sidInput.value = event.sessionId;
|
|
1063
|
+
} else if (event.type === 'session_init' && event.sessionId) {
|
|
1064
|
+
var sidInput2 = $('#session-id-stream');
|
|
1065
|
+
if (sidInput2) sidInput2.value = event.sessionId;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (state.autoScroll) output.scrollTop = output.scrollHeight;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
if (err.name !== 'AbortError') {
|
|
1073
|
+
textArea.innerHTML = fullText + '\\n<span style="color:var(--red)">Error: ' + escHtml(err.message) + '</span>';
|
|
1074
|
+
} else {
|
|
1075
|
+
var cur2 = textArea.querySelector('.cursor-blink');
|
|
1076
|
+
if (cur2) cur2.remove();
|
|
1077
|
+
textArea.innerHTML += '\\n<span style="color:var(--text-2)">[Cancelled]</span>';
|
|
1078
|
+
}
|
|
1079
|
+
} finally {
|
|
1080
|
+
sendBtn.disabled = false;
|
|
1081
|
+
state.activeAbort = null;
|
|
1082
|
+
var cur3 = textArea.querySelector('.cursor-blink');
|
|
1083
|
+
if (cur3) cur3.remove();
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function makeToolBlock(name, content, extraClass) {
|
|
1088
|
+
var block = document.createElement('div');
|
|
1089
|
+
block.className = 'tool-block' + (extraClass ? ' ' + extraClass : '');
|
|
1090
|
+
block.innerHTML =
|
|
1091
|
+
'<div class="tool-block-head">' + chevronSvg + ' ' + escHtml(name) + '</div>' +
|
|
1092
|
+
'<div class="tool-block-body"><pre>' + escHtml(content) + '</pre></div>';
|
|
1093
|
+
block.querySelector('.tool-block-head').onclick = function() { block.classList.toggle('open'); };
|
|
1094
|
+
return block;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ── Chat (OpenAI) ──
|
|
1098
|
+
async function sendChat() {
|
|
1099
|
+
var bodyEl = $('#body-chat');
|
|
1100
|
+
var sendBtn = $('#send-chat');
|
|
1101
|
+
|
|
1102
|
+
var body;
|
|
1103
|
+
try { body = JSON.parse(bodyEl.value); } catch (e) {
|
|
1104
|
+
$('#res-status-chat').innerHTML = '<span class="status-pill err">JSON Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(e.message) + '</span>';
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (body.stream === true) {
|
|
1109
|
+
await sendChatStream(body, sendBtn);
|
|
1110
|
+
} else {
|
|
1111
|
+
sendBtn.disabled = true;
|
|
1112
|
+
var resStatus = $('#res-status-chat');
|
|
1113
|
+
var resFormatted = $('#res-formatted-chat');
|
|
1114
|
+
var resRaw = $('#res-raw-chat');
|
|
1115
|
+
var emptyEl = $('#res-empty-chat');
|
|
1116
|
+
resStatus.innerHTML = '<span class="status-pill pending">Pending</span>';
|
|
1117
|
+
resFormatted.innerHTML = '';
|
|
1118
|
+
resRaw.textContent = '';
|
|
1119
|
+
if (emptyEl) emptyEl.classList.add('hidden');
|
|
1120
|
+
|
|
1121
|
+
$('#chat-response-standard').classList.remove('hidden');
|
|
1122
|
+
$('#chat-stream-output').classList.add('hidden');
|
|
1123
|
+
|
|
1124
|
+
var startTime = Date.now();
|
|
1125
|
+
var controller = new AbortController();
|
|
1126
|
+
state.activeAbort = controller;
|
|
1127
|
+
|
|
1128
|
+
try {
|
|
1129
|
+
var res = await fetch(BASE + '/v1/chat/completions', {
|
|
1130
|
+
method: 'POST',
|
|
1131
|
+
headers: getHeaders(),
|
|
1132
|
+
body: JSON.stringify(body),
|
|
1133
|
+
signal: controller.signal,
|
|
1134
|
+
});
|
|
1135
|
+
var duration = Date.now() - startTime;
|
|
1136
|
+
var raw = await res.text();
|
|
1137
|
+
var parsed;
|
|
1138
|
+
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
|
1139
|
+
|
|
1140
|
+
var ok = res.status >= 200 && res.status < 300;
|
|
1141
|
+
resStatus.innerHTML =
|
|
1142
|
+
'<span class="status-pill ' + (ok ? 'ok' : 'err') + '">' + res.status + '</span>' +
|
|
1143
|
+
'<span class="res-duration">' + duration + 'ms</span>';
|
|
1144
|
+
|
|
1145
|
+
if (typeof parsed === 'object') {
|
|
1146
|
+
resFormatted.innerHTML = syntaxHighlight(parsed);
|
|
1147
|
+
} else {
|
|
1148
|
+
resFormatted.textContent = raw;
|
|
1149
|
+
}
|
|
1150
|
+
resRaw.textContent = raw;
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
if (err.name === 'AbortError') {
|
|
1153
|
+
resStatus.innerHTML = '<span class="status-pill cancelled">Cancelled</span>';
|
|
1154
|
+
} else {
|
|
1155
|
+
resStatus.innerHTML = '<span class="status-pill err">Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(err.message) + '</span>';
|
|
1156
|
+
}
|
|
1157
|
+
} finally {
|
|
1158
|
+
sendBtn.disabled = false;
|
|
1159
|
+
state.activeAbort = null;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function sendChatStream(body, sendBtn) {
|
|
1165
|
+
var output = $('#chat-stream-output');
|
|
1166
|
+
var textArea = $('#chat-stream-text');
|
|
1167
|
+
var summary = $('#chat-stream-summary');
|
|
1168
|
+
|
|
1169
|
+
sendBtn.disabled = true;
|
|
1170
|
+
$('#chat-response-standard').classList.add('hidden');
|
|
1171
|
+
output.classList.remove('hidden');
|
|
1172
|
+
textArea.innerHTML = '<span class="cursor-blink"></span>';
|
|
1173
|
+
summary.classList.add('hidden');
|
|
1174
|
+
state.autoScroll = true;
|
|
1175
|
+
|
|
1176
|
+
var controller = new AbortController();
|
|
1177
|
+
state.activeAbort = controller;
|
|
1178
|
+
var fullText = '';
|
|
1179
|
+
var startTime = Date.now();
|
|
1180
|
+
|
|
1181
|
+
try {
|
|
1182
|
+
var res = await fetch(BASE + '/v1/chat/completions', {
|
|
1183
|
+
method: 'POST',
|
|
1184
|
+
headers: getHeaders(),
|
|
1185
|
+
body: JSON.stringify(body),
|
|
1186
|
+
signal: controller.signal,
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
var reader = res.body.getReader();
|
|
1190
|
+
var decoder = new TextDecoder();
|
|
1191
|
+
var buffer = '';
|
|
1192
|
+
|
|
1193
|
+
while (true) {
|
|
1194
|
+
var chunk = await reader.read();
|
|
1195
|
+
if (chunk.done) break;
|
|
1196
|
+
|
|
1197
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
1198
|
+
var lines = buffer.split('\\n');
|
|
1199
|
+
buffer = lines.pop();
|
|
1200
|
+
|
|
1201
|
+
for (var j = 0; j < lines.length; j++) {
|
|
1202
|
+
var line = lines[j];
|
|
1203
|
+
if (!line.startsWith('data: ')) continue;
|
|
1204
|
+
var data = line.slice(6);
|
|
1205
|
+
if (data === '[DONE]') {
|
|
1206
|
+
var cur = textArea.querySelector('.cursor-blink');
|
|
1207
|
+
if (cur) cur.remove();
|
|
1208
|
+
var duration = Date.now() - startTime;
|
|
1209
|
+
summary.classList.remove('hidden');
|
|
1210
|
+
summary.innerHTML = '<span><span class="label">Duration</span>' + duration + 'ms</span><span>Stream complete</span>';
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
var event;
|
|
1215
|
+
try { event = JSON.parse(data); } catch { continue; }
|
|
1216
|
+
|
|
1217
|
+
var delta = event.choices && event.choices[0] && event.choices[0].delta;
|
|
1218
|
+
if (delta && delta.content) {
|
|
1219
|
+
fullText += delta.content;
|
|
1220
|
+
textArea.innerHTML = escHtml(fullText) + '<span class="cursor-blink"></span>';
|
|
1221
|
+
}
|
|
1222
|
+
if (delta && delta.tool_calls) {
|
|
1223
|
+
for (var k = 0; k < delta.tool_calls.length; k++) {
|
|
1224
|
+
var tc = delta.tool_calls[k];
|
|
1225
|
+
if (tc.function && tc.function.name) {
|
|
1226
|
+
var block = makeToolBlock(tc.function.name, tc.function.arguments || '');
|
|
1227
|
+
textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (state.autoScroll) output.scrollTop = output.scrollHeight;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (err.name !== 'AbortError') {
|
|
1237
|
+
textArea.innerHTML = fullText + '\\n<span style="color:var(--red)">Error: ' + escHtml(err.message) + '</span>';
|
|
1238
|
+
} else {
|
|
1239
|
+
var cur2 = textArea.querySelector('.cursor-blink');
|
|
1240
|
+
if (cur2) cur2.remove();
|
|
1241
|
+
textArea.innerHTML += '\\n<span style="color:var(--text-2)">[Cancelled]</span>';
|
|
1242
|
+
}
|
|
1243
|
+
} finally {
|
|
1244
|
+
sendBtn.disabled = false;
|
|
1245
|
+
state.activeAbort = null;
|
|
1246
|
+
var cur3 = textArea.querySelector('.cursor-blink');
|
|
1247
|
+
if (cur3) cur3.remove();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// ── WebSocket ──
|
|
1252
|
+
function wsConnect() {
|
|
1253
|
+
if (state.ws) return;
|
|
1254
|
+
var dot = $('#ws-dot');
|
|
1255
|
+
|
|
1256
|
+
dot.className = 'ws-dot connecting';
|
|
1257
|
+
wsLog('Connecting to ' + WS_BASE + '/ws ...', 'system');
|
|
1258
|
+
|
|
1259
|
+
var ws = new WebSocket(WS_BASE + '/ws');
|
|
1260
|
+
state.ws = ws;
|
|
1261
|
+
|
|
1262
|
+
ws.onopen = function() {
|
|
1263
|
+
state.wsConnected = true;
|
|
1264
|
+
dot.className = 'ws-dot connected';
|
|
1265
|
+
wsLog('Connected', 'system');
|
|
1266
|
+
$('#ws-connect').disabled = true;
|
|
1267
|
+
$('#ws-disconnect').disabled = false;
|
|
1268
|
+
$('#ws-send').disabled = false;
|
|
1269
|
+
$('#ws-label').textContent = 'Connected';
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
ws.onmessage = function(e) {
|
|
1273
|
+
var display;
|
|
1274
|
+
try { display = JSON.stringify(JSON.parse(e.data), null, 2); }
|
|
1275
|
+
catch { display = e.data; }
|
|
1276
|
+
wsLog(display, 'received');
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
ws.onerror = function() { wsLog('Connection error', 'system'); };
|
|
1280
|
+
|
|
1281
|
+
ws.onclose = function(e) {
|
|
1282
|
+
state.ws = null;
|
|
1283
|
+
state.wsConnected = false;
|
|
1284
|
+
dot.className = 'ws-dot';
|
|
1285
|
+
wsLog('Disconnected (code: ' + e.code + ')', 'system');
|
|
1286
|
+
$('#ws-connect').disabled = false;
|
|
1287
|
+
$('#ws-disconnect').disabled = true;
|
|
1288
|
+
$('#ws-send').disabled = true;
|
|
1289
|
+
$('#ws-label').textContent = 'Disconnected';
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function wsDisconnect() {
|
|
1294
|
+
if (state.ws) state.ws.close();
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function wsSend() {
|
|
1298
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
|
|
1299
|
+
var input = $('#ws-input');
|
|
1300
|
+
var msg = input.value.trim();
|
|
1301
|
+
if (!msg) return;
|
|
1302
|
+
state.ws.send(msg);
|
|
1303
|
+
wsLog(msg, 'sent');
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function wsLog(text, type) {
|
|
1307
|
+
var log = $('#ws-log');
|
|
1308
|
+
var div = document.createElement('div');
|
|
1309
|
+
div.className = 'ws-msg ' + type;
|
|
1310
|
+
var prefix = type === 'sent' ? '\\u25B6' : type === 'received' ? '\\u25C0' : '\\u2022';
|
|
1311
|
+
div.innerHTML = '<span class="ws-prefix">' + prefix + '</span>' + escHtml(text);
|
|
1312
|
+
log.appendChild(div);
|
|
1313
|
+
log.scrollTop = log.scrollHeight;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ── Utilities ──
|
|
1317
|
+
function cancelRequest() {
|
|
1318
|
+
if (state.activeAbort) {
|
|
1319
|
+
state.activeAbort.abort();
|
|
1320
|
+
state.activeAbort = null;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function escHtml(str) {
|
|
1325
|
+
if (typeof str !== 'string') str = String(str);
|
|
1326
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1327
|
+
}
|
|
1328
|
+
})();
|
|
1329
|
+
`;
|
|
1330
|
+
}
|
|
1331
|
+
function playgroundHTML() {
|
|
1332
|
+
return `
|
|
1333
|
+
<div class="app">
|
|
1334
|
+
<header class="header">
|
|
1335
|
+
<div class="header-left">
|
|
1336
|
+
<span class="logo">\u{1F9A6}</span>
|
|
1337
|
+
<span class="logo-text">otterly</span>
|
|
1338
|
+
<span class="version-badge">v0.3.1</span>
|
|
1339
|
+
</div>
|
|
1340
|
+
<div class="header-center">
|
|
1341
|
+
<nav class="nav">
|
|
1342
|
+
<button class="nav-item active" data-tab="status">Status</button>
|
|
1343
|
+
<button class="nav-item" data-tab="run">Run</button>
|
|
1344
|
+
<button class="nav-item" data-tab="stream">Stream</button>
|
|
1345
|
+
<button class="nav-item" data-tab="chat">Chat</button>
|
|
1346
|
+
<button class="nav-item" data-tab="ws">WebSocket</button>
|
|
1347
|
+
</nav>
|
|
1348
|
+
</div>
|
|
1349
|
+
<div class="header-right">
|
|
1350
|
+
<div class="key-wrapper">
|
|
1351
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
1352
|
+
<input type="password" id="api-key" class="key-input" placeholder="API Key">
|
|
1353
|
+
</div>
|
|
1354
|
+
</div>
|
|
1355
|
+
</header>
|
|
1356
|
+
|
|
1357
|
+
<div class="content">
|
|
1358
|
+
<!-- ── Status ── -->
|
|
1359
|
+
<div class="panel active" id="panel-status">
|
|
1360
|
+
<div class="status-panel" id="status-content">
|
|
1361
|
+
<div class="status-inner">
|
|
1362
|
+
<div class="status-hero">
|
|
1363
|
+
<div class="skeleton" style="width:10px;height:10px;border-radius:50%"></div>
|
|
1364
|
+
<div><div class="skeleton" style="width:180px;height:18px;margin-bottom:4px"></div><div class="skeleton" style="width:60px;height:14px"></div></div>
|
|
1365
|
+
</div>
|
|
1366
|
+
<div class="skeleton" style="width:100%;height:100px;border-radius:8px"></div>
|
|
1367
|
+
</div>
|
|
1368
|
+
</div>
|
|
1369
|
+
</div>
|
|
1370
|
+
|
|
1371
|
+
<!-- ── Run ── -->
|
|
1372
|
+
<div class="panel" id="panel-run">
|
|
1373
|
+
<div class="split-pane">
|
|
1374
|
+
<div class="pane">
|
|
1375
|
+
<div class="pane-head">
|
|
1376
|
+
<span class="method-badge method-post">POST</span>
|
|
1377
|
+
<span class="endpoint-path">/api/run</span>
|
|
1378
|
+
</div>
|
|
1379
|
+
<div class="pane-body">
|
|
1380
|
+
<button class="section-toggle" type="button">
|
|
1381
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
1382
|
+
Headers
|
|
1383
|
+
</button>
|
|
1384
|
+
<div class="collapsible">
|
|
1385
|
+
<div class="field-row">
|
|
1386
|
+
<span class="label-inline">X-Session-Id</span>
|
|
1387
|
+
<input type="text" id="session-id-run" placeholder="auto from response">
|
|
1388
|
+
</div>
|
|
1389
|
+
</div>
|
|
1390
|
+
<div class="field-label">Request body</div>
|
|
1391
|
+
<textarea id="body-run"></textarea>
|
|
1392
|
+
<div class="actions">
|
|
1393
|
+
<button class="btn btn-primary" id="send-run">
|
|
1394
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
1395
|
+
Send
|
|
1396
|
+
</button>
|
|
1397
|
+
<button class="btn btn-ghost btn-cancel">Cancel</button>
|
|
1398
|
+
</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
</div>
|
|
1401
|
+
<div class="pane">
|
|
1402
|
+
<div class="pane-head">
|
|
1403
|
+
<span style="font-size:13px;font-weight:500;color:var(--text-1)">Response</span>
|
|
1404
|
+
<div class="pane-head-right">
|
|
1405
|
+
<div id="res-status-run" class="res-status"></div>
|
|
1406
|
+
<div class="mode-toggle">
|
|
1407
|
+
<button class="active" data-mode="formatted">Formatted</button>
|
|
1408
|
+
<button data-mode="raw">Raw</button>
|
|
1409
|
+
</div>
|
|
1410
|
+
</div>
|
|
1411
|
+
</div>
|
|
1412
|
+
<div class="pane-body">
|
|
1413
|
+
<div id="res-empty-run" class="code-output empty-state">Send a request to see the response</div>
|
|
1414
|
+
<pre id="res-formatted-run" class="code-output hidden response-formatted"></pre>
|
|
1415
|
+
<pre id="res-raw-run" class="code-output hidden response-raw"></pre>
|
|
1416
|
+
</div>
|
|
1417
|
+
</div>
|
|
1418
|
+
</div>
|
|
1419
|
+
</div>
|
|
1420
|
+
|
|
1421
|
+
<!-- ── Stream ── -->
|
|
1422
|
+
<div class="panel" id="panel-stream">
|
|
1423
|
+
<div class="split-pane">
|
|
1424
|
+
<div class="pane">
|
|
1425
|
+
<div class="pane-head">
|
|
1426
|
+
<span class="method-badge method-post">POST</span>
|
|
1427
|
+
<span class="endpoint-path">/api/stream</span>
|
|
1428
|
+
</div>
|
|
1429
|
+
<div class="pane-body">
|
|
1430
|
+
<button class="section-toggle" type="button">
|
|
1431
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
1432
|
+
Headers
|
|
1433
|
+
</button>
|
|
1434
|
+
<div class="collapsible">
|
|
1435
|
+
<div class="field-row">
|
|
1436
|
+
<span class="label-inline">X-Session-Id</span>
|
|
1437
|
+
<input type="text" id="session-id-stream" placeholder="auto from response">
|
|
1438
|
+
</div>
|
|
1439
|
+
</div>
|
|
1440
|
+
<div class="field-label">Request body</div>
|
|
1441
|
+
<textarea id="body-stream"></textarea>
|
|
1442
|
+
<div class="actions">
|
|
1443
|
+
<button class="btn btn-primary" id="send-stream">
|
|
1444
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
1445
|
+
Send
|
|
1446
|
+
</button>
|
|
1447
|
+
<button class="btn btn-ghost btn-cancel">Cancel</button>
|
|
1448
|
+
<button class="btn btn-ghost btn-sm" id="stream-raw-toggle">Raw</button>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1451
|
+
</div>
|
|
1452
|
+
<div class="pane">
|
|
1453
|
+
<div class="pane-head">
|
|
1454
|
+
<span style="font-size:13px;font-weight:500;color:var(--text-1)">Stream output</span>
|
|
1455
|
+
</div>
|
|
1456
|
+
<div class="pane-body">
|
|
1457
|
+
<div class="stream-area hidden" id="stream-output">
|
|
1458
|
+
<div id="stream-text"></div>
|
|
1459
|
+
<pre id="stream-raw" class="hidden"></pre>
|
|
1460
|
+
<button class="jump-btn">Jump to bottom</button>
|
|
1461
|
+
</div>
|
|
1462
|
+
<div id="stream-empty" class="code-output empty-state">Send a request to start streaming</div>
|
|
1463
|
+
<div class="summary-bar hidden" id="stream-summary"></div>
|
|
1464
|
+
</div>
|
|
1465
|
+
</div>
|
|
1466
|
+
</div>
|
|
1467
|
+
</div>
|
|
1468
|
+
|
|
1469
|
+
<!-- ── Chat ── -->
|
|
1470
|
+
<div class="panel" id="panel-chat">
|
|
1471
|
+
<div class="split-pane">
|
|
1472
|
+
<div class="pane">
|
|
1473
|
+
<div class="pane-head">
|
|
1474
|
+
<span class="method-badge method-post">POST</span>
|
|
1475
|
+
<span class="endpoint-path">/v1/chat/completions</span>
|
|
1476
|
+
</div>
|
|
1477
|
+
<div class="pane-body">
|
|
1478
|
+
<button class="section-toggle" type="button">
|
|
1479
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
1480
|
+
Headers
|
|
1481
|
+
</button>
|
|
1482
|
+
<div class="collapsible">
|
|
1483
|
+
<div class="field-row">
|
|
1484
|
+
<span class="label-inline">X-Session-Id</span>
|
|
1485
|
+
<input type="text" id="session-id-chat" placeholder="auto from response">
|
|
1486
|
+
</div>
|
|
1487
|
+
</div>
|
|
1488
|
+
<div class="field-label">Request body</div>
|
|
1489
|
+
<textarea id="body-chat"></textarea>
|
|
1490
|
+
<div class="actions">
|
|
1491
|
+
<button class="btn btn-primary" id="send-chat">
|
|
1492
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
1493
|
+
Send
|
|
1494
|
+
</button>
|
|
1495
|
+
<button class="btn btn-ghost btn-cancel">Cancel</button>
|
|
1496
|
+
</div>
|
|
1497
|
+
</div>
|
|
1498
|
+
</div>
|
|
1499
|
+
<div class="pane">
|
|
1500
|
+
<div class="pane-head">
|
|
1501
|
+
<span style="font-size:13px;font-weight:500;color:var(--text-1)">Response</span>
|
|
1502
|
+
<div class="pane-head-right">
|
|
1503
|
+
<div id="res-status-chat" class="res-status"></div>
|
|
1504
|
+
<div class="mode-toggle">
|
|
1505
|
+
<button class="active" data-mode="formatted">Formatted</button>
|
|
1506
|
+
<button data-mode="raw">Raw</button>
|
|
1507
|
+
</div>
|
|
1508
|
+
</div>
|
|
1509
|
+
</div>
|
|
1510
|
+
<div class="pane-body">
|
|
1511
|
+
<div id="chat-response-standard">
|
|
1512
|
+
<div id="res-empty-chat" class="code-output empty-state">Send a request to see the response</div>
|
|
1513
|
+
<pre id="res-formatted-chat" class="code-output hidden response-formatted"></pre>
|
|
1514
|
+
<pre id="res-raw-chat" class="code-output hidden response-raw"></pre>
|
|
1515
|
+
</div>
|
|
1516
|
+
<div class="stream-area hidden" id="chat-stream-output">
|
|
1517
|
+
<div id="chat-stream-text"></div>
|
|
1518
|
+
<button class="jump-btn">Jump to bottom</button>
|
|
1519
|
+
</div>
|
|
1520
|
+
<div class="summary-bar hidden" id="chat-stream-summary"></div>
|
|
1521
|
+
</div>
|
|
1522
|
+
</div>
|
|
1523
|
+
</div>
|
|
1524
|
+
</div>
|
|
1525
|
+
|
|
1526
|
+
<!-- ── WebSocket ── -->
|
|
1527
|
+
<div class="panel" id="panel-ws">
|
|
1528
|
+
<div class="split-pane">
|
|
1529
|
+
<div class="pane">
|
|
1530
|
+
<div class="pane-head">
|
|
1531
|
+
<span class="method-badge method-ws">WS</span>
|
|
1532
|
+
<span class="endpoint-path">/ws</span>
|
|
1533
|
+
</div>
|
|
1534
|
+
<div class="pane-body">
|
|
1535
|
+
<div class="ws-controls">
|
|
1536
|
+
<span class="ws-dot" id="ws-dot"></span>
|
|
1537
|
+
<span class="ws-label" id="ws-label">Disconnected</span>
|
|
1538
|
+
<div style="margin-left:auto;display:flex;gap:6px">
|
|
1539
|
+
<button class="btn btn-primary btn-sm" id="ws-connect">Connect</button>
|
|
1540
|
+
<button class="btn btn-ghost btn-sm" id="ws-disconnect" disabled>Disconnect</button>
|
|
1541
|
+
</div>
|
|
1542
|
+
</div>
|
|
1543
|
+
<div class="field-label mt-12">Message</div>
|
|
1544
|
+
<textarea id="ws-input" style="min-height:80px"></textarea>
|
|
1545
|
+
<div class="actions">
|
|
1546
|
+
<button class="btn btn-primary" id="ws-send" disabled>
|
|
1547
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
1548
|
+
Send
|
|
1549
|
+
</button>
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
</div>
|
|
1553
|
+
<div class="pane">
|
|
1554
|
+
<div class="pane-head">
|
|
1555
|
+
<span style="font-size:13px;font-weight:500;color:var(--text-1)">Messages</span>
|
|
1556
|
+
</div>
|
|
1557
|
+
<div class="pane-body">
|
|
1558
|
+
<div class="ws-log" id="ws-log" style="flex:1"></div>
|
|
1559
|
+
</div>
|
|
1560
|
+
</div>
|
|
1561
|
+
</div>
|
|
1562
|
+
</div>
|
|
1563
|
+
|
|
1564
|
+
</div>
|
|
1565
|
+
</div>
|
|
1566
|
+
`;
|
|
1567
|
+
}
|
|
1568
|
+
export function getPlaygroundHtml(port) {
|
|
1569
|
+
const templateScript = `
|
|
1570
|
+
<script>
|
|
1571
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
1572
|
+
var T = {
|
|
1573
|
+
run: ${JSON.stringify(JSON.stringify({ prompt: "What is 2 + 2?", options: { maxTurns: 1 } }, null, 2))},
|
|
1574
|
+
stream: ${JSON.stringify(JSON.stringify({ prompt: "Write a haiku about coding", options: { maxTurns: 1 } }, null, 2))},
|
|
1575
|
+
chat: ${JSON.stringify(JSON.stringify({ model: "claude-sonnet-4-20250514", messages: [{ role: "user", content: "Hello! What can you do?" }], stream: false }, null, 2))},
|
|
1576
|
+
ws: ${JSON.stringify(JSON.stringify({ type: "start", prompt: "Hello from WebSocket" }, null, 2))}
|
|
1577
|
+
};
|
|
1578
|
+
var el;
|
|
1579
|
+
el = document.getElementById('body-run'); if (el) el.value = T.run;
|
|
1580
|
+
el = document.getElementById('body-stream'); if (el) el.value = T.stream;
|
|
1581
|
+
el = document.getElementById('body-chat'); if (el) el.value = T.chat;
|
|
1582
|
+
el = document.getElementById('ws-input'); if (el) el.value = T.ws;
|
|
1583
|
+
|
|
1584
|
+
// Hide empty states when content appears
|
|
1585
|
+
document.querySelectorAll('.code-output.empty-state').forEach(function(empty) {
|
|
1586
|
+
var observer = new MutationObserver(function() {
|
|
1587
|
+
var panel = empty.closest('.pane-body');
|
|
1588
|
+
if (!panel) return;
|
|
1589
|
+
var formatted = panel.querySelector('.response-formatted');
|
|
1590
|
+
var raw = panel.querySelector('.response-raw');
|
|
1591
|
+
if ((formatted && formatted.innerHTML) || (raw && raw.textContent)) {
|
|
1592
|
+
empty.classList.add('hidden');
|
|
1593
|
+
if (formatted) formatted.classList.remove('hidden');
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
var panel = empty.closest('.pane-body');
|
|
1597
|
+
if (panel) observer.observe(panel, { childList: true, subtree: true, characterData: true });
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
// Hide stream empty state when streaming starts
|
|
1601
|
+
var streamEmpty = document.getElementById('stream-empty');
|
|
1602
|
+
var streamOutput = document.getElementById('stream-output');
|
|
1603
|
+
if (streamEmpty && streamOutput) {
|
|
1604
|
+
new MutationObserver(function() {
|
|
1605
|
+
if (!streamOutput.classList.contains('hidden')) {
|
|
1606
|
+
streamEmpty.classList.add('hidden');
|
|
1607
|
+
}
|
|
1608
|
+
}).observe(streamOutput, { attributes: true, attributeFilter: ['class'] });
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
</script>`;
|
|
1612
|
+
return `<!DOCTYPE html>
|
|
1613
|
+
<html lang="en">
|
|
1614
|
+
<head>
|
|
1615
|
+
<meta charset="UTF-8">
|
|
1616
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1617
|
+
<title>otterly playground</title>
|
|
1618
|
+
<style>${playgroundCSS()}</style>
|
|
1619
|
+
</head>
|
|
1620
|
+
<body>
|
|
1621
|
+
${playgroundHTML()}
|
|
1622
|
+
<script>${playgroundJS(port)}</script>
|
|
1623
|
+
${templateScript}
|
|
1624
|
+
</body>
|
|
1625
|
+
</html>`;
|
|
1626
|
+
}
|