@stratusagent/dashboard 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/ui/app.js +370 -0
- package/ui/favicon.svg +4 -0
- package/ui/index.html +10 -0
- package/ui/lib/api.js +120 -0
- package/ui/lib/avatar.js +28 -0
- package/ui/lib/dom.js +83 -0
- package/ui/styles.css +208 -0
- package/ui/views/agent.js +704 -0
- package/ui/views/dashboard.js +148 -0
- package/ui/views/plugins.js +86 -0
- package/ui/views/settings.js +365 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { ago, el } from '../lib/dom.js';
|
|
2
|
+
import { api } from '../lib/api.js';
|
|
3
|
+
import { avatar } from '../lib/avatar.js';
|
|
4
|
+
import { onEnvelope, refreshCore, store } from '../app.js';
|
|
5
|
+
|
|
6
|
+
/** A conversation started from this page, distinguishable from a Slack thread. */
|
|
7
|
+
const newSessionId = (agentId) => `web:${agentId}:${crypto.randomUUID()}`;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Session ids are addresses, not names — a Slack thread key or a UUID. Show
|
|
11
|
+
* enough to tell two apart without letting one eat the row it sits in.
|
|
12
|
+
*/
|
|
13
|
+
const shortId = (id) => (id.length <= 28 ? id : `${id.slice(0, 12)}…${id.slice(-8)}`);
|
|
14
|
+
|
|
15
|
+
const TOOL_OUTPUT_LIMIT = 260;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A tool result, as a person would want to read it.
|
|
19
|
+
*
|
|
20
|
+
* The stored message is the raw result envelope — call id, tool name, ok
|
|
21
|
+
* flag, output — because that is what the model consumed. Rendering it
|
|
22
|
+
* verbatim puts a wall of JSON with an internal id in the middle of a
|
|
23
|
+
* conversation, so this pulls out the part a reader is actually asking about
|
|
24
|
+
* and leaves the rest to the transcript in the store.
|
|
25
|
+
*/
|
|
26
|
+
const summariseTool = (content) => {
|
|
27
|
+
const text = String(content ?? '');
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
return text.length > TOOL_OUTPUT_LIMIT ? `${text.slice(0, TOOL_OUTPUT_LIMIT)}…` : text;
|
|
33
|
+
}
|
|
34
|
+
if (typeof parsed !== 'object' || parsed === null || !parsed.toolName) {
|
|
35
|
+
return text.length > TOOL_OUTPUT_LIMIT ? `${text.slice(0, TOOL_OUTPUT_LIMIT)}…` : text;
|
|
36
|
+
}
|
|
37
|
+
const outcome = parsed.ok === false
|
|
38
|
+
? `failed — ${parsed.error ?? 'no reason given'}`
|
|
39
|
+
: typeof parsed.output === 'string' ? parsed.output : JSON.stringify(parsed.output);
|
|
40
|
+
const body = String(outcome ?? '');
|
|
41
|
+
return `${parsed.toolName} → ${body.length > TOOL_OUTPUT_LIMIT ? `${body.slice(0, TOOL_OUTPUT_LIMIT)}…` : body}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const renderAgent = (agentId, initialSessionId) => {
|
|
45
|
+
const state = {
|
|
46
|
+
tab: 'chat',
|
|
47
|
+
sessions: [],
|
|
48
|
+
sessionId: initialSessionId,
|
|
49
|
+
/**
|
|
50
|
+
* True while this conversation exists only in the page.
|
|
51
|
+
*
|
|
52
|
+
* A session id minted here has nothing stored behind it until the first
|
|
53
|
+
* message lands, so asking the daemon for it would 404 — correctly, and
|
|
54
|
+
* confusingly: a red line in the console for the ordinary act of opening
|
|
55
|
+
* a new conversation.
|
|
56
|
+
*/
|
|
57
|
+
unsent: initialSessionId === undefined,
|
|
58
|
+
/** Canonical history, re-read from the daemon whenever a turn settles. */
|
|
59
|
+
messages: [],
|
|
60
|
+
/** The reply currently arriving, token by token. */
|
|
61
|
+
streaming: '',
|
|
62
|
+
/** Tool status lines for the turn in flight. */
|
|
63
|
+
toolLines: [],
|
|
64
|
+
turnId: undefined,
|
|
65
|
+
/**
|
|
66
|
+
* Envelopes that arrived before the POST said which turn is ours.
|
|
67
|
+
*
|
|
68
|
+
* The dispatch starts before the response is read, so its first events
|
|
69
|
+
* can beat the turn id here. Dropping them would lose the opening tokens
|
|
70
|
+
* of every reply; accepting them blind would mix in another client's
|
|
71
|
+
* turn on the same conversation. So they wait, and are replayed once the
|
|
72
|
+
* id lands.
|
|
73
|
+
*/
|
|
74
|
+
pending: [],
|
|
75
|
+
sending: false,
|
|
76
|
+
/**
|
|
77
|
+
* Conversations whose POST is unanswered, by session id.
|
|
78
|
+
*
|
|
79
|
+
* Distinct from `sending`, which stays true for the whole turn: this is
|
|
80
|
+
* the window in which the store cannot answer for the turn at all — a new
|
|
81
|
+
* conversation has nothing stored, and an existing one still carries the
|
|
82
|
+
* *previous* turn's terminal status until the dispatch saves. Anything
|
|
83
|
+
* that reconciles against the store during it would be reading about a
|
|
84
|
+
* turn that has not started.
|
|
85
|
+
*
|
|
86
|
+
* Keyed rather than a flag, because sends overlap across conversations: a
|
|
87
|
+
* message to A, then a switch to B and a message there, leaves two in
|
|
88
|
+
* flight — and A's response clearing a shared flag would tell this view
|
|
89
|
+
* that B has nothing pending, which is exactly when clearing B's turn
|
|
90
|
+
* does damage.
|
|
91
|
+
*/
|
|
92
|
+
awaitingSend: new Set(),
|
|
93
|
+
/**
|
|
94
|
+
* Conversations that owe a reconciliation once their POST settles.
|
|
95
|
+
*
|
|
96
|
+
* `reconcile` cannot decide anything while a send is outstanding — the
|
|
97
|
+
* store still reads as the previous turn — so it returns. But nothing
|
|
98
|
+
* replays what the stream missed, and no second reconnect is coming, so
|
|
99
|
+
* a turn that finished during the outage would leave the composer on
|
|
100
|
+
* "Working…" for good. The debt is recorded here and paid the moment the
|
|
101
|
+
* response lands, which is the earliest point anything can decide.
|
|
102
|
+
*/
|
|
103
|
+
reconcileAfterSend: new Set(),
|
|
104
|
+
/**
|
|
105
|
+
* The turn this page started on each conversation, by session id.
|
|
106
|
+
*
|
|
107
|
+
* `turnId` above is only the selected conversation's. Switching away and
|
|
108
|
+
* back used to clear it, which enabled the composer on a turn that was
|
|
109
|
+
* still running and made the rest of that turn's envelopes fail
|
|
110
|
+
* `isOurTurn` — so its reply rendered nowhere and its completion cleared
|
|
111
|
+
* nothing. An entry lives from the POST that minted it until that turn
|
|
112
|
+
* ends, or until the store says it already has.
|
|
113
|
+
*/
|
|
114
|
+
turns: new Map(),
|
|
115
|
+
error: undefined,
|
|
116
|
+
saved: undefined,
|
|
117
|
+
/**
|
|
118
|
+
* The agent's full soul, fetched for editing.
|
|
119
|
+
*
|
|
120
|
+
* The roster carries `persona`: the first line of the instructions,
|
|
121
|
+
* trimmed to fit a table row. Seeding the editor from it and saving
|
|
122
|
+
* would write that snippet back as the whole persona, so the settings
|
|
123
|
+
* tab waits for the real thing.
|
|
124
|
+
*/
|
|
125
|
+
soul: undefined,
|
|
126
|
+
soulError: undefined,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const node = el('div', { class: 'main' });
|
|
130
|
+
const transcriptBox = el('div', { class: 'transcript' });
|
|
131
|
+
const composer = el('textarea', {
|
|
132
|
+
placeholder: 'Message this agent…',
|
|
133
|
+
rows: 2,
|
|
134
|
+
onKeyDown: (event) => {
|
|
135
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
void send();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const agent = () => store.agents.find((candidate) => candidate.id === agentId);
|
|
143
|
+
|
|
144
|
+
// ---- transcript --------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
const bubbles = () => {
|
|
147
|
+
const nodes = [];
|
|
148
|
+
for (const message of state.messages) {
|
|
149
|
+
if (message.role === 'system') {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (message.role === 'tool') {
|
|
153
|
+
nodes.push(el('div', { class: 'bubble tool' }, summariseTool(message.content)));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!message.content) {
|
|
157
|
+
// An assistant turn that only carried tool calls has no text to show;
|
|
158
|
+
// the tool bubbles beside it are the story.
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
nodes.push(el('div', { class: `bubble ${message.role}` }, message.content));
|
|
162
|
+
}
|
|
163
|
+
for (const line of state.toolLines) {
|
|
164
|
+
nodes.push(el('div', { class: 'bubble tool' }, line));
|
|
165
|
+
}
|
|
166
|
+
if (state.streaming) {
|
|
167
|
+
nodes.push(el('div', { class: 'bubble assistant streaming' }, state.streaming));
|
|
168
|
+
}
|
|
169
|
+
return nodes;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const paintTranscript = () => {
|
|
173
|
+
// Pinned to the bottom only when the reader already was: yanking someone
|
|
174
|
+
// back down while they are reading earlier output is worse than a reply
|
|
175
|
+
// they have to scroll to.
|
|
176
|
+
const pinned = transcriptBox.scrollHeight - transcriptBox.scrollTop - transcriptBox.clientHeight < 60;
|
|
177
|
+
transcriptBox.replaceChildren(...bubbles());
|
|
178
|
+
if (pinned) {
|
|
179
|
+
transcriptBox.scrollTop = transcriptBox.scrollHeight;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ---- data --------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
const loadSessions = async () => {
|
|
186
|
+
const listed = await api.sessions(agentId, 50).catch(() => ({ sessions: [] }));
|
|
187
|
+
state.sessions = listed.sessions ?? [];
|
|
188
|
+
if (!state.sessionId) {
|
|
189
|
+
const newest = state.sessions[0]?.id;
|
|
190
|
+
state.sessionId = newest ?? newSessionId(agentId);
|
|
191
|
+
state.unsent = newest === undefined;
|
|
192
|
+
}
|
|
193
|
+
await loadTranscript();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const loadTranscript = async () => {
|
|
197
|
+
if (!state.sessionId) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (state.unsent) {
|
|
201
|
+
// Nothing to fetch: this conversation has never been sent anywhere.
|
|
202
|
+
state.messages = [];
|
|
203
|
+
state.streaming = '';
|
|
204
|
+
state.toolLines = [];
|
|
205
|
+
paintTranscript();
|
|
206
|
+
update();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Which conversation this fetch is for. Opening B while A is still in
|
|
210
|
+
// flight would otherwise land A's history under B's selector, and the
|
|
211
|
+
// next message typed would go to B while showing A — so a response that
|
|
212
|
+
// arrives after the selection moved on is dropped.
|
|
213
|
+
const requested = state.sessionId;
|
|
214
|
+
let messages;
|
|
215
|
+
try {
|
|
216
|
+
const { session } = await api.session(requested);
|
|
217
|
+
messages = session.messages ?? [];
|
|
218
|
+
} catch {
|
|
219
|
+
// Raced with a turn that has not saved yet, or a session removed out
|
|
220
|
+
// from under the page. An empty transcript is the honest render.
|
|
221
|
+
messages = [];
|
|
222
|
+
}
|
|
223
|
+
if (state.sessionId !== requested) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
state.messages = messages;
|
|
227
|
+
state.streaming = '';
|
|
228
|
+
state.toolLines = [];
|
|
229
|
+
paintTranscript();
|
|
230
|
+
update();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The full soul, read once and only when the settings tab needs it.
|
|
235
|
+
*
|
|
236
|
+
* Guarded rather than awaited from the render, because `update()` runs on
|
|
237
|
+
* every event from every agent and an unguarded fetch there would open one
|
|
238
|
+
* request per event.
|
|
239
|
+
*/
|
|
240
|
+
let soulLoading = false;
|
|
241
|
+
const loadSoul = async () => {
|
|
242
|
+
if (soulLoading || state.soul) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
soulLoading = true;
|
|
246
|
+
try {
|
|
247
|
+
state.soul = await api.agent(agentId);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
state.soulError = `Could not read this agent's soul file: ${error.message}`;
|
|
250
|
+
} finally {
|
|
251
|
+
soulLoading = false;
|
|
252
|
+
}
|
|
253
|
+
update();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const openSession = (sessionId) => {
|
|
257
|
+
state.sessionId = sessionId;
|
|
258
|
+
state.unsent = false;
|
|
259
|
+
state.pending = [];
|
|
260
|
+
// Restored, not cleared: this page may still be waiting on a turn here.
|
|
261
|
+
state.turnId = state.turns.get(sessionId);
|
|
262
|
+
state.sending = state.turnId !== undefined || state.awaitingSend.has(sessionId);
|
|
263
|
+
state.tab = 'chat';
|
|
264
|
+
void loadTranscript();
|
|
265
|
+
if (state.sending) {
|
|
266
|
+
// It may equally have finished while we were looking elsewhere — the
|
|
267
|
+
// completion only reaches `applyEnvelope` for the selected
|
|
268
|
+
// conversation. The store is what settles that, and `reconcile` already
|
|
269
|
+
// knows how to ask it without disturbing a turn that is genuinely live.
|
|
270
|
+
void reconcile();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const startSession = () => {
|
|
275
|
+
state.sessionId = newSessionId(agentId);
|
|
276
|
+
state.unsent = true;
|
|
277
|
+
state.pending = [];
|
|
278
|
+
state.turnId = undefined;
|
|
279
|
+
state.sending = false;
|
|
280
|
+
state.messages = [];
|
|
281
|
+
state.streaming = '';
|
|
282
|
+
state.toolLines = [];
|
|
283
|
+
paintTranscript();
|
|
284
|
+
update();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const send = async () => {
|
|
288
|
+
const text = composer.value.trim();
|
|
289
|
+
// No session id yet means the first `/sessions` listing has not come back,
|
|
290
|
+
// so we do not know whether to resume the newest conversation or mint a
|
|
291
|
+
// new one. Sending anyway posted to `/sessions/undefined/messages`, which
|
|
292
|
+
// the daemon accepts — creating a durable conversation literally named
|
|
293
|
+
// `undefined` — and whose response the arriving listing then discards.
|
|
294
|
+
// The button is disabled for this window; Enter reaches here regardless.
|
|
295
|
+
if (!text || state.sending || !state.sessionId) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
state.sending = true;
|
|
299
|
+
state.error = undefined;
|
|
300
|
+
composer.value = '';
|
|
301
|
+
// Which conversation this message is for. Opening or starting another one
|
|
302
|
+
// while the POST is in flight would otherwise stamp this turn's id onto
|
|
303
|
+
// that conversation — so its own next message would drain its opening
|
|
304
|
+
// events against a turn it never sent, and a brand-new conversation would
|
|
305
|
+
// be marked as stored when nothing had been written for it.
|
|
306
|
+
const sentTo = state.sessionId;
|
|
307
|
+
state.awaitingSend.add(sentTo);
|
|
308
|
+
// Shown immediately rather than waiting for the round trip: the daemon
|
|
309
|
+
// will persist exactly this, and a message that vanishes for a second
|
|
310
|
+
// reads as a dropped one.
|
|
311
|
+
const optimistic = { id: `local:${crypto.randomUUID()}`, role: 'user', content: text };
|
|
312
|
+
state.messages = [...state.messages, optimistic];
|
|
313
|
+
state.streaming = '';
|
|
314
|
+
state.toolLines = [];
|
|
315
|
+
paintTranscript();
|
|
316
|
+
update();
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const { turnId } = await api.send(sentTo, { message: text, agentId });
|
|
320
|
+
state.awaitingSend.delete(sentTo);
|
|
321
|
+
// Recorded before the selection check, so a turn started here is
|
|
322
|
+
// recoverable even when the user walked away before it was answered.
|
|
323
|
+
state.turns.set(sentTo, turnId);
|
|
324
|
+
if (state.sessionId !== sentTo) {
|
|
325
|
+
// The view moved on, and `openSession`/`startSession` already reset
|
|
326
|
+
// the in-flight state it would have reconciled.
|
|
327
|
+
state.reconcileAfterSend.delete(sentTo);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// It exists in the store now, so later reads are real reads.
|
|
331
|
+
state.unsent = false;
|
|
332
|
+
// Kept so deltas can be attributed to *this* message: a session runs
|
|
333
|
+
// its turns in sequence, and without the id there is no telling our
|
|
334
|
+
// reply from the next one.
|
|
335
|
+
state.turnId = turnId;
|
|
336
|
+
const queued = state.pending;
|
|
337
|
+
state.pending = [];
|
|
338
|
+
for (const envelope of queued) {
|
|
339
|
+
applyEnvelope(envelope);
|
|
340
|
+
}
|
|
341
|
+
if (state.reconcileAfterSend.delete(sentTo)) {
|
|
342
|
+
// The stream came back while this POST was in flight. Now there is a
|
|
343
|
+
// turn id and a stored session, so the store can finally say whether
|
|
344
|
+
// the turn we are waiting on already ended during the outage.
|
|
345
|
+
await reconcile();
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
state.awaitingSend.delete(sentTo);
|
|
349
|
+
// Nothing ran, so there is nothing to reconcile against.
|
|
350
|
+
state.reconcileAfterSend.delete(sentTo);
|
|
351
|
+
if (state.sessionId !== sentTo) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Nothing was stored, so nothing should look stored. Left in place, the
|
|
355
|
+
// bubble reads as a sent message forever, and the next message that
|
|
356
|
+
// does succeed renders after a turn that never existed. The text goes
|
|
357
|
+
// back in the composer rather than being lost with it.
|
|
358
|
+
state.messages = state.messages.filter((message) => message !== optimistic);
|
|
359
|
+
if (!composer.value) {
|
|
360
|
+
composer.value = text;
|
|
361
|
+
}
|
|
362
|
+
state.error = error.message;
|
|
363
|
+
state.sending = false;
|
|
364
|
+
paintTranscript();
|
|
365
|
+
update();
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// ---- live --------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Whether this envelope belongs to the turn this page started.
|
|
373
|
+
*
|
|
374
|
+
* A conversation is shared: another browser, a Slack thread, or the CLI can
|
|
375
|
+
* be mid-turn on the same session. Without this check their completion
|
|
376
|
+
* clears our composer and reloads the transcript while our message is still
|
|
377
|
+
* queued, and their tokens land in the reply we are rendering.
|
|
378
|
+
*/
|
|
379
|
+
const isOurTurn = (envelope) => envelope.turnId !== undefined && envelope.turnId === state.turnId;
|
|
380
|
+
|
|
381
|
+
const applyEnvelope = (envelope) => {
|
|
382
|
+
const event = envelope.event;
|
|
383
|
+
|
|
384
|
+
// Anything that changes the stored conversation is worth re-reading even
|
|
385
|
+
// when somebody else caused it — it is the same conversation. Only the
|
|
386
|
+
// in-flight rendering below is ours alone.
|
|
387
|
+
if (!isOurTurn(envelope)) {
|
|
388
|
+
if (event.type === 'session.completed' || event.type === 'session.failed') {
|
|
389
|
+
void loadTranscript();
|
|
390
|
+
void loadSessions();
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
switch (event.type) {
|
|
396
|
+
case 'provider.delta':
|
|
397
|
+
if (event.delta.type === 'text') {
|
|
398
|
+
state.streaming += event.delta.text;
|
|
399
|
+
paintTranscript();
|
|
400
|
+
} else if (event.delta.type === 'reset') {
|
|
401
|
+
// The provider abandoned this attempt — a fallback taking over, or
|
|
402
|
+
// a retry. Everything streamed so far belongs to an answer that is
|
|
403
|
+
// not coming, so it goes rather than being fused with the next one.
|
|
404
|
+
state.streaming = '';
|
|
405
|
+
paintTranscript();
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
case 'tool.called':
|
|
409
|
+
state.toolLines = [...state.toolLines, `running ${event.call.toolName}…`];
|
|
410
|
+
paintTranscript();
|
|
411
|
+
break;
|
|
412
|
+
case 'tool.completed':
|
|
413
|
+
state.toolLines = [...state.toolLines, `${event.result.toolName} ${event.result.ok ? 'finished' : 'failed'}`];
|
|
414
|
+
paintTranscript();
|
|
415
|
+
break;
|
|
416
|
+
case 'tool.denied':
|
|
417
|
+
state.toolLines = [...state.toolLines, `${event.call.toolName} was denied`];
|
|
418
|
+
paintTranscript();
|
|
419
|
+
break;
|
|
420
|
+
case 'tool.approval-requested':
|
|
421
|
+
state.toolLines = [...state.toolLines, `${event.call.toolName} is waiting for approval`];
|
|
422
|
+
paintTranscript();
|
|
423
|
+
break;
|
|
424
|
+
case 'session.failed':
|
|
425
|
+
state.error = event.error;
|
|
426
|
+
state.sending = false;
|
|
427
|
+
state.turnId = undefined;
|
|
428
|
+
state.turns.delete(envelope.sessionId);
|
|
429
|
+
void loadTranscript();
|
|
430
|
+
break;
|
|
431
|
+
case 'session.completed':
|
|
432
|
+
state.sending = false;
|
|
433
|
+
state.turnId = undefined;
|
|
434
|
+
state.turns.delete(envelope.sessionId);
|
|
435
|
+
// Re-read rather than keeping what was streamed: the stored
|
|
436
|
+
// transcript is what every other surface will show, and a page that
|
|
437
|
+
// quietly disagrees with it is worse than one that flickers.
|
|
438
|
+
void loadTranscript();
|
|
439
|
+
void loadSessions();
|
|
440
|
+
break;
|
|
441
|
+
default:
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const detach = onEnvelope((envelope) => {
|
|
447
|
+
if (envelope.sessionId !== state.sessionId) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (state.sending && state.turnId === undefined) {
|
|
451
|
+
// Our own dispatch is in flight and has not told us its id yet.
|
|
452
|
+
state.pending.push(envelope);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
applyEnvelope(envelope);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ---- tabs --------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
const chatTab = () => el('div', { class: 'chat' },
|
|
461
|
+
transcriptBox,
|
|
462
|
+
state.error ? el('div', { class: 'notice bad' }, state.error) : null,
|
|
463
|
+
el('div', { class: 'composer' },
|
|
464
|
+
composer,
|
|
465
|
+
el('button', { class: 'primary', disabled: state.sending || !state.sessionId, onClick: () => void send() },
|
|
466
|
+
state.sending ? 'Working…' : 'Send'),
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const activityTab = () => {
|
|
471
|
+
const ours = new Set(state.sessions.map((session) => session.id));
|
|
472
|
+
ours.add(state.sessionId);
|
|
473
|
+
const lines = store.activity.filter((line) => ours.has(line.sessionId)).slice(0, 80);
|
|
474
|
+
return lines.length === 0
|
|
475
|
+
? el('p', { class: 'empty' }, 'Nothing yet for this agent.')
|
|
476
|
+
: el('div', { class: 'activity' }, ...lines.map((line) => el('div', { class: 'line' },
|
|
477
|
+
el('span', { class: 'when' }, ago(new Date(line.at).toISOString())),
|
|
478
|
+
el('span', { class: 'what', title: line.sessionId },
|
|
479
|
+
line.agent ? `${line.agent} ${line.text}` : line.text),
|
|
480
|
+
)));
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The settings form, built once and kept.
|
|
485
|
+
*
|
|
486
|
+
* `update()` runs on every event from every agent, and rebuilding these
|
|
487
|
+
* inputs from the last server values would erase whatever someone had
|
|
488
|
+
* typed the moment anything anywhere emitted an event. Text people are
|
|
489
|
+
* editing is state, not a projection — so the nodes outlive the render
|
|
490
|
+
* that created them, exactly as the composer does.
|
|
491
|
+
*/
|
|
492
|
+
let settingsForm;
|
|
493
|
+
|
|
494
|
+
const buildSettingsForm = (current, soul) => {
|
|
495
|
+
const name = el('input', { value: soul.agent?.name ?? current.name });
|
|
496
|
+
// The stored instructions in full, never the roster's one-line snippet.
|
|
497
|
+
const instructions = el('textarea', { rows: 8, value: soul.agent?.instructions ?? '' });
|
|
498
|
+
const provider = el('input', { value: soul.provider ?? '', placeholder: 'follows your setup' });
|
|
499
|
+
const model = el('input', { value: soul.model ?? '', placeholder: 'follows your setup' });
|
|
500
|
+
|
|
501
|
+
const save = async () => {
|
|
502
|
+
state.saved = undefined;
|
|
503
|
+
state.error = undefined;
|
|
504
|
+
try {
|
|
505
|
+
await api.updateAgent(agentId, {
|
|
506
|
+
name: name.value,
|
|
507
|
+
instructions: instructions.value,
|
|
508
|
+
// An empty string clears a pin; leaving it alone keeps it.
|
|
509
|
+
provider: provider.value.trim(),
|
|
510
|
+
model: model.value.trim(),
|
|
511
|
+
});
|
|
512
|
+
await refreshCore();
|
|
513
|
+
state.saved = 'Saved. The next turn in every conversation uses it.';
|
|
514
|
+
} catch (error) {
|
|
515
|
+
state.error = error.message;
|
|
516
|
+
}
|
|
517
|
+
settingsForm?.refreshNotice();
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// The one part that does change per render: whether the last save
|
|
521
|
+
// succeeded. Kept as its own node so the fields above are never touched.
|
|
522
|
+
const notice = el('div', {});
|
|
523
|
+
const node = el('div', {},
|
|
524
|
+
el('div', { class: 'form-grid' },
|
|
525
|
+
el('div', {}, el('label', {}, 'Name'), name),
|
|
526
|
+
el('div', {}, el('label', {}, 'Agent id'), el('input', { value: current.id, disabled: true })),
|
|
527
|
+
el('div', {}, el('label', {}, 'Provider'), provider),
|
|
528
|
+
el('div', {}, el('label', {}, 'Model'), model),
|
|
529
|
+
),
|
|
530
|
+
el('p', { class: 'field-note' }, 'An id keys sessions, memory, and credentials, so it cannot be changed in place.'),
|
|
531
|
+
el('label', {}, 'Persona'),
|
|
532
|
+
instructions,
|
|
533
|
+
el('p', { class: 'field-note' }, soul.soulPath ? `Soul file: ${soul.soulPath}` : ''),
|
|
534
|
+
notice,
|
|
535
|
+
el('div', { class: 'actions' }, el('button', { class: 'primary', onClick: () => void save() }, 'Save')),
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
node,
|
|
540
|
+
refreshNotice() {
|
|
541
|
+
notice.replaceChildren(
|
|
542
|
+
...(state.saved ? [el('div', { class: 'notice ok' }, state.saved)] : []),
|
|
543
|
+
...(state.error ? [el('div', { class: 'notice bad' }, state.error)] : []),
|
|
544
|
+
);
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const settingsTab = () => {
|
|
550
|
+
const current = agent();
|
|
551
|
+
if (!current) {
|
|
552
|
+
return el('p', { class: 'empty' }, 'This agent is no longer on the roster.');
|
|
553
|
+
}
|
|
554
|
+
if (current.builtIn) {
|
|
555
|
+
return el('p', { class: 'empty' }, 'The built-in agent has no soul file to edit. Create your own agent to customise one.');
|
|
556
|
+
}
|
|
557
|
+
if (state.soulError) {
|
|
558
|
+
return el('p', { class: 'empty' }, state.soulError);
|
|
559
|
+
}
|
|
560
|
+
if (!state.soul) {
|
|
561
|
+
void loadSoul();
|
|
562
|
+
return el('p', { class: 'empty' }, 'Reading the soul file…');
|
|
563
|
+
}
|
|
564
|
+
settingsForm ??= buildSettingsForm(current, state.soul);
|
|
565
|
+
settingsForm.refreshNotice();
|
|
566
|
+
return settingsForm.node;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
// ---- shell -------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
const sessionPicker = () => {
|
|
572
|
+
const picker = el('select', {
|
|
573
|
+
onChange: (event) => openSession(event.target.value),
|
|
574
|
+
}, ...state.sessions.map((session) => el('option', {
|
|
575
|
+
value: session.id,
|
|
576
|
+
selected: session.id === state.sessionId,
|
|
577
|
+
}, `${session.status.replace('_', ' ')} · ${ago(session.updatedAt)} · ${shortId(session.id)}`)));
|
|
578
|
+
|
|
579
|
+
if (!state.sessions.some((session) => session.id === state.sessionId)) {
|
|
580
|
+
// A conversation this page just started is not in the store yet, so it
|
|
581
|
+
// would otherwise be missing from the very picker that is selecting it.
|
|
582
|
+
picker.prepend(el('option', { value: state.sessionId, selected: true }, 'new conversation'));
|
|
583
|
+
}
|
|
584
|
+
return picker;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const header = () => {
|
|
588
|
+
const current = agent();
|
|
589
|
+
return el('div', { class: 'card-head' },
|
|
590
|
+
el('div', { class: 'row' },
|
|
591
|
+
current ? avatar(current, 34) : null,
|
|
592
|
+
el('div', { class: 'grow' },
|
|
593
|
+
el('div', { class: 'title' }, current?.name ?? agentId),
|
|
594
|
+
el('div', { class: 'sub' }, current
|
|
595
|
+
// An absent `runsOn` means the daemon is serving a soul whose
|
|
596
|
+
// file it can no longer read, so it cannot say what this runs on.
|
|
597
|
+
// "demo" would be a guess, and a billing claim is not a good
|
|
598
|
+
// place to guess.
|
|
599
|
+
? `${current.runsOn?.provider ?? 'unknown'}${current.runsOn?.model ? ` · ${current.runsOn.model}` : ''} · ${current.memories ?? 0} remembered`
|
|
600
|
+
: agentId),
|
|
601
|
+
),
|
|
602
|
+
),
|
|
603
|
+
el('div', { class: 'actions' },
|
|
604
|
+
...['chat', 'activity', 'settings'].map((tab) => el('button', {
|
|
605
|
+
class: `small${state.tab === tab ? ' primary' : ''}`,
|
|
606
|
+
onClick: () => { state.tab = tab; update(); },
|
|
607
|
+
}, tab.charAt(0).toUpperCase() + tab.slice(1))),
|
|
608
|
+
),
|
|
609
|
+
);
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const update = () => {
|
|
613
|
+
const body = state.tab === 'chat' ? chatTab()
|
|
614
|
+
: state.tab === 'activity' ? activityTab()
|
|
615
|
+
: settingsTab();
|
|
616
|
+
|
|
617
|
+
node.replaceChildren(
|
|
618
|
+
el('div', { class: 'column' },
|
|
619
|
+
el('section', { class: 'card' },
|
|
620
|
+
header(),
|
|
621
|
+
state.tab === 'chat'
|
|
622
|
+
? el('div', {}, el('label', {}, 'Conversation'), sessionPicker())
|
|
623
|
+
: null,
|
|
624
|
+
body,
|
|
625
|
+
),
|
|
626
|
+
),
|
|
627
|
+
el('div', { class: 'column' },
|
|
628
|
+
el('section', { class: 'card' },
|
|
629
|
+
el('div', { class: 'card-head' },
|
|
630
|
+
el('h2', {}, 'Conversations'),
|
|
631
|
+
el('button', { class: 'small', onClick: startSession }, 'New'),
|
|
632
|
+
),
|
|
633
|
+
state.sessions.length === 0
|
|
634
|
+
? el('p', { class: 'empty' }, 'No stored conversations yet.')
|
|
635
|
+
: el('div', { class: 'rows' }, ...state.sessions.slice(0, 12).map((session) => el('div', { class: 'row' },
|
|
636
|
+
el('div', { class: 'grow' },
|
|
637
|
+
el('div', { class: 'title', title: session.id },
|
|
638
|
+
session.id === state.sessionId ? `▸ ${shortId(session.id)}` : shortId(session.id)),
|
|
639
|
+
el('div', { class: 'sub' }, ago(session.updatedAt))),
|
|
640
|
+
el('span', { class: `pill ${session.status}` }, session.status.replace('_', ' ')),
|
|
641
|
+
el('button', { class: 'small', onClick: () => openSession(session.id) }, 'Open'),
|
|
642
|
+
))),
|
|
643
|
+
),
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
paintTranscript();
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Re-read after the event stream was down.
|
|
651
|
+
*
|
|
652
|
+
* Nothing replays what was missed, so a turn that finished during the
|
|
653
|
+
* outage leaves this view waiting on a completion that already happened —
|
|
654
|
+
* the composer disabled on "Working…" forever.
|
|
655
|
+
*
|
|
656
|
+
* The store decides, not the reconnect: a socket can also come back while
|
|
657
|
+
* the turn is genuinely still running, and clearing then would re-enable
|
|
658
|
+
* the composer mid-turn and make that turn's remaining events look like
|
|
659
|
+
* somebody else's. So the in-flight state is dropped only once the stored
|
|
660
|
+
* session says it has ended.
|
|
661
|
+
*/
|
|
662
|
+
const reconcile = async () => {
|
|
663
|
+
if (state.sessionId && state.awaitingSend.has(state.sessionId)) {
|
|
664
|
+
// Our own POST is about to say how this turn went — including its id,
|
|
665
|
+
// which lands *after* this would have cleared `sending`, re-enabling
|
|
666
|
+
// the composer on a turn that had only just started. The store cannot
|
|
667
|
+
// help here: a new conversation has nothing in it yet, and an existing
|
|
668
|
+
// one still reads as the previous turn. So the reconciliation is owed
|
|
669
|
+
// rather than skipped: the terminal event is not replayed, and this may
|
|
670
|
+
// be the only reconnect there is.
|
|
671
|
+
state.reconcileAfterSend.add(state.sessionId);
|
|
672
|
+
void loadSessions();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// Past that, `unsent` can no longer hide a live turn: a conversation with
|
|
676
|
+
// one has either an outstanding POST, caught above, or a response that
|
|
677
|
+
// already marked it stored.
|
|
678
|
+
if (state.sending && state.sessionId && !state.unsent) {
|
|
679
|
+
const stored = await api.session(state.sessionId).then((read) => read.session).catch(() => undefined);
|
|
680
|
+
// Unreadable, or still going: leave the turn alone. A session saved a
|
|
681
|
+
// moment behind the turn it is running reads as `running` here, and
|
|
682
|
+
// the next event or check-in settles it.
|
|
683
|
+
if (!stored || stored.status === 'running' || stored.status === 'pending_approval') {
|
|
684
|
+
void loadSessions();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
state.sending = false;
|
|
689
|
+
state.turnId = undefined;
|
|
690
|
+
if (state.sessionId) {
|
|
691
|
+
state.turns.delete(state.sessionId);
|
|
692
|
+
}
|
|
693
|
+
state.pending = [];
|
|
694
|
+
state.streaming = '';
|
|
695
|
+
state.toolLines = [];
|
|
696
|
+
void loadSessions();
|
|
697
|
+
update();
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
update();
|
|
701
|
+
void loadSessions();
|
|
702
|
+
|
|
703
|
+
return { node, update, openSession, reconcile, destroy: detach };
|
|
704
|
+
};
|