loopengine 0.1.2 → 0.1.4
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/core/agent-config.d.ts +9 -5
- package/dist/core/durable-approvals.d.ts +106 -0
- package/dist/core/durable-approvals.js +158 -0
- package/dist/core/gateway-tools.js +10 -9
- package/dist/core/run-agent.js +20 -3
- package/dist/index.d.ts +24 -1
- package/dist/index.js +26 -1
- package/dist/web/actauth-admin.d.ts +36 -0
- package/dist/web/actauth-admin.js +108 -0
- package/dist/web/agent-file-admin.d.ts +30 -0
- package/dist/web/agent-file-admin.js +165 -0
- package/dist/web/agents-config-page.d.ts +1 -0
- package/dist/web/agents-config-page.js +1628 -0
- package/dist/web/agents-list-page.d.ts +1 -0
- package/dist/web/agents-list-page.js +246 -0
- package/dist/web/dev-ui-styles.d.ts +1 -0
- package/dist/web/dev-ui-styles.js +63 -0
- package/dist/web/global-config-page.d.ts +1 -0
- package/dist/web/global-config-page.js +303 -0
- package/dist/web/global-config.d.ts +57 -0
- package/dist/web/global-config.js +67 -0
- package/dist/web/playground.d.ts +1 -0
- package/dist/web/playground.js +1271 -0
- package/dist/web/skills-admin.d.ts +23 -0
- package/dist/web/skills-admin.js +67 -0
- package/dist/web/web-approver.d.ts +35 -0
- package/dist/web/web-approver.js +88 -0
- package/package.json +5 -2
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
// A local dev playground: a single self-contained HTML page (no build
|
|
2
|
+
// step, no framework, no CDN) served by adapters/http.ts at GET
|
|
3
|
+
// /playground. It's a browser client on top of the *existing*
|
|
4
|
+
// /agents/:name/messages/stream route — nothing here talks to runAgent,
|
|
5
|
+
// SessionStore, or ActAuth directly; it only renders the same SSE events
|
|
6
|
+
// that route already emits, live, instead of leaving `curl -N` as the
|
|
7
|
+
// only way to see the loop actually think.
|
|
8
|
+
//
|
|
9
|
+
// Embedded as a TS string (not a separate .html file) so tsc's ordinary
|
|
10
|
+
// compile emits it straight into dist/adapters/ alongside http.js — no
|
|
11
|
+
// asset-copy step needed in the build or the Dockerfile.
|
|
12
|
+
//
|
|
13
|
+
// The inline <script> below is deliberately written without backticks or
|
|
14
|
+
// ${} — it lives inside this outer TS template literal, so a nested
|
|
15
|
+
// template literal would need every backtick/${ escaped relative to the
|
|
16
|
+
// outer one. Plain string concatenation sidesteps that class of bug
|
|
17
|
+
// entirely rather than relying on getting every escape right.
|
|
18
|
+
import { devUiCss } from './dev-ui-styles.js';
|
|
19
|
+
export const playgroundHtml = `<!doctype html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8">
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
24
|
+
<title>LoopEngine Playground</title>
|
|
25
|
+
<style>${devUiCss}
|
|
26
|
+
body {
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
height: 100vh;
|
|
30
|
+
}
|
|
31
|
+
header {
|
|
32
|
+
padding: 10px 16px;
|
|
33
|
+
border-bottom: 1px solid light-dark(#ddd, #333);
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 12px;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
}
|
|
39
|
+
header h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
|
40
|
+
#agentCaption {
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
color: light-dark(#666, #999);
|
|
43
|
+
max-width: 360px;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
text-overflow: ellipsis;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
#configLink { font-size: 12px; white-space: nowrap; text-decoration: none; }
|
|
49
|
+
#sessionLabel {
|
|
50
|
+
margin-left: auto;
|
|
51
|
+
font-size: 12px;
|
|
52
|
+
font-family: ui-monospace, monospace;
|
|
53
|
+
color: light-dark(#666, #999);
|
|
54
|
+
}
|
|
55
|
+
#sessionLabel.copyable { cursor: pointer; }
|
|
56
|
+
#sessionLabel.copyable:hover { text-decoration: underline; }
|
|
57
|
+
main {
|
|
58
|
+
flex: 1;
|
|
59
|
+
display: flex;
|
|
60
|
+
min-height: 0;
|
|
61
|
+
}
|
|
62
|
+
.pane {
|
|
63
|
+
flex: 1;
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
min-width: 0;
|
|
67
|
+
border-right: 1px solid light-dark(#ddd, #333);
|
|
68
|
+
}
|
|
69
|
+
.pane:last-child { border-right: none; }
|
|
70
|
+
.sessions-pane { flex: 0 0 200px; }
|
|
71
|
+
.sessions-pane .pane-body { padding: 6px; gap: 2px; }
|
|
72
|
+
.session-item {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 4px;
|
|
76
|
+
border-radius: 6px;
|
|
77
|
+
padding: 6px 8px;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
}
|
|
80
|
+
.session-item:hover { background: light-dark(#f0f0f1, #26262b); }
|
|
81
|
+
.session-item.active { background: light-dark(#dbeafe, #1e3a5f); }
|
|
82
|
+
.session-item .preview {
|
|
83
|
+
flex: 1;
|
|
84
|
+
min-width: 0;
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
overflow: hidden;
|
|
87
|
+
text-overflow: ellipsis;
|
|
88
|
+
white-space: nowrap;
|
|
89
|
+
}
|
|
90
|
+
.session-item .session-time { font-size: 10px; color: light-dark(#999, #777); flex-shrink: 0; }
|
|
91
|
+
.session-item .session-remove {
|
|
92
|
+
flex-shrink: 0;
|
|
93
|
+
border: none;
|
|
94
|
+
background: none;
|
|
95
|
+
padding: 0 2px;
|
|
96
|
+
font-size: 13px;
|
|
97
|
+
line-height: 1;
|
|
98
|
+
color: light-dark(#999, #777);
|
|
99
|
+
display: none;
|
|
100
|
+
}
|
|
101
|
+
.session-item:hover .session-remove { display: block; }
|
|
102
|
+
.session-item .session-remove:hover { color: light-dark(#991b1b, #f87171); }
|
|
103
|
+
.pane h2 {
|
|
104
|
+
font-size: 12px;
|
|
105
|
+
text-transform: uppercase;
|
|
106
|
+
letter-spacing: 0.04em;
|
|
107
|
+
color: light-dark(#666, #999);
|
|
108
|
+
margin: 0;
|
|
109
|
+
padding: 8px 12px;
|
|
110
|
+
border-bottom: 1px solid light-dark(#eee, #2a2a2e);
|
|
111
|
+
}
|
|
112
|
+
.pane-body {
|
|
113
|
+
flex: 1;
|
|
114
|
+
overflow-y: auto;
|
|
115
|
+
padding: 10px 12px;
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
gap: 8px;
|
|
119
|
+
}
|
|
120
|
+
.msg { max-width: 90%; }
|
|
121
|
+
.msg-label {
|
|
122
|
+
font-size: 10px;
|
|
123
|
+
text-transform: uppercase;
|
|
124
|
+
color: light-dark(#999, #777);
|
|
125
|
+
margin-bottom: 2px;
|
|
126
|
+
}
|
|
127
|
+
.msg-body {
|
|
128
|
+
padding: 8px 10px;
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
white-space: pre-wrap;
|
|
131
|
+
word-break: break-word;
|
|
132
|
+
}
|
|
133
|
+
.msg-user { align-self: flex-end; }
|
|
134
|
+
.msg-user .msg-body { background: light-dark(#dbeafe, #1e3a5f); }
|
|
135
|
+
.msg-assistant .msg-body { background: light-dark(#fff, #2a2a2e); border: 1px solid light-dark(#ddd, #3a3a3e); }
|
|
136
|
+
.msg-error .msg-body { background: light-dark(#fee2e2, #4a1f1f); color: light-dark(#991b1b, #f87171); }
|
|
137
|
+
.msg-stopped .msg-body { font-style: italic; color: light-dark(#666, #999); background: light-dark(#f3f3f4, #26262b); }
|
|
138
|
+
.msg-thinking .msg-body { display: inline-flex; gap: 4px; padding: 11px 10px; }
|
|
139
|
+
.msg-approval .msg-body {
|
|
140
|
+
background: light-dark(#fefce8, #422006);
|
|
141
|
+
border: 1px solid light-dark(#eab308, #a16207);
|
|
142
|
+
display: flex;
|
|
143
|
+
flex-direction: column;
|
|
144
|
+
gap: 6px;
|
|
145
|
+
}
|
|
146
|
+
.approval-tool { font-family: ui-monospace, monospace; font-weight: 600; }
|
|
147
|
+
.approval-scope { font-size: 11px; color: light-dark(#666, #999); }
|
|
148
|
+
.approval-reason { font-size: 12px; }
|
|
149
|
+
.approval-args {
|
|
150
|
+
font-size: 11px;
|
|
151
|
+
margin: 0;
|
|
152
|
+
background: light-dark(#fff, #26262b);
|
|
153
|
+
border-radius: 6px;
|
|
154
|
+
padding: 6px 8px;
|
|
155
|
+
overflow-x: auto;
|
|
156
|
+
white-space: pre-wrap;
|
|
157
|
+
word-break: break-word;
|
|
158
|
+
}
|
|
159
|
+
.approval-actions { display: flex; gap: 8px; }
|
|
160
|
+
.approval-actions button { font-size: 12px; padding: 5px 12px; }
|
|
161
|
+
.approval-actions .approve { background: light-dark(#dcfce7, #14532d); }
|
|
162
|
+
.approval-actions .deny { background: light-dark(#fee2e2, #450a0a); }
|
|
163
|
+
.approval-status { font-size: 12px; font-style: italic; color: light-dark(#666, #999); }
|
|
164
|
+
.msg-question .msg-body {
|
|
165
|
+
background: light-dark(#eff6ff, #1e293b);
|
|
166
|
+
border: 1px solid light-dark(#3b82f6, #60a5fa);
|
|
167
|
+
display: flex;
|
|
168
|
+
flex-direction: column;
|
|
169
|
+
gap: 6px;
|
|
170
|
+
}
|
|
171
|
+
.question-text { font-size: 13px; }
|
|
172
|
+
.question-options { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
173
|
+
.question-options button { font-size: 12px; padding: 5px 12px; }
|
|
174
|
+
.question-answer-row { display: flex; gap: 6px; }
|
|
175
|
+
.question-answer-row input { flex: 1; font-size: 12px; }
|
|
176
|
+
.question-answer-row button { font-size: 12px; padding: 5px 12px; }
|
|
177
|
+
.question-status { font-size: 12px; font-style: italic; color: light-dark(#666, #999); }
|
|
178
|
+
.dot {
|
|
179
|
+
width: 6px;
|
|
180
|
+
height: 6px;
|
|
181
|
+
border-radius: 50%;
|
|
182
|
+
background: light-dark(#999, #777);
|
|
183
|
+
animation: dot-blink 1.4s infinite both;
|
|
184
|
+
}
|
|
185
|
+
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
186
|
+
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
187
|
+
@keyframes dot-blink { 0%, 80%, 100% { opacity: 0.2; } 40% { opacity: 1; } }
|
|
188
|
+
.empty-hint { padding: 10px 2px; }
|
|
189
|
+
.event {
|
|
190
|
+
border-left: 3px solid light-dark(#ccc, #444);
|
|
191
|
+
padding: 4px 8px;
|
|
192
|
+
font-size: 12px;
|
|
193
|
+
}
|
|
194
|
+
.event-label { font-weight: 600; font-family: ui-monospace, monospace; }
|
|
195
|
+
.event-data {
|
|
196
|
+
margin: 2px 0 0;
|
|
197
|
+
font-family: ui-monospace, monospace;
|
|
198
|
+
font-size: 11px;
|
|
199
|
+
white-space: pre-wrap;
|
|
200
|
+
word-break: break-word;
|
|
201
|
+
color: light-dark(#444, #aaa);
|
|
202
|
+
}
|
|
203
|
+
.event-actauth { border-left-color: #f59e0b; }
|
|
204
|
+
.event-toollane { border-left-color: #3b82f6; }
|
|
205
|
+
.event-budget { border-left-color: #8b5cf6; }
|
|
206
|
+
.event-prompt { border-left-color: #ef4444; }
|
|
207
|
+
.event-skill { border-left-color: #10b981; }
|
|
208
|
+
.event-loop { border-left-color: #6b7280; }
|
|
209
|
+
.event-approval { border-left-color: #eab308; }
|
|
210
|
+
.event-question { border-left-color: #3b82f6; }
|
|
211
|
+
.composer {
|
|
212
|
+
border-top: 1px solid light-dark(#ddd, #333);
|
|
213
|
+
padding: 10px 12px;
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
gap: 8px;
|
|
217
|
+
}
|
|
218
|
+
.send-row { display: flex; gap: 8px; }
|
|
219
|
+
#messageInput { flex: 1; resize: vertical; height: 90px; }
|
|
220
|
+
#messageInput:disabled, #sendButton:disabled { cursor: not-allowed; }
|
|
221
|
+
details summary { cursor: pointer; font-size: 12px; color: light-dark(#666, #999); }
|
|
222
|
+
.advanced-hint { margin: 6px 0 0; font-size: 11px; }
|
|
223
|
+
.advanced-fields { display: flex; gap: 8px; margin-top: 6px; }
|
|
224
|
+
.advanced-fields > div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
|
225
|
+
.advanced-fields label { font-size: 11px; color: light-dark(#666, #999); }
|
|
226
|
+
.advanced-fields textarea { height: 50px; font-family: ui-monospace, monospace; font-size: 11px; resize: vertical; }
|
|
227
|
+
@media (max-width: 720px) {
|
|
228
|
+
main { flex-direction: column; }
|
|
229
|
+
.pane { border-right: none; border-bottom: 1px solid light-dark(#ddd, #333); min-height: 160px; }
|
|
230
|
+
.pane:last-child { border-bottom: none; }
|
|
231
|
+
.sessions-pane { flex-basis: auto; }
|
|
232
|
+
}
|
|
233
|
+
</style>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<nav class="topnav">
|
|
237
|
+
<a href="/agents">Agents</a>
|
|
238
|
+
<a href="/agents/config">Config</a>
|
|
239
|
+
<a href="/playground" class="active">Playground</a>
|
|
240
|
+
</nav>
|
|
241
|
+
<header>
|
|
242
|
+
<h1>LoopEngine Playground</h1>
|
|
243
|
+
<select id="agentSelect"></select>
|
|
244
|
+
<span id="agentCaption"></span>
|
|
245
|
+
<button id="newConversationButton" type="button">New conversation</button>
|
|
246
|
+
<a id="configLink" href="/agents/config">Agent config →</a>
|
|
247
|
+
<span id="sessionLabel">session: (new)</span>
|
|
248
|
+
</header>
|
|
249
|
+
<main>
|
|
250
|
+
<section class="pane sessions-pane">
|
|
251
|
+
<h2>Sessions</h2>
|
|
252
|
+
<div class="pane-body" id="sessionsPane"></div>
|
|
253
|
+
</section>
|
|
254
|
+
<section class="pane">
|
|
255
|
+
<h2>Chat</h2>
|
|
256
|
+
<div class="pane-body" id="chatPane"></div>
|
|
257
|
+
<div class="composer">
|
|
258
|
+
<details>
|
|
259
|
+
<summary>Advanced</summary>
|
|
260
|
+
<p class="advanced-hint muted">For agents whose sessionIdFor/tenantFor need something beyond a plain sessionId — e.g. customer-service reads customerEmail from the body.</p>
|
|
261
|
+
<div class="advanced-fields">
|
|
262
|
+
<div>
|
|
263
|
+
<label for="extraHeadersInput">Extra headers (one "key: value" per line)</label>
|
|
264
|
+
<textarea id="extraHeadersInput" placeholder="x-api-key: acme-trusted-key"></textarea>
|
|
265
|
+
</div>
|
|
266
|
+
<div>
|
|
267
|
+
<label for="extraBodyInput">Extra body fields (JSON)</label>
|
|
268
|
+
<textarea id="extraBodyInput" placeholder='{"customerEmail": "a@example.com"}'></textarea>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</details>
|
|
272
|
+
<div class="send-row">
|
|
273
|
+
<textarea id="messageInput" placeholder="Message… (Enter to send, Shift+Enter for a new line)"></textarea>
|
|
274
|
+
<button id="sendButton" type="button">Send</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</section>
|
|
278
|
+
<section class="pane">
|
|
279
|
+
<h2>Loop events</h2>
|
|
280
|
+
<div class="pane-body" id="timelinePane"></div>
|
|
281
|
+
</section>
|
|
282
|
+
</main>
|
|
283
|
+
<script>
|
|
284
|
+
(function () {
|
|
285
|
+
var agentSelect = document.getElementById('agentSelect');
|
|
286
|
+
var agentCaption = document.getElementById('agentCaption');
|
|
287
|
+
var configLink = document.getElementById('configLink');
|
|
288
|
+
var sessionLabel = document.getElementById('sessionLabel');
|
|
289
|
+
var chatPane = document.getElementById('chatPane');
|
|
290
|
+
var timelinePane = document.getElementById('timelinePane');
|
|
291
|
+
var sessionsPane = document.getElementById('sessionsPane');
|
|
292
|
+
var messageInput = document.getElementById('messageInput');
|
|
293
|
+
var sendButton = document.getElementById('sendButton');
|
|
294
|
+
var newConversationButton = document.getElementById('newConversationButton');
|
|
295
|
+
var extraHeadersInput = document.getElementById('extraHeadersInput');
|
|
296
|
+
var extraBodyInput = document.getElementById('extraBodyInput');
|
|
297
|
+
|
|
298
|
+
var currentAgents = [];
|
|
299
|
+
var sessionId = null;
|
|
300
|
+
var isSending = false;
|
|
301
|
+
var thinkingEl = null;
|
|
302
|
+
// Set right before a message is sent, consumed by handleFrame's own
|
|
303
|
+
// 'session' branch once the server echoes back which sessionId this
|
|
304
|
+
// turn actually used (brand new or resumed) — that's the only point
|
|
305
|
+
// this code knows for certain "this session id is real and this is
|
|
306
|
+
// what the user just said to it," the two things rememberSession needs.
|
|
307
|
+
var lastSentMessage = null;
|
|
308
|
+
|
|
309
|
+
// Keyed by tool_use id — a 'tool:started' card (see appendToolCallCard
|
|
310
|
+
// below) parks its status <div> here so the later 'tool:result' event
|
|
311
|
+
// for the exact same call (run-agent.ts emits both with the same id)
|
|
312
|
+
// can update it in place instead of appending a second, redundant card.
|
|
313
|
+
// Cleared on every conversation switch (resetConversation/resumeSession)
|
|
314
|
+
// since a card from a previous session/turn is never a valid update
|
|
315
|
+
// target for one that happens to reuse an id namespace.
|
|
316
|
+
var toolCallCardsById = {};
|
|
317
|
+
|
|
318
|
+
// A page refresh only ever loses *this tab's* JS state — the actual
|
|
319
|
+
// conversation is durably stored server-side (see session-store.ts).
|
|
320
|
+
// What's missing after a refresh is just "which session ids did I
|
|
321
|
+
// start," so a browser-local list of {agent, sessionId, preview,
|
|
322
|
+
// updatedAt} is enough to rebuild a resumable list; GET
|
|
323
|
+
// /agents/:name/sessions/:id (resumeSession below) rehydrates the full
|
|
324
|
+
// conversation from the real store once one's picked.
|
|
325
|
+
var SESSIONS_STORAGE_KEY = 'loopengine-playground-sessions';
|
|
326
|
+
var MAX_SAVED_SESSIONS = 50;
|
|
327
|
+
|
|
328
|
+
function clearEmptyHint(pane) {
|
|
329
|
+
var hint = pane.querySelector('.empty-hint');
|
|
330
|
+
if (hint) hint.remove();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setEmptyHint(pane, text) {
|
|
334
|
+
pane.innerHTML = '';
|
|
335
|
+
var p = document.createElement('p');
|
|
336
|
+
p.className = 'empty-hint muted';
|
|
337
|
+
p.textContent = text;
|
|
338
|
+
pane.appendChild(p);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function appendChatMessage(role, text) {
|
|
342
|
+
clearEmptyHint(chatPane);
|
|
343
|
+
var div = document.createElement('div');
|
|
344
|
+
div.className = 'msg msg-' + role;
|
|
345
|
+
var label = document.createElement('div');
|
|
346
|
+
label.className = 'msg-label';
|
|
347
|
+
label.textContent = role;
|
|
348
|
+
var body = document.createElement('div');
|
|
349
|
+
body.className = 'msg-body';
|
|
350
|
+
body.textContent = text;
|
|
351
|
+
div.appendChild(label);
|
|
352
|
+
div.appendChild(body);
|
|
353
|
+
chatPane.appendChild(div);
|
|
354
|
+
chatPane.scrollTop = chatPane.scrollHeight;
|
|
355
|
+
return div;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Only ever called for a resumeContext'd card (see decide()/answer()
|
|
359
|
+
// below, both gated on it the same way) — a live SSE-pushed card has no
|
|
360
|
+
// resumeContext and never calls this, since its own connection already
|
|
361
|
+
// independently shows whatever comes next via handleFrame. Renders
|
|
362
|
+
// exactly what that same live connection would have shown for this
|
|
363
|
+
// response: another pending card, or the final text — the decide/
|
|
364
|
+
// answer response itself already says which (see adapters/http.ts's own
|
|
365
|
+
// raceAndRespond), so there's nothing left to poll or replay history
|
|
366
|
+
// for. Replaced pollForCompletion, which used to re-fetch and replay
|
|
367
|
+
// session history to find out the same thing.
|
|
368
|
+
function applyDecisionResult(respBody, resumeContext) {
|
|
369
|
+
if (respBody.pending) {
|
|
370
|
+
if (respBody.type === 'question') appendQuestionCard(respBody, resumeContext);
|
|
371
|
+
else appendApprovalCard(respBody, resumeContext);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
var doneEl = appendChatMessage('assistant', respBody.text);
|
|
375
|
+
if (respBody.stopReason) doneEl.classList.add('msg-stopped');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// A 'ask' decision arrived mid-turn (see run-agent.ts's gate.evaluate)
|
|
379
|
+
// and the server's default approver is now a tracked WebchatApprover (see
|
|
380
|
+
// web/web-approver.ts's createTrackedApprover) instead of a blocking
|
|
381
|
+
// terminal prompt — it parked this one and pushed it straight onto this
|
|
382
|
+
// exact SSE connection via onPending, so it can be decided right here
|
|
383
|
+
// instead of on a page
|
|
384
|
+
// nobody's watching. The rest of the turn (toollane:result, done, ...)
|
|
385
|
+
// keeps streaming into this same response once decide() resolves it.
|
|
386
|
+
function appendApprovalCard(data, resumeContext) {
|
|
387
|
+
clearEmptyHint(chatPane);
|
|
388
|
+
var div = document.createElement('div');
|
|
389
|
+
div.className = 'msg msg-assistant msg-approval';
|
|
390
|
+
var label = document.createElement('div');
|
|
391
|
+
label.className = 'msg-label';
|
|
392
|
+
label.textContent = 'approval needed';
|
|
393
|
+
var body = document.createElement('div');
|
|
394
|
+
body.className = 'msg-body';
|
|
395
|
+
|
|
396
|
+
var tool = document.createElement('div');
|
|
397
|
+
tool.className = 'approval-tool';
|
|
398
|
+
tool.textContent = data.tool;
|
|
399
|
+
|
|
400
|
+
var scope = document.createElement('div');
|
|
401
|
+
scope.className = 'approval-scope';
|
|
402
|
+
scope.textContent = data.scope.tenant + '/' + data.scope.environment + '/' + data.scope.agent;
|
|
403
|
+
|
|
404
|
+
var reason = document.createElement('div');
|
|
405
|
+
reason.className = 'approval-reason';
|
|
406
|
+
reason.textContent = data.reason;
|
|
407
|
+
|
|
408
|
+
var pre = document.createElement('pre');
|
|
409
|
+
pre.className = 'approval-args';
|
|
410
|
+
pre.textContent = JSON.stringify(data.args, null, 2);
|
|
411
|
+
|
|
412
|
+
var actions = document.createElement('div');
|
|
413
|
+
actions.className = 'approval-actions';
|
|
414
|
+
var approveBtn = document.createElement('button');
|
|
415
|
+
approveBtn.type = 'button';
|
|
416
|
+
approveBtn.className = 'approve';
|
|
417
|
+
approveBtn.textContent = 'Approve';
|
|
418
|
+
var denyBtn = document.createElement('button');
|
|
419
|
+
denyBtn.type = 'button';
|
|
420
|
+
denyBtn.className = 'deny';
|
|
421
|
+
denyBtn.textContent = 'Deny';
|
|
422
|
+
actions.appendChild(approveBtn);
|
|
423
|
+
actions.appendChild(denyBtn);
|
|
424
|
+
|
|
425
|
+
body.appendChild(tool);
|
|
426
|
+
body.appendChild(scope);
|
|
427
|
+
body.appendChild(reason);
|
|
428
|
+
body.appendChild(pre);
|
|
429
|
+
body.appendChild(actions);
|
|
430
|
+
div.appendChild(label);
|
|
431
|
+
div.appendChild(body);
|
|
432
|
+
chatPane.appendChild(div);
|
|
433
|
+
chatPane.scrollTop = chatPane.scrollHeight;
|
|
434
|
+
|
|
435
|
+
function decide(approved) {
|
|
436
|
+
approveBtn.disabled = true;
|
|
437
|
+
denyBtn.disabled = true;
|
|
438
|
+
// Live connection only: shown *synchronously*, before fetch() even
|
|
439
|
+
// starts — not inside its .then(). adapters/http.ts's own decide
|
|
440
|
+
// route now races the rest of the turn the same way POST /messages
|
|
441
|
+
// does (see raceAndRespond), so this response and the SSE 'done'/
|
|
442
|
+
// 'approval:pending' event for the very same outcome can land in
|
|
443
|
+
// either order — confirmed live. Calling showThinking() here is
|
|
444
|
+
// guaranteed to happen before either one, since nothing async can
|
|
445
|
+
// run before this synchronous click handler finishes; putting it in
|
|
446
|
+
// the .then() instead meant that whenever the SSE frame won the
|
|
447
|
+
// race and called removeThinking() *first*, this call landed after
|
|
448
|
+
// and left an orphaned thinking indicator nothing would ever clear
|
|
449
|
+
// — the "stuck on pending" bug.
|
|
450
|
+
if (!resumeContext) showThinking();
|
|
451
|
+
fetch('/approvals/' + encodeURIComponent(data.id) + '/' + (approved ? 'approve' : 'deny'), { method: 'POST' })
|
|
452
|
+
.then(function (r) {
|
|
453
|
+
return r.json().then(function (respBody) {
|
|
454
|
+
if (!r.ok) throw new Error(respBody.error || ('HTTP ' + r.status));
|
|
455
|
+
return respBody;
|
|
456
|
+
});
|
|
457
|
+
})
|
|
458
|
+
.then(function (respBody) {
|
|
459
|
+
actions.remove();
|
|
460
|
+
var status = document.createElement('div');
|
|
461
|
+
status.className = 'approval-status';
|
|
462
|
+
status.textContent = approved ? 'Approved.' : 'Denied.';
|
|
463
|
+
body.appendChild(status);
|
|
464
|
+
// Resumed: no live connection to show what's next on its own,
|
|
465
|
+
// so use the response directly (see applyDecisionResult).
|
|
466
|
+
// Live: nothing further to do here — the SSE stream (which may
|
|
467
|
+
// well have already fired by now) is what shows whatever comes
|
|
468
|
+
// next; this response was only ever needed to confirm the
|
|
469
|
+
// decision was recorded.
|
|
470
|
+
if (resumeContext) applyDecisionResult(respBody, resumeContext);
|
|
471
|
+
})
|
|
472
|
+
.catch(function (err) {
|
|
473
|
+
approveBtn.disabled = false;
|
|
474
|
+
denyBtn.disabled = false;
|
|
475
|
+
if (!resumeContext) removeThinking();
|
|
476
|
+
alert('Could not record decision: ' + err.message);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
approveBtn.addEventListener('click', function () { decide(true); });
|
|
481
|
+
denyBtn.addEventListener('click', function () { decide(false); });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// The model called the system ask_user tool (see
|
|
485
|
+
// system-tools/ask_user.ts) — same "parked and pushed onto this exact
|
|
486
|
+
// SSE connection" mechanism appendApprovalCard uses, just answered with
|
|
487
|
+
// an arbitrary string instead of a fixed allow/deny. Suggested options
|
|
488
|
+
// (if the model gave any) render as one-click buttons; free text is
|
|
489
|
+
// always available too, since the tool never restricts the answer to
|
|
490
|
+
// just those.
|
|
491
|
+
function appendQuestionCard(data, resumeContext) {
|
|
492
|
+
clearEmptyHint(chatPane);
|
|
493
|
+
var div = document.createElement('div');
|
|
494
|
+
div.className = 'msg msg-assistant msg-question';
|
|
495
|
+
var label = document.createElement('div');
|
|
496
|
+
label.className = 'msg-label';
|
|
497
|
+
label.textContent = 'answer needed';
|
|
498
|
+
var body = document.createElement('div');
|
|
499
|
+
body.className = 'msg-body';
|
|
500
|
+
|
|
501
|
+
var text = document.createElement('div');
|
|
502
|
+
text.className = 'question-text';
|
|
503
|
+
text.textContent = data.question;
|
|
504
|
+
body.appendChild(text);
|
|
505
|
+
|
|
506
|
+
var optionsRow = null;
|
|
507
|
+
if (data.options && data.options.length) {
|
|
508
|
+
optionsRow = document.createElement('div');
|
|
509
|
+
optionsRow.className = 'question-options';
|
|
510
|
+
for (var i = 0; i < data.options.length; i++) {
|
|
511
|
+
(function (option) {
|
|
512
|
+
var btn = document.createElement('button');
|
|
513
|
+
btn.type = 'button';
|
|
514
|
+
btn.textContent = option;
|
|
515
|
+
btn.addEventListener('click', function () { answer(option); });
|
|
516
|
+
optionsRow.appendChild(btn);
|
|
517
|
+
})(data.options[i]);
|
|
518
|
+
}
|
|
519
|
+
body.appendChild(optionsRow);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
var answerRow = document.createElement('div');
|
|
523
|
+
answerRow.className = 'question-answer-row';
|
|
524
|
+
var input = document.createElement('input');
|
|
525
|
+
input.type = 'text';
|
|
526
|
+
input.placeholder = 'Type an answer\\u2026';
|
|
527
|
+
var sendBtn = document.createElement('button');
|
|
528
|
+
sendBtn.type = 'button';
|
|
529
|
+
sendBtn.textContent = 'Send';
|
|
530
|
+
answerRow.appendChild(input);
|
|
531
|
+
answerRow.appendChild(sendBtn);
|
|
532
|
+
body.appendChild(answerRow);
|
|
533
|
+
|
|
534
|
+
div.appendChild(label);
|
|
535
|
+
div.appendChild(body);
|
|
536
|
+
chatPane.appendChild(div);
|
|
537
|
+
chatPane.scrollTop = chatPane.scrollHeight;
|
|
538
|
+
input.focus();
|
|
539
|
+
|
|
540
|
+
function answer(value) {
|
|
541
|
+
if (!value) return;
|
|
542
|
+
input.disabled = true;
|
|
543
|
+
sendBtn.disabled = true;
|
|
544
|
+
if (optionsRow) optionsRow.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
|
|
545
|
+
// See appendApprovalCard's own decide() for why this fires
|
|
546
|
+
// synchronously, before fetch(), instead of inside its .then() —
|
|
547
|
+
// same race against the SSE stream, same fix.
|
|
548
|
+
if (!resumeContext) showThinking();
|
|
549
|
+
fetch('/questions/' + encodeURIComponent(data.id) + '/answer', {
|
|
550
|
+
method: 'POST',
|
|
551
|
+
headers: { 'content-type': 'application/json' },
|
|
552
|
+
body: JSON.stringify({ answer: value }),
|
|
553
|
+
})
|
|
554
|
+
.then(function (r) {
|
|
555
|
+
return r.json().then(function (respBody) {
|
|
556
|
+
if (!r.ok) throw new Error(respBody.error || ('HTTP ' + r.status));
|
|
557
|
+
return respBody;
|
|
558
|
+
});
|
|
559
|
+
})
|
|
560
|
+
.then(function (respBody) {
|
|
561
|
+
answerRow.remove();
|
|
562
|
+
if (optionsRow) optionsRow.remove();
|
|
563
|
+
var status = document.createElement('div');
|
|
564
|
+
status.className = 'question-status';
|
|
565
|
+
status.textContent = 'Answered: ' + value;
|
|
566
|
+
body.appendChild(status);
|
|
567
|
+
if (resumeContext) applyDecisionResult(respBody, resumeContext);
|
|
568
|
+
})
|
|
569
|
+
.catch(function (err) {
|
|
570
|
+
input.disabled = false;
|
|
571
|
+
sendBtn.disabled = false;
|
|
572
|
+
if (optionsRow) optionsRow.querySelectorAll('button').forEach(function (b) { b.disabled = false; });
|
|
573
|
+
if (!resumeContext) removeThinking();
|
|
574
|
+
alert('Could not send answer: ' + err.message);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
sendBtn.addEventListener('click', function () { answer(input.value.trim()); });
|
|
579
|
+
input.addEventListener('keydown', function (e) {
|
|
580
|
+
if (e.key === 'Enter') answer(input.value.trim());
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Every tool call reconstructed from stored history (see
|
|
585
|
+
// renderHistoryMessage below) — success, denied, or skipped alike — same
|
|
586
|
+
// look as a live/resumed appendApprovalCard, minus Approve/Deny (already
|
|
587
|
+
// resolved either way) and minus scope (not something a tool_result's
|
|
588
|
+
// plain string content carries, and not worth threading
|
|
589
|
+
// agent/tenant/environment all the way through just to show three words
|
|
590
|
+
// nobody's questioning by this point). Without this, a refresh
|
|
591
|
+
// collapsed a denied call down to one bare "Denied: <tool> (<reason>)"
|
|
592
|
+
// line and a successful one down to nothing at all — confirmed live
|
|
593
|
+
// that refreshing after approving firecrawl_FIRECRAWL_SCRAPE left no
|
|
594
|
+
// way to see what URL had even been requested, or what it returned.
|
|
595
|
+
//
|
|
596
|
+
// Same yellow .msg-approval styling regardless of outcome (approved,
|
|
597
|
+
// denied, skipped, or error) — statusText is the only thing that
|
|
598
|
+
// changes; deliberately not recolored per outcome afterward.
|
|
599
|
+
//
|
|
600
|
+
// Labeled the same as the live card this same event would have shown
|
|
601
|
+
// ("approval needed"/appendApprovalCard, "answer needed"/
|
|
602
|
+
// appendQuestionCard) — not a distinct "tool call" label — so live and
|
|
603
|
+
// refreshed views of the exact same event read the same way instead of
|
|
604
|
+
// looking like two different kinds of thing.
|
|
605
|
+
function appendToolCallCard(toolName, args, detailText, statusText, id) {
|
|
606
|
+
clearEmptyHint(chatPane);
|
|
607
|
+
var div = document.createElement('div');
|
|
608
|
+
div.className = 'msg msg-assistant msg-approval';
|
|
609
|
+
var label = document.createElement('div');
|
|
610
|
+
label.className = 'msg-label';
|
|
611
|
+
label.textContent = toolName === 'system_ask_user' ? 'answer needed' : 'approval needed';
|
|
612
|
+
var body = document.createElement('div');
|
|
613
|
+
body.className = 'msg-body';
|
|
614
|
+
|
|
615
|
+
var tool = document.createElement('div');
|
|
616
|
+
tool.className = 'approval-tool';
|
|
617
|
+
tool.textContent = toolName;
|
|
618
|
+
body.appendChild(tool);
|
|
619
|
+
|
|
620
|
+
if (detailText) {
|
|
621
|
+
var reason = document.createElement('div');
|
|
622
|
+
reason.className = 'approval-reason';
|
|
623
|
+
reason.textContent = detailText;
|
|
624
|
+
body.appendChild(reason);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (args !== undefined) {
|
|
628
|
+
var pre = document.createElement('pre');
|
|
629
|
+
pre.className = 'approval-args';
|
|
630
|
+
pre.textContent = JSON.stringify(args, null, 2);
|
|
631
|
+
body.appendChild(pre);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
var status = document.createElement('div');
|
|
635
|
+
status.className = 'approval-status';
|
|
636
|
+
status.textContent = statusText;
|
|
637
|
+
body.appendChild(status);
|
|
638
|
+
|
|
639
|
+
div.appendChild(label);
|
|
640
|
+
div.appendChild(body);
|
|
641
|
+
chatPane.appendChild(div);
|
|
642
|
+
chatPane.scrollTop = chatPane.scrollHeight;
|
|
643
|
+
// Only a 'tool:started' card (the one phase with a real "later update"
|
|
644
|
+
// coming) registers itself here — see updateToolCallCard below.
|
|
645
|
+
if (id) toolCallCardsById[id] = status;
|
|
646
|
+
return div;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Turns a 'tool:started' card's "Running…" placeholder into its real
|
|
650
|
+
// outcome in place, rather than appending a second card right under it
|
|
651
|
+
// — same call, same id, just the second half of the same event pushed
|
|
652
|
+
// by run-agent.ts once execution actually finishes. Returns whether an
|
|
653
|
+
// in-place update happened; the caller falls back to a fresh
|
|
654
|
+
// appendToolCallCard when it didn't (a denied/skipped call, which never
|
|
655
|
+
// gets a 'tool:started' card in the first place, or an interactively-
|
|
656
|
+
// asked call this handleFrame chose not to render one for at all —
|
|
657
|
+
// see wasAskedInteractively below).
|
|
658
|
+
function updateToolCallCard(id, statusText) {
|
|
659
|
+
var status = id && toolCallCardsById[id];
|
|
660
|
+
if (!status) return false;
|
|
661
|
+
delete toolCallCardsById[id];
|
|
662
|
+
status.textContent = statusText;
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// run-agent.ts is a pure engine now: 'tool:started'/'tool:result' fire
|
|
667
|
+
// unconditionally for every call, whether or not a human was actually
|
|
668
|
+
// asked live via an approval card (see appendApprovalCard). Deciding
|
|
669
|
+
// whether that duplicates something already on screen is a rendering
|
|
670
|
+
// call, made here instead. actauth's own Gate only ever appends this
|
|
671
|
+
// exact suffix to a decision's reason when the rule it matched said
|
|
672
|
+
// 'ask' in the first place (see actauth's own gate.ts) - the one
|
|
673
|
+
// reliable way to tell "a human was actually asked, live" apart from a
|
|
674
|
+
// straight allow/deny rule that never asked anyone anything. Without
|
|
675
|
+
// this check, every interactively-approved or -denied call shows up
|
|
676
|
+
// twice: once as the approval card itself, once more as a redundant
|
|
677
|
+
// second "approval needed" card right under it.
|
|
678
|
+
function wasAskedInteractively(detailText) {
|
|
679
|
+
return !!detailText && (detailText.indexOf(' \\u2014 human approved') !== -1 || detailText.indexOf(' \\u2014 human denied') !== -1);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function showThinking() {
|
|
683
|
+
var div = appendChatMessage('assistant', '');
|
|
684
|
+
div.classList.add('msg-thinking');
|
|
685
|
+
div.querySelector('.msg-body').innerHTML = '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
|
|
686
|
+
thinkingEl = div;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function removeThinking() {
|
|
690
|
+
if (thinkingEl) {
|
|
691
|
+
thinkingEl.remove();
|
|
692
|
+
thinkingEl = null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function eventCategory(eventName) {
|
|
697
|
+
var idx = eventName.indexOf(':');
|
|
698
|
+
return idx === -1 ? '' : 'event-' + eventName.slice(0, idx);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function loadSavedSessions() {
|
|
702
|
+
try {
|
|
703
|
+
var raw = localStorage.getItem(SESSIONS_STORAGE_KEY);
|
|
704
|
+
var parsed = raw ? JSON.parse(raw) : [];
|
|
705
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
706
|
+
} catch (err) {
|
|
707
|
+
return [];
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function saveSavedSessions(list) {
|
|
712
|
+
try {
|
|
713
|
+
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(list));
|
|
714
|
+
} catch (err) {
|
|
715
|
+
// best-effort — private browsing / a full quota just means this
|
|
716
|
+
// session won't be in the list after a refresh, not a hard failure.
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Most-recently-used first, one entry per {agent, sessionId} (a resumed
|
|
721
|
+
// session that gets a new message moves back to the top instead of
|
|
722
|
+
// duplicating), capped so this can't grow without bound over a long
|
|
723
|
+
// browser lifetime.
|
|
724
|
+
function rememberSession(agent, id, preview) {
|
|
725
|
+
var list = loadSavedSessions().filter(function (s) { return !(s.agent === agent && s.sessionId === id); });
|
|
726
|
+
list.unshift({ agent: agent, sessionId: id, preview: preview, updatedAt: Date.now() });
|
|
727
|
+
if (list.length > MAX_SAVED_SESSIONS) list = list.slice(0, MAX_SAVED_SESSIONS);
|
|
728
|
+
saveSavedSessions(list);
|
|
729
|
+
renderSessionsPane();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function forgetSession(agent, id) {
|
|
733
|
+
saveSavedSessions(loadSavedSessions().filter(function (s) { return !(s.agent === agent && s.sessionId === id); }));
|
|
734
|
+
renderSessionsPane();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Same "browser-local, best-effort" fallback rememberSession's own
|
|
738
|
+
// preview field already is (see its doc comment) — just for the
|
|
739
|
+
// in-flight turn's *assistant* text instead of the user's own message.
|
|
740
|
+
// Written every time a live 'assistant:text' event arrives (see
|
|
741
|
+
// handleFrame below) and cleared the moment the turn actually finishes
|
|
742
|
+
// ('done'/'error'), so a resume finds it set only while genuinely
|
|
743
|
+
// waiting on a still-pending approval/question — exactly the window
|
|
744
|
+
// resumeSession's own history fetch can't see into yet (see its own
|
|
745
|
+
// doc comment on why the *user's* message needs the same treatment).
|
|
746
|
+
function updateSessionField(agent, id, field, value) {
|
|
747
|
+
var list = loadSavedSessions();
|
|
748
|
+
for (var i = 0; i < list.length; i++) {
|
|
749
|
+
if (list[i].agent === agent && list[i].sessionId === id) {
|
|
750
|
+
list[i][field] = value;
|
|
751
|
+
saveSavedSessions(list);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function formatRelativeTime(ms) {
|
|
758
|
+
var minutes = Math.round((Date.now() - ms) / 60000);
|
|
759
|
+
if (minutes < 1) return 'now';
|
|
760
|
+
if (minutes < 60) return minutes + 'm';
|
|
761
|
+
var hours = Math.round(minutes / 60);
|
|
762
|
+
if (hours < 24) return hours + 'h';
|
|
763
|
+
return Math.round(hours / 24) + 'd';
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function renderSessionsPane() {
|
|
767
|
+
var agent = agentSelect.value;
|
|
768
|
+
var list = loadSavedSessions().filter(function (s) { return s.agent === agent; });
|
|
769
|
+
sessionsPane.textContent = '';
|
|
770
|
+
if (!list.length) {
|
|
771
|
+
setEmptyHint(sessionsPane, 'No saved sessions yet.');
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
for (var i = 0; i < list.length; i++) {
|
|
775
|
+
var item = list[i];
|
|
776
|
+
var div = document.createElement('div');
|
|
777
|
+
div.className = 'session-item' + (item.sessionId === sessionId ? ' active' : '');
|
|
778
|
+
var preview = document.createElement('span');
|
|
779
|
+
preview.className = 'preview';
|
|
780
|
+
preview.textContent = item.preview || '(empty)';
|
|
781
|
+
var time = document.createElement('span');
|
|
782
|
+
time.className = 'session-time';
|
|
783
|
+
time.textContent = formatRelativeTime(item.updatedAt);
|
|
784
|
+
var removeBtn = document.createElement('button');
|
|
785
|
+
removeBtn.type = 'button';
|
|
786
|
+
removeBtn.className = 'session-remove';
|
|
787
|
+
removeBtn.textContent = '\\u00d7';
|
|
788
|
+
removeBtn.title = 'Remove from this list';
|
|
789
|
+
div.appendChild(preview);
|
|
790
|
+
div.appendChild(time);
|
|
791
|
+
div.appendChild(removeBtn);
|
|
792
|
+
(function (item) {
|
|
793
|
+
div.addEventListener('click', function () { resumeSession(item); });
|
|
794
|
+
removeBtn.addEventListener('click', function (ev) {
|
|
795
|
+
ev.stopPropagation();
|
|
796
|
+
forgetSession(item.agent, item.sessionId);
|
|
797
|
+
});
|
|
798
|
+
})(item);
|
|
799
|
+
sessionsPane.appendChild(div);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Every tool_use/tool_result pair reconstructs as a tool call card (see
|
|
804
|
+
// appendToolCallCard above) — approved and successful, denied, or
|
|
805
|
+
// skipped alike, not just the denied/skipped subset this used to be
|
|
806
|
+
// limited to. A refresh used to leave a successful call with zero trace
|
|
807
|
+
// it ever happened (that used to be "what the Loop events pane is
|
|
808
|
+
// for," but that pane only ever covers the *live* run that produced it,
|
|
809
|
+
// never a resumed one) — confirmed live that resuming a session after
|
|
810
|
+
// approving a tool call left no way to see what it was even called
|
|
811
|
+
// with, or what it returned. toolCallsById correlates a tool_result
|
|
812
|
+
// back to which tool it's for (by tool_use_id) across the two separate
|
|
813
|
+
// messages that always bundle them — threaded in from resumeSession's
|
|
814
|
+
// own render loop below, one map per session render.
|
|
815
|
+
function renderHistoryMessage(msg, toolCallsById) {
|
|
816
|
+
if (Array.isArray(msg.content)) {
|
|
817
|
+
for (var i = 0; i < msg.content.length; i++) {
|
|
818
|
+
var block = msg.content[i];
|
|
819
|
+
if (block.type === 'tool_use') {
|
|
820
|
+
toolCallsById[block.id] = { name: block.name, input: block.input };
|
|
821
|
+
} else if (block.type === 'tool_result' && typeof block.content === 'string') {
|
|
822
|
+
var call = toolCallsById[block.tool_use_id];
|
|
823
|
+
var toolName = call ? call.name : 'a tool call';
|
|
824
|
+
var toolArgs = call ? call.input : undefined;
|
|
825
|
+
// Plain prefix checks, not a regex — this whole file is one
|
|
826
|
+
// outer TS template literal (see its own top-of-file doc
|
|
827
|
+
// comment), so an unrecognized backslash escape gets silently
|
|
828
|
+
// dropped when TS compiles it, and a two-character regex class
|
|
829
|
+
// meant to match any character including newlines quietly
|
|
830
|
+
// turned into a class that only matches two literal letters in
|
|
831
|
+
// the actually served script — confirmed by extracting and
|
|
832
|
+
// inspecting the real served script, not just re-typing the
|
|
833
|
+
// intended pattern in a standalone test file (which doesn't go
|
|
834
|
+
// through this file's own compile step, so never would have
|
|
835
|
+
// caught this). That silently broke every denied/skipped
|
|
836
|
+
// reconstruction earlier this session, the whole time.
|
|
837
|
+
var deniedPrefix = 'denied: ';
|
|
838
|
+
var skippedPrefix = 'skipped: ';
|
|
839
|
+
if (!block.is_error) {
|
|
840
|
+
// block.reason (see run-agent.ts's own ModelContentBlock.reason)
|
|
841
|
+
// is the actauth decision's own "matched rule '...'" text —
|
|
842
|
+
// the tool's actual return value is deliberately not shown
|
|
843
|
+
// here (that's the Loop events pane's job for the live run
|
|
844
|
+
// that produced it); this card is about *why it was allowed*,
|
|
845
|
+
// not what it returned.
|
|
846
|
+
appendToolCallCard(toolName, toolArgs, block.reason, 'Approved.');
|
|
847
|
+
} else if (block.content.indexOf(deniedPrefix) === 0) {
|
|
848
|
+
appendToolCallCard(toolName, toolArgs, block.content.slice(deniedPrefix.length), 'Denied.');
|
|
849
|
+
} else if (block.content.indexOf(skippedPrefix) === 0) {
|
|
850
|
+
appendToolCallCard(toolName, toolArgs, block.content.slice(skippedPrefix.length), 'Skipped.');
|
|
851
|
+
} else {
|
|
852
|
+
appendToolCallCard(toolName, toolArgs, block.content, 'Error.');
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (typeof msg.content === 'string') {
|
|
859
|
+
appendChatMessage(msg.role, msg.content);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (!Array.isArray(msg.content)) return;
|
|
863
|
+
var text = msg.content
|
|
864
|
+
.filter(function (b) { return b.type === 'text'; })
|
|
865
|
+
.map(function (b) { return b.text; })
|
|
866
|
+
.join('\\n');
|
|
867
|
+
if (text) appendChatMessage(msg.role, text);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function resumeSession(item) {
|
|
871
|
+
if (isSending || item.sessionId === sessionId) return;
|
|
872
|
+
agentSelect.value = item.agent;
|
|
873
|
+
updateCaption();
|
|
874
|
+
sessionId = item.sessionId;
|
|
875
|
+
sessionLabel.textContent = 'session: ' + sessionId;
|
|
876
|
+
sessionLabel.title = 'Click to copy session id';
|
|
877
|
+
sessionLabel.classList.add('copyable');
|
|
878
|
+
chatPane.textContent = '';
|
|
879
|
+
toolCallCardsById = {};
|
|
880
|
+
setEmptyHint(timelinePane, 'Loop events (tool calls, permission checks, budget checks) will appear here as the agent runs.');
|
|
881
|
+
renderSessionsPane();
|
|
882
|
+
|
|
883
|
+
fetch('/agents/' + encodeURIComponent(item.agent) + '/sessions/' + encodeURIComponent(item.sessionId))
|
|
884
|
+
.then(function (r) { return r.json(); })
|
|
885
|
+
.then(function (data) {
|
|
886
|
+
var history = data.history || [];
|
|
887
|
+
var lastRenderedUserText = null;
|
|
888
|
+
var toolCallsById = {};
|
|
889
|
+
for (var i = 0; i < history.length; i++) {
|
|
890
|
+
renderHistoryMessage(history[i], toolCallsById);
|
|
891
|
+
if (history[i].role === 'user' && typeof history[i].content === 'string') lastRenderedUserText = history[i].content;
|
|
892
|
+
}
|
|
893
|
+
if (!history.length) setEmptyHint(chatPane, 'This session has no messages yet.');
|
|
894
|
+
// The turn that's currently pending (see checkForPendingItems
|
|
895
|
+
// below), if there is one, hasn't been persisted at all yet —
|
|
896
|
+
// withSession only appends a whole turn once it fully completes,
|
|
897
|
+
// human-wait included (see session-store.ts's own withSession) —
|
|
898
|
+
// so the very message that triggered it wouldn't show up above,
|
|
899
|
+
// making the pending card look like it came out of nowhere. The
|
|
900
|
+
// sidebar already has it locally regardless (rememberSession
|
|
901
|
+
// saves it the moment it's sent), so fall back to that instead.
|
|
902
|
+
if (item.preview && item.preview !== lastRenderedUserText) {
|
|
903
|
+
clearEmptyHint(chatPane);
|
|
904
|
+
appendChatMessage('user', item.preview);
|
|
905
|
+
|
|
906
|
+
// Same reasoning, one message later: the turn's own assistant
|
|
907
|
+
// text (its "I'll do X" alongside the tool_use an approval/
|
|
908
|
+
// question card is about) hasn't persisted yet either, for the
|
|
909
|
+
// exact same reason the user's own message above hasn't — only
|
|
910
|
+
// reachable inside this branch since it can only be genuinely
|
|
911
|
+
// pending when the user message itself is too (withSession
|
|
912
|
+
// persists a whole turn atomically, never half of one).
|
|
913
|
+
// pendingAssistantText is written live by handleFrame's own
|
|
914
|
+
// 'assistant:text' branch and cleared the moment the turn
|
|
915
|
+
// actually finishes, so a stale leftover here can't happen —
|
|
916
|
+
// it's only ever set while this exact window is still open.
|
|
917
|
+
// Without this, resuming while an approval/question is pending
|
|
918
|
+
// showed only the bare card, with the reasoning it was
|
|
919
|
+
// responding to nowhere on the page until the card was decided.
|
|
920
|
+
if (item.pendingAssistantText) appendChatMessage('assistant', item.pendingAssistantText);
|
|
921
|
+
}
|
|
922
|
+
checkForPendingItems(item.agent, item.sessionId);
|
|
923
|
+
})
|
|
924
|
+
.catch(function (err) {
|
|
925
|
+
setEmptyHint(chatPane, 'Could not load this session: ' + err.message);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function checkForPendingItems(agent, sessionId) {
|
|
930
|
+
var resumeContext = { agent: agent, sessionId: sessionId };
|
|
931
|
+
fetch('/agents/' + encodeURIComponent(agent) + '/sessions/' + encodeURIComponent(sessionId) + '/questions')
|
|
932
|
+
.then(function (r) { return r.json(); })
|
|
933
|
+
.then(function (data) {
|
|
934
|
+
var questions = data.questions || [];
|
|
935
|
+
for (var i = 0; i < questions.length; i++) appendQuestionCard(questions[i], resumeContext);
|
|
936
|
+
})
|
|
937
|
+
.catch(function () {});
|
|
938
|
+
fetch('/agents/' + encodeURIComponent(agent) + '/sessions/' + encodeURIComponent(sessionId) + '/approvals')
|
|
939
|
+
.then(function (r) { return r.json(); })
|
|
940
|
+
.then(function (data) {
|
|
941
|
+
var approvals = data.approvals || [];
|
|
942
|
+
for (var i = 0; i < approvals.length; i++) appendApprovalCard(approvals[i], resumeContext);
|
|
943
|
+
})
|
|
944
|
+
.catch(function () {});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function appendTimelineEntry(eventName, data) {
|
|
948
|
+
clearEmptyHint(timelinePane);
|
|
949
|
+
var div = document.createElement('div');
|
|
950
|
+
div.className = 'event ' + eventCategory(eventName);
|
|
951
|
+
var label = document.createElement('div');
|
|
952
|
+
label.className = 'event-label';
|
|
953
|
+
label.textContent = eventName;
|
|
954
|
+
var pre = document.createElement('pre');
|
|
955
|
+
pre.className = 'event-data';
|
|
956
|
+
// Every LoopEvent's own 'type' field already duplicates eventName
|
|
957
|
+
// (the SSE event name above it) - stripped here so the JSON blob
|
|
958
|
+
// shows only what's specific to this event, not the label twice.
|
|
959
|
+
var displayData = data;
|
|
960
|
+
if (data && typeof data === 'object' && 'type' in data) {
|
|
961
|
+
displayData = Object.assign({}, data);
|
|
962
|
+
delete displayData.type;
|
|
963
|
+
}
|
|
964
|
+
pre.textContent = JSON.stringify(displayData, null, 2);
|
|
965
|
+
div.appendChild(label);
|
|
966
|
+
div.appendChild(pre);
|
|
967
|
+
timelinePane.appendChild(div);
|
|
968
|
+
timelinePane.scrollTop = timelinePane.scrollHeight;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function parseExtraHeaders(text) {
|
|
972
|
+
var result = {};
|
|
973
|
+
var lines = text.split('\\n');
|
|
974
|
+
for (var i = 0; i < lines.length; i++) {
|
|
975
|
+
var line = lines[i].trim();
|
|
976
|
+
if (!line) continue;
|
|
977
|
+
var idx = line.indexOf(':');
|
|
978
|
+
if (idx === -1) continue;
|
|
979
|
+
var key = line.slice(0, idx).trim();
|
|
980
|
+
var value = line.slice(idx + 1).trim();
|
|
981
|
+
if (key) result[key] = value;
|
|
982
|
+
}
|
|
983
|
+
return result;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function parseExtraBody(text) {
|
|
987
|
+
var trimmed = text.trim();
|
|
988
|
+
if (!trimmed) return {};
|
|
989
|
+
try {
|
|
990
|
+
return JSON.parse(trimmed);
|
|
991
|
+
} catch (err) {
|
|
992
|
+
appendChatMessage('error', 'Extra body fields must be valid JSON: ' + err.message);
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function updateCaption() {
|
|
998
|
+
var name = agentSelect.value;
|
|
999
|
+
if (!currentAgents.length) {
|
|
1000
|
+
agentCaption.textContent = 'No agents registered — add one under agents/.';
|
|
1001
|
+
configLink.style.display = 'none';
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
configLink.style.display = '';
|
|
1005
|
+
var agent = null;
|
|
1006
|
+
for (var i = 0; i < currentAgents.length; i++) {
|
|
1007
|
+
if (currentAgents[i].name === name) { agent = currentAgents[i]; break; }
|
|
1008
|
+
}
|
|
1009
|
+
agentCaption.textContent = agent ? agent.systemPrompt : '';
|
|
1010
|
+
configLink.href = '/agents/config' + (name ? '?agent=' + encodeURIComponent(name) : '');
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function updateSendButtonState() {
|
|
1014
|
+
sendButton.disabled = isSending || !messageInput.value.trim() || !agentSelect.value;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function setSending(sending) {
|
|
1018
|
+
isSending = sending;
|
|
1019
|
+
var hasAgents = currentAgents.length > 0;
|
|
1020
|
+
messageInput.disabled = sending || !hasAgents;
|
|
1021
|
+
agentSelect.disabled = sending || !hasAgents;
|
|
1022
|
+
newConversationButton.disabled = sending;
|
|
1023
|
+
sendButton.textContent = sending ? 'Sending\\u2026' : 'Send';
|
|
1024
|
+
updateSendButtonState();
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function resetConversation() {
|
|
1028
|
+
sessionId = null;
|
|
1029
|
+
toolCallCardsById = {};
|
|
1030
|
+
removeThinking();
|
|
1031
|
+
sessionLabel.textContent = 'session: (new)';
|
|
1032
|
+
sessionLabel.classList.remove('copyable');
|
|
1033
|
+
sessionLabel.title = '';
|
|
1034
|
+
var name = agentSelect.value;
|
|
1035
|
+
setEmptyHint(chatPane, name ? 'No messages yet \\u2014 say hi to ' + name + '.' : 'No messages yet \\u2014 pick an agent above to get started.');
|
|
1036
|
+
setEmptyHint(timelinePane, 'Loop events (tool calls, permission checks, budget checks) will appear here as the agent runs.');
|
|
1037
|
+
renderSessionsPane();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function loadAgents() {
|
|
1041
|
+
fetch('/agents')
|
|
1042
|
+
.then(function (r) { return r.json(); })
|
|
1043
|
+
.then(function (data) {
|
|
1044
|
+
currentAgents = data.agents || [];
|
|
1045
|
+
agentSelect.textContent = '';
|
|
1046
|
+
for (var i = 0; i < currentAgents.length; i++) {
|
|
1047
|
+
var opt = document.createElement('option');
|
|
1048
|
+
opt.value = currentAgents[i].name;
|
|
1049
|
+
opt.textContent = currentAgents[i].name;
|
|
1050
|
+
agentSelect.appendChild(opt);
|
|
1051
|
+
}
|
|
1052
|
+
// Deep-linked from the agents list or config page (?agent=name) —
|
|
1053
|
+
// preselect it if it's a real, currently-registered agent.
|
|
1054
|
+
var requested = new URLSearchParams(location.search).get('agent');
|
|
1055
|
+
if (requested && currentAgents.some(function (a) { return a.name === requested; })) {
|
|
1056
|
+
agentSelect.value = requested;
|
|
1057
|
+
}
|
|
1058
|
+
var hasAgents = currentAgents.length > 0;
|
|
1059
|
+
agentSelect.disabled = !hasAgents;
|
|
1060
|
+
messageInput.disabled = !hasAgents;
|
|
1061
|
+
messageInput.placeholder = hasAgents
|
|
1062
|
+
? 'Message\\u2026 (Enter to send, Shift+Enter for a new line)'
|
|
1063
|
+
: 'No agents registered';
|
|
1064
|
+
updateCaption();
|
|
1065
|
+
resetConversation();
|
|
1066
|
+
updateSendButtonState();
|
|
1067
|
+
if (hasAgents) messageInput.focus();
|
|
1068
|
+
})
|
|
1069
|
+
.catch(function (err) {
|
|
1070
|
+
agentCaption.textContent = 'Could not load agents: ' + err.message;
|
|
1071
|
+
resetConversation();
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function handleFrame(frame) {
|
|
1076
|
+
var lines = frame.split('\\n');
|
|
1077
|
+
var eventName = 'message';
|
|
1078
|
+
var dataLine = '';
|
|
1079
|
+
for (var i = 0; i < lines.length; i++) {
|
|
1080
|
+
if (lines[i].indexOf('event: ') === 0) eventName = lines[i].slice(7);
|
|
1081
|
+
else if (lines[i].indexOf('data: ') === 0) dataLine = lines[i].slice(6);
|
|
1082
|
+
}
|
|
1083
|
+
var data;
|
|
1084
|
+
try {
|
|
1085
|
+
data = JSON.parse(dataLine);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
data = dataLine;
|
|
1088
|
+
}
|
|
1089
|
+
if (eventName === 'session') {
|
|
1090
|
+
sessionId = data.sessionId;
|
|
1091
|
+
sessionLabel.textContent = 'session: ' + sessionId;
|
|
1092
|
+
sessionLabel.title = 'Click to copy session id';
|
|
1093
|
+
sessionLabel.classList.add('copyable');
|
|
1094
|
+
if (lastSentMessage) {
|
|
1095
|
+
rememberSession(agentSelect.value, sessionId, lastSentMessage);
|
|
1096
|
+
lastSentMessage = null;
|
|
1097
|
+
}
|
|
1098
|
+
} else if (eventName === 'done') {
|
|
1099
|
+
removeThinking();
|
|
1100
|
+
var doneEl = appendChatMessage('assistant', data.text);
|
|
1101
|
+
// stopReason (see run-agent.ts's own RunAgentResult) marks this as
|
|
1102
|
+
// a synthetic notice, not something the model actually said — a
|
|
1103
|
+
// denied tool call stopping the loop right there, same as
|
|
1104
|
+
// max_turns already could. Visually distinct so it doesn't read
|
|
1105
|
+
// like a normal finished answer.
|
|
1106
|
+
if (data.stopReason) doneEl.classList.add('msg-stopped');
|
|
1107
|
+
if (sessionId) updateSessionField(agentSelect.value, sessionId, 'pendingAssistantText', null);
|
|
1108
|
+
} else if (eventName === 'error') {
|
|
1109
|
+
removeThinking();
|
|
1110
|
+
appendChatMessage('error', data.error);
|
|
1111
|
+
if (sessionId) updateSessionField(agentSelect.value, sessionId, 'pendingAssistantText', null);
|
|
1112
|
+
} else if (eventName === 'assistant:text') {
|
|
1113
|
+
removeThinking();
|
|
1114
|
+
appendChatMessage('assistant', data.text);
|
|
1115
|
+
showThinking();
|
|
1116
|
+
if (sessionId) updateSessionField(agentSelect.value, sessionId, 'pendingAssistantText', data.text);
|
|
1117
|
+
} else if (eventName === 'tool:started') {
|
|
1118
|
+
// Fired the instant any call is decided, auto-allowed or
|
|
1119
|
+
// interactively asked alike (see run-agent.ts's own 'tool:started'
|
|
1120
|
+
// log call - it emits this unconditionally now, no notion of
|
|
1121
|
+
// "already shown elsewhere"). An interactively-asked call already
|
|
1122
|
+
// has its own approval card showing this decision (see
|
|
1123
|
+
// appendApprovalCard/wasAskedInteractively above) - no redundant
|
|
1124
|
+
// 'Running…' card needed for it, unlike an auto-allowed call which
|
|
1125
|
+
// had nothing shown until now.
|
|
1126
|
+
removeThinking();
|
|
1127
|
+
if (!wasAskedInteractively(data.detailText)) {
|
|
1128
|
+
appendToolCallCard(data.tool, data.args, data.detailText, 'Running\\u2026', data.id);
|
|
1129
|
+
}
|
|
1130
|
+
showThinking();
|
|
1131
|
+
appendTimelineEntry(eventName, data);
|
|
1132
|
+
} else if (eventName === 'tool:result') {
|
|
1133
|
+
// A tool call's own resolution (see run-agent.ts's own 'tool:result'
|
|
1134
|
+
// log call, also unconditional now) - auto-allowed, approved,
|
|
1135
|
+
// denied, or skipped alike. An interactively-approved call that
|
|
1136
|
+
// then executed successfully already has its own approval card
|
|
1137
|
+
// saying "Approved." - redundant to show again. A rejected
|
|
1138
|
+
// execution is new information that card never had (it only ever
|
|
1139
|
+
// showed the *decision*, not what running the call actually did),
|
|
1140
|
+
// so it still gets shown regardless.
|
|
1141
|
+
removeThinking();
|
|
1142
|
+
if (!wasAskedInteractively(data.detailText) || data.statusText === 'Error.') {
|
|
1143
|
+
// A call that got its own 'tool:started' card above updates it in
|
|
1144
|
+
// place (updateToolCallCard); one that didn't (denied/skipped, or
|
|
1145
|
+
// the rejected-after-interactive-approval case just above) gets a
|
|
1146
|
+
// fresh card instead - same fallback renderHistoryMessage's replay
|
|
1147
|
+
// of stored history already relies on for a resumed session, which
|
|
1148
|
+
// never sees 'tool:started' at all.
|
|
1149
|
+
if (!updateToolCallCard(data.id, data.statusText)) {
|
|
1150
|
+
appendToolCallCard(data.tool, data.args, data.detailText, data.statusText, data.id);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
showThinking();
|
|
1154
|
+
appendTimelineEntry(eventName, data);
|
|
1155
|
+
} else if (eventName === 'approval:pending') {
|
|
1156
|
+
removeThinking();
|
|
1157
|
+
appendApprovalCard(data);
|
|
1158
|
+
appendTimelineEntry(eventName, data);
|
|
1159
|
+
} else if (eventName === 'question:pending') {
|
|
1160
|
+
removeThinking();
|
|
1161
|
+
appendQuestionCard(data);
|
|
1162
|
+
appendTimelineEntry(eventName, data);
|
|
1163
|
+
} else {
|
|
1164
|
+
appendTimelineEntry(eventName, data);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function sendMessage() {
|
|
1169
|
+
if (isSending) return;
|
|
1170
|
+
var agent = agentSelect.value;
|
|
1171
|
+
var message = messageInput.value.trim();
|
|
1172
|
+
if (!agent || !message) return;
|
|
1173
|
+
|
|
1174
|
+
var extraHeaders = parseExtraHeaders(extraHeadersInput.value);
|
|
1175
|
+
var extraBody = parseExtraBody(extraBodyInput.value);
|
|
1176
|
+
if (extraBody === null) return;
|
|
1177
|
+
|
|
1178
|
+
var body = { message: message };
|
|
1179
|
+
if (sessionId) body.sessionId = sessionId;
|
|
1180
|
+
for (var key in extraBody) body[key] = extraBody[key];
|
|
1181
|
+
|
|
1182
|
+
var headers = { 'content-type': 'application/json' };
|
|
1183
|
+
for (var hk in extraHeaders) headers[hk] = extraHeaders[hk];
|
|
1184
|
+
|
|
1185
|
+
appendChatMessage('user', message);
|
|
1186
|
+
lastSentMessage = message;
|
|
1187
|
+
messageInput.value = '';
|
|
1188
|
+
setSending(true);
|
|
1189
|
+
showThinking();
|
|
1190
|
+
|
|
1191
|
+
function finish() {
|
|
1192
|
+
removeThinking();
|
|
1193
|
+
setSending(false);
|
|
1194
|
+
messageInput.focus();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
fetch('/agents/' + encodeURIComponent(agent) + '/messages/stream', {
|
|
1198
|
+
method: 'POST',
|
|
1199
|
+
headers: headers,
|
|
1200
|
+
body: JSON.stringify(body),
|
|
1201
|
+
})
|
|
1202
|
+
.then(function (response) {
|
|
1203
|
+
var contentType = response.headers.get('content-type') || '';
|
|
1204
|
+
if (contentType.indexOf('text/event-stream') === -1) {
|
|
1205
|
+
return response.json()
|
|
1206
|
+
.catch(function () { return { error: 'HTTP ' + response.status }; })
|
|
1207
|
+
.then(function (data) {
|
|
1208
|
+
appendChatMessage('error', data.error || ('HTTP ' + response.status));
|
|
1209
|
+
finish();
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
var reader = response.body.getReader();
|
|
1214
|
+
var decoder = new TextDecoder();
|
|
1215
|
+
var buffer = '';
|
|
1216
|
+
|
|
1217
|
+
function pump() {
|
|
1218
|
+
return reader.read().then(function (chunk) {
|
|
1219
|
+
if (chunk.done) {
|
|
1220
|
+
buffer += decoder.decode();
|
|
1221
|
+
finish();
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
1225
|
+
var idx;
|
|
1226
|
+
while ((idx = buffer.indexOf('\\n\\n')) !== -1) {
|
|
1227
|
+
var frame = buffer.slice(0, idx);
|
|
1228
|
+
buffer = buffer.slice(idx + 2);
|
|
1229
|
+
handleFrame(frame);
|
|
1230
|
+
}
|
|
1231
|
+
return pump();
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return pump();
|
|
1236
|
+
})
|
|
1237
|
+
.catch(function (err) {
|
|
1238
|
+
appendChatMessage('error', 'Network error: ' + err.message);
|
|
1239
|
+
finish();
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
agentSelect.addEventListener('change', function () {
|
|
1244
|
+
updateCaption();
|
|
1245
|
+
resetConversation();
|
|
1246
|
+
updateSendButtonState();
|
|
1247
|
+
});
|
|
1248
|
+
messageInput.addEventListener('input', updateSendButtonState);
|
|
1249
|
+
newConversationButton.addEventListener('click', resetConversation);
|
|
1250
|
+
sendButton.addEventListener('click', sendMessage);
|
|
1251
|
+
messageInput.addEventListener('keydown', function (e) {
|
|
1252
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1253
|
+
e.preventDefault();
|
|
1254
|
+
sendMessage();
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
sessionLabel.addEventListener('click', function () {
|
|
1258
|
+
if (!sessionId || !navigator.clipboard) return;
|
|
1259
|
+
navigator.clipboard.writeText(sessionId).then(function () {
|
|
1260
|
+
var original = 'session: ' + sessionId;
|
|
1261
|
+
sessionLabel.textContent = 'copied!';
|
|
1262
|
+
setTimeout(function () { sessionLabel.textContent = original; }, 1000);
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
loadAgents();
|
|
1267
|
+
})();
|
|
1268
|
+
</script>
|
|
1269
|
+
</body>
|
|
1270
|
+
</html>
|
|
1271
|
+
`;
|