@frockbot/plugin-shell 0.3.10 → 0.3.12
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/package.json +34 -32
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +107 -2
- package/src/backend-configuration.test.ts +15 -9
- package/src/backend-package-catalog.ts +75 -26
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +118 -27
- package/src/client/FrockBotApp.vue +405 -134
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/index.test.ts +25 -5
- package/src/client/index.ts +191 -47
- package/src/client/model-presentation.test.ts +3 -3
- package/src/client/no-bot-model-label.test.ts +7 -7
- package/src/client/skill-invocation.test.ts +34 -0
- package/src/client/skill-invocation.ts +22 -0
- package/src/client/styles.css +134 -89
- package/src/client/transcript-cache.test.ts +125 -0
- package/src/client/transcript-cache.ts +190 -0
- package/src/compaction-scheduler.test.ts +96 -0
- package/src/compaction-scheduler.ts +108 -0
- package/src/compaction-transcript.test.ts +174 -0
- package/src/compaction.test.ts +596 -0
- package/src/compaction.ts +539 -0
- package/src/focus.test.ts +222 -0
- package/src/focus.ts +93 -0
- package/src/history.ts +86 -8
- package/src/legacy-frock-model-id.test.ts +148 -0
- package/src/notification-id.test.ts +26 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +148 -38
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +17 -2
- package/src/shared.ts +36 -0
- package/src/unread.ts +23 -1
- package/tsconfig.json +1 -2
package/src/client/styles.css
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
.frockbot-root {
|
|
2
|
+
/*
|
|
3
|
+
* The clear margin a bubble leaves on the side it is not anchored to — about
|
|
4
|
+
* a centimetre and a half. It is what says which side of the conversation a
|
|
5
|
+
* line is on, so it is the same on a phone as on a desktop.
|
|
6
|
+
*/
|
|
7
|
+
--frock-bubble-gutter: 56px;
|
|
2
8
|
position: fixed;
|
|
3
9
|
z-index: 1000;
|
|
4
10
|
inset: 0;
|
|
@@ -195,6 +201,18 @@
|
|
|
195
201
|
-webkit-app-region: no-drag;
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
/*
|
|
205
|
+
* The Bot's own header controls on a phone, where the right panel that
|
|
206
|
+
* usually carries them is a closed drawer.
|
|
207
|
+
*/
|
|
208
|
+
.topbar-bot-actions {
|
|
209
|
+
display: flex;
|
|
210
|
+
flex: 0 0 auto;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: 2px;
|
|
213
|
+
margin-left: auto;
|
|
214
|
+
}
|
|
215
|
+
|
|
198
216
|
/* Thread */
|
|
199
217
|
|
|
200
218
|
.thread {
|
|
@@ -209,6 +227,20 @@
|
|
|
209
227
|
background-image: var(--frock-thread-gradient);
|
|
210
228
|
scrollbar-color: var(--frock-scrollbar) transparent;
|
|
211
229
|
scrollbar-width: thin;
|
|
230
|
+
/*
|
|
231
|
+
* New content is added at the end, so the browser keeps the reader where
|
|
232
|
+
* they are rather than letting the growth push the view.
|
|
233
|
+
*/
|
|
234
|
+
overflow-anchor: auto;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* Laid out and measured, deliberately not painted. A conversation is put at
|
|
239
|
+
* its end while this holds, so it opens there instead of opening at the top
|
|
240
|
+
* and scrolling down where the reader can see it.
|
|
241
|
+
*/
|
|
242
|
+
.thread-settling {
|
|
243
|
+
visibility: hidden;
|
|
212
244
|
}
|
|
213
245
|
|
|
214
246
|
.empty-thread {
|
|
@@ -324,26 +356,45 @@
|
|
|
324
356
|
}
|
|
325
357
|
|
|
326
358
|
/*
|
|
327
|
-
* An assistant Turn is
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
359
|
+
* An assistant Turn is one column holding everything it produced: bubbles,
|
|
360
|
+
* notices and sends stack inside it, so a one-word reply is a bubble the width
|
|
361
|
+
* of its word rather than a sliver of a shared row.
|
|
362
|
+
*
|
|
363
|
+
* The avatar is not in that column. While the Bot is working it sits on its
|
|
364
|
+
* own row underneath, so the trail streaming off its right has the width of
|
|
365
|
+
* the transcript to run through rather than the gutter beside a bubble. Every
|
|
366
|
+
* reply here is from the same Bot — there are no group conversations — so a
|
|
367
|
+
* sheep on every settled line said nothing and cost the column its left edge.
|
|
331
368
|
*/
|
|
332
369
|
.message-assistant {
|
|
333
|
-
flex-direction:
|
|
334
|
-
align-items:
|
|
370
|
+
flex-direction: column;
|
|
371
|
+
align-items: stretch;
|
|
335
372
|
gap: 8px;
|
|
336
373
|
}
|
|
337
374
|
|
|
338
375
|
.message-column {
|
|
339
376
|
display: flex;
|
|
340
377
|
min-width: 0;
|
|
341
|
-
flex: 1 1 auto;
|
|
342
378
|
flex-direction: column;
|
|
343
379
|
align-items: flex-start;
|
|
344
380
|
gap: 6px;
|
|
345
381
|
}
|
|
346
382
|
|
|
383
|
+
/*
|
|
384
|
+
* The working row. Its height is the trail's canvas: tall enough for the
|
|
385
|
+
* wobble to be visible, short enough that it reads as one line of the thread.
|
|
386
|
+
*/
|
|
387
|
+
.bot-working {
|
|
388
|
+
display: flex;
|
|
389
|
+
height: 44px;
|
|
390
|
+
align-items: center;
|
|
391
|
+
gap: 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.bot-working-indicator {
|
|
395
|
+
height: 100%;
|
|
396
|
+
}
|
|
397
|
+
|
|
347
398
|
.bot-avatar {
|
|
348
399
|
position: relative;
|
|
349
400
|
display: grid;
|
|
@@ -351,7 +402,6 @@
|
|
|
351
402
|
height: var(--frock-avatar-sm);
|
|
352
403
|
flex: 0 0 auto;
|
|
353
404
|
place-items: center;
|
|
354
|
-
margin-top: 2px;
|
|
355
405
|
}
|
|
356
406
|
|
|
357
407
|
.bot-avatar-fallback {
|
|
@@ -381,20 +431,23 @@
|
|
|
381
431
|
animation: frock-breathe 2600ms ease-in-out infinite;
|
|
382
432
|
}
|
|
383
433
|
|
|
384
|
-
.bot-avatar-
|
|
385
|
-
|
|
386
|
-
border-radius: 12px;
|
|
387
|
-
animation: frock-halo 2600ms ease-in-out infinite;
|
|
388
|
-
box-shadow: 0 0 0 2px var(--frock-surface-accent);
|
|
389
|
-
content: "";
|
|
390
|
-
inset: -4px;
|
|
391
|
-
opacity: 0;
|
|
392
|
-
pointer-events: none;
|
|
434
|
+
.bot-avatar-waiting {
|
|
435
|
+
animation-duration: 1600ms;
|
|
393
436
|
}
|
|
394
437
|
|
|
395
|
-
|
|
396
|
-
.
|
|
397
|
-
|
|
438
|
+
/*
|
|
439
|
+
* The working row's way off screen. The Turn has settled, the reply is the
|
|
440
|
+
* answer now, and the row fades rather than vanishing between two frames —
|
|
441
|
+
* which is also long enough for the particles still in flight to drain.
|
|
442
|
+
*/
|
|
443
|
+
.bot-working-enter-active,
|
|
444
|
+
.bot-working-leave-active {
|
|
445
|
+
transition: opacity 420ms ease-out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.bot-working-enter-from,
|
|
449
|
+
.bot-working-leave-to {
|
|
450
|
+
opacity: 0;
|
|
398
451
|
}
|
|
399
452
|
|
|
400
453
|
@keyframes frock-breathe {
|
|
@@ -421,9 +474,59 @@
|
|
|
421
474
|
}
|
|
422
475
|
}
|
|
423
476
|
|
|
477
|
+
/*
|
|
478
|
+
* The bubble a Turn has before it has words: three dots that say the Bot is
|
|
479
|
+
* working, where the reply itself will appear.
|
|
480
|
+
*/
|
|
481
|
+
.message-working {
|
|
482
|
+
display: flex;
|
|
483
|
+
align-items: center;
|
|
484
|
+
min-height: 20px;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.working-dots {
|
|
488
|
+
display: inline-flex;
|
|
489
|
+
gap: 4px;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.working-dots i {
|
|
493
|
+
width: 6px;
|
|
494
|
+
height: 6px;
|
|
495
|
+
border-radius: 50%;
|
|
496
|
+
background: var(--frock-text-muted);
|
|
497
|
+
animation: frock-working-dot 1.2s ease-in-out infinite;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.working-dots i:nth-child(2) {
|
|
501
|
+
animation-delay: 0.15s;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.working-dots i:nth-child(3) {
|
|
505
|
+
animation-delay: 0.3s;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
@keyframes frock-working-dot {
|
|
509
|
+
0%,
|
|
510
|
+
60%,
|
|
511
|
+
100% {
|
|
512
|
+
opacity: 0.3;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
30% {
|
|
516
|
+
opacity: 1;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/*
|
|
521
|
+
* A bubble runs nearly the width of the transcript, leaving one clear margin
|
|
522
|
+
* on the side it is not anchored to: the Bot's at the end, the User's at the
|
|
523
|
+
* start. That margin is the only thing distinguishing the two columns now that
|
|
524
|
+
* neither carries an avatar, so it is a fixed gap rather than a percentage —
|
|
525
|
+
* it has to stay legible at every width.
|
|
526
|
+
*/
|
|
424
527
|
.message-bubble {
|
|
425
528
|
width: max-content;
|
|
426
|
-
max-width:
|
|
529
|
+
max-width: calc(100% - var(--frock-bubble-gutter));
|
|
427
530
|
padding: 10px 14px;
|
|
428
531
|
border: 1px solid var(--frock-border);
|
|
429
532
|
border-radius: 18px 18px 18px 6px;
|
|
@@ -438,7 +541,7 @@
|
|
|
438
541
|
}
|
|
439
542
|
|
|
440
543
|
.message-user .message-bubble {
|
|
441
|
-
max-width:
|
|
544
|
+
max-width: calc(100% - var(--frock-bubble-gutter));
|
|
442
545
|
border-color: var(--frock-action-primary);
|
|
443
546
|
border-radius: 18px 18px 6px 18px;
|
|
444
547
|
color: var(--frock-on-accent);
|
|
@@ -453,59 +556,9 @@
|
|
|
453
556
|
|
|
454
557
|
.message-sends {
|
|
455
558
|
display: flex;
|
|
559
|
+
width: calc(100% - var(--frock-bubble-gutter));
|
|
456
560
|
flex-direction: column;
|
|
457
561
|
gap: 8px;
|
|
458
|
-
width: min(640px, 84%);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/*
|
|
462
|
-
* Tool calls. Quieter than anything the Bot said: the User is watching work
|
|
463
|
-
* happen, not reading a message, so a chip carries the name and its state and
|
|
464
|
-
* opens in place to what the tool returned.
|
|
465
|
-
*/
|
|
466
|
-
|
|
467
|
-
.message-tools {
|
|
468
|
-
display: flex;
|
|
469
|
-
flex-direction: column;
|
|
470
|
-
gap: 6px;
|
|
471
|
-
width: min(640px, 84%);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
.tool-chip {
|
|
475
|
-
display: flex;
|
|
476
|
-
flex-wrap: wrap;
|
|
477
|
-
gap: 8px;
|
|
478
|
-
align-items: baseline;
|
|
479
|
-
padding: 6px 10px;
|
|
480
|
-
font: inherit;
|
|
481
|
-
color: var(--frock-text-muted);
|
|
482
|
-
text-align: left;
|
|
483
|
-
cursor: pointer;
|
|
484
|
-
background: var(--frock-surface);
|
|
485
|
-
border: 1px solid var(--frock-border);
|
|
486
|
-
border-radius: 10px;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
.tool-chip-name {
|
|
490
|
-
color: var(--frock-text);
|
|
491
|
-
font-weight: 600;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
.tool-chip-status {
|
|
495
|
-
flex: 1 1 auto;
|
|
496
|
-
font-size: var(--frock-text-xs);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
.tool-chip-failed .tool-chip-status {
|
|
500
|
-
color: var(--frock-danger-text);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
.tool-chip-result {
|
|
504
|
-
flex: 1 0 100%;
|
|
505
|
-
max-height: 12em;
|
|
506
|
-
overflow: auto;
|
|
507
|
-
white-space: pre-wrap;
|
|
508
|
-
word-break: break-word;
|
|
509
562
|
}
|
|
510
563
|
|
|
511
564
|
/*
|
|
@@ -516,9 +569,9 @@
|
|
|
516
569
|
|
|
517
570
|
.message-tasks {
|
|
518
571
|
display: flex;
|
|
572
|
+
width: calc(100% - var(--frock-bubble-gutter));
|
|
519
573
|
flex-direction: column;
|
|
520
574
|
gap: 6px;
|
|
521
|
-
width: min(640px, 84%);
|
|
522
575
|
}
|
|
523
576
|
|
|
524
577
|
.task-chip {
|
|
@@ -1138,19 +1191,21 @@
|
|
|
1138
1191
|
padding-left: 0;
|
|
1139
1192
|
}
|
|
1140
1193
|
|
|
1194
|
+
/* No window chrome to clear, but the panel toggle still sits at the
|
|
1195
|
+
trailing edge, so the row ends before it rather than under it. */
|
|
1141
1196
|
.topbar {
|
|
1142
1197
|
gap: 8px;
|
|
1143
|
-
padding: 0
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
.brand-mark {
|
|
1147
|
-
font-size: var(--frock-text-lg);
|
|
1198
|
+
padding: 0 52px 0 4px;
|
|
1148
1199
|
}
|
|
1149
1200
|
|
|
1150
1201
|
.window-actions {
|
|
1151
1202
|
padding: 0 8px;
|
|
1152
1203
|
}
|
|
1153
1204
|
|
|
1205
|
+
.brand-mark {
|
|
1206
|
+
font-size: var(--frock-text-lg);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1154
1209
|
.nav-toggle {
|
|
1155
1210
|
flex: 0 0 auto;
|
|
1156
1211
|
}
|
|
@@ -1169,16 +1224,6 @@
|
|
|
1169
1224
|
padding: 20px;
|
|
1170
1225
|
}
|
|
1171
1226
|
|
|
1172
|
-
.message-bubble,
|
|
1173
|
-
.message-user .message-bubble {
|
|
1174
|
-
max-width: 92%;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
.message-sends,
|
|
1178
|
-
.message-tasks {
|
|
1179
|
-
width: 92%;
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
1227
|
.message-attachments img {
|
|
1183
1228
|
max-width: 100%;
|
|
1184
1229
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { WebChatMessage } from "../shared.js";
|
|
3
|
+
import {
|
|
4
|
+
TRANSCRIPT_CACHE_LIMIT,
|
|
5
|
+
TranscriptCache,
|
|
6
|
+
TRANSCRIPT_FRESH_MS,
|
|
7
|
+
} from "./transcript-cache.js";
|
|
8
|
+
|
|
9
|
+
function message(runId: string, text = "hello"): WebChatMessage {
|
|
10
|
+
return {
|
|
11
|
+
id: `${runId}:user`,
|
|
12
|
+
runId,
|
|
13
|
+
role: "user",
|
|
14
|
+
text,
|
|
15
|
+
status: "completed",
|
|
16
|
+
tools: [],
|
|
17
|
+
sends: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function snapshot(conversationKey = "bot:1", runId = "run-1") {
|
|
22
|
+
return { conversationKey, messages: [message(runId)] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("TranscriptCache", () => {
|
|
26
|
+
test("gives a saved conversation back without a read", () => {
|
|
27
|
+
const cache = new TranscriptCache();
|
|
28
|
+
cache.save("alpha", snapshot());
|
|
29
|
+
const restored = cache.take("alpha", "bot:1");
|
|
30
|
+
expect(restored?.messages.map((entry) => entry.runId)).toEqual(["run-1"]);
|
|
31
|
+
expect(restored?.stale).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("hands back copies, so the caller's edits never reach the cache", () => {
|
|
35
|
+
const cache = new TranscriptCache();
|
|
36
|
+
cache.save("alpha", snapshot());
|
|
37
|
+
const restored = cache.take("alpha", "bot:1");
|
|
38
|
+
restored?.messages.push(message("run-2"));
|
|
39
|
+
const [first] = restored?.messages ?? [];
|
|
40
|
+
if (first) first.text = "rewritten";
|
|
41
|
+
expect(cache.take("alpha", "bot:1")?.messages).toEqual([message("run-1")]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a different conversation on the same Bot is a miss, not the old one", () => {
|
|
45
|
+
const cache = new TranscriptCache();
|
|
46
|
+
cache.save("alpha", snapshot("bot:1"));
|
|
47
|
+
// ADR 0027: "new conversation" moves the Bot to a new Session, and the
|
|
48
|
+
// transcript that belonged to the previous one must not come back.
|
|
49
|
+
expect(cache.take("alpha", "bot:1#2")).toBeUndefined();
|
|
50
|
+
// The miss drops it: nothing will ask for that conversation again.
|
|
51
|
+
expect(cache.take("alpha", "bot:1")).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("keeps the last N Bots and evicts the least recently used", () => {
|
|
55
|
+
const cache = new TranscriptCache();
|
|
56
|
+
for (let index = 0; index < TRANSCRIPT_CACHE_LIMIT + 2; index += 1) {
|
|
57
|
+
cache.save(`bot-${index}`, snapshot(`key-${index}`, `run-${index}`));
|
|
58
|
+
}
|
|
59
|
+
expect(cache.size).toBe(TRANSCRIPT_CACHE_LIMIT);
|
|
60
|
+
expect(cache.take("bot-0", "key-0")).toBeUndefined();
|
|
61
|
+
expect(cache.take("bot-1", "key-1")).toBeUndefined();
|
|
62
|
+
expect(cache.take("bot-2", "key-2")).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reading a transcript makes it the last one evicted", () => {
|
|
66
|
+
const cache = new TranscriptCache({ limit: 2 });
|
|
67
|
+
cache.save("alpha", snapshot("a"));
|
|
68
|
+
cache.save("beta", snapshot("b"));
|
|
69
|
+
// Alpha is the oldest write but the newest use.
|
|
70
|
+
expect(cache.take("alpha", "a")).toBeDefined();
|
|
71
|
+
cache.save("gamma", snapshot("c"));
|
|
72
|
+
expect(cache.take("beta", "b")).toBeUndefined();
|
|
73
|
+
expect(cache.take("alpha", "a")).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an empty transcript is not held", () => {
|
|
77
|
+
const cache = new TranscriptCache();
|
|
78
|
+
cache.save("alpha", snapshot());
|
|
79
|
+
cache.save("alpha", { conversationKey: "bot:1", messages: [] });
|
|
80
|
+
expect(cache.size).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a channel notice leaves the transcript drawable but owes a read", () => {
|
|
84
|
+
const cache = new TranscriptCache();
|
|
85
|
+
cache.save("alpha", snapshot());
|
|
86
|
+
cache.markStale("alpha");
|
|
87
|
+
const restored = cache.take("alpha", "bot:1");
|
|
88
|
+
expect(restored?.messages).toHaveLength(1);
|
|
89
|
+
expect(restored?.stale).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a transcript past its freshness window owes a read", () => {
|
|
93
|
+
let clock = 1_000;
|
|
94
|
+
const cache = new TranscriptCache({ now: () => clock });
|
|
95
|
+
cache.save("alpha", snapshot());
|
|
96
|
+
clock += TRANSCRIPT_FRESH_MS + 1;
|
|
97
|
+
expect(cache.take("alpha", "bot:1")?.stale).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("forget drops one Bot, or every Bot", () => {
|
|
101
|
+
const cache = new TranscriptCache();
|
|
102
|
+
cache.save("alpha", snapshot("a"));
|
|
103
|
+
cache.save("beta", snapshot("b"));
|
|
104
|
+
cache.forget("alpha");
|
|
105
|
+
expect(cache.take("alpha", "a")).toBeUndefined();
|
|
106
|
+
expect(cache.take("beta", "b")).toBeDefined();
|
|
107
|
+
// Signing out is not "some conversations are stale", it is "none of these
|
|
108
|
+
// are this User's".
|
|
109
|
+
cache.forget();
|
|
110
|
+
expect(cache.size).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("remembers where the reader had the thread", () => {
|
|
114
|
+
const cache = new TranscriptCache();
|
|
115
|
+
cache.save("alpha", snapshot());
|
|
116
|
+
cache.rememberViewport("alpha", { scrollTop: 420, pinnedToLatest: false });
|
|
117
|
+
expect(cache.take("alpha", "bot:1")?.viewport).toEqual({
|
|
118
|
+
scrollTop: 420,
|
|
119
|
+
pinnedToLatest: false,
|
|
120
|
+
});
|
|
121
|
+
// A viewport for a Bot that is not held is dropped rather than resurrecting it.
|
|
122
|
+
cache.rememberViewport("ghost", { scrollTop: 1, pinnedToLatest: true });
|
|
123
|
+
expect(cache.take("ghost", "bot:1")).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the client keeps of a conversation it is not currently showing.
|
|
3
|
+
*
|
|
4
|
+
* Clicking between Bots used to throw the transcript away and read it back:
|
|
5
|
+
* every switch was an empty thread, a network round trip, and a scroll jump.
|
|
6
|
+
* A Bot's conversation is small, already durable behind it, and cheap to hold,
|
|
7
|
+
* so the last few are kept in memory and redrawn immediately.
|
|
8
|
+
*
|
|
9
|
+
* Two rules keep the cache from lying:
|
|
10
|
+
*
|
|
11
|
+
* - **It is keyed by conversation, not by Bot.** ADR 0027 makes "new
|
|
12
|
+
* conversation" change the Session a Bot's Turns record, so the transcript
|
|
13
|
+
* that belonged to the previous one must not come back under the same key.
|
|
14
|
+
* - **A cached transcript is still revalidated.** The entry carries when it
|
|
15
|
+
* was written; past {@link TRANSCRIPT_FRESH_MS}, or once something has told
|
|
16
|
+
* the client the Bot's runs moved, the restore is followed by a read. Inside
|
|
17
|
+
* that window the click costs nothing, which is the whole point.
|
|
18
|
+
*
|
|
19
|
+
* The cache is memory only and never outlives the page: nothing about one
|
|
20
|
+
* User's conversations reaches the next one through it.
|
|
21
|
+
*/
|
|
22
|
+
import type { WebActiveRun, WebChatMessage } from "../shared.ts";
|
|
23
|
+
|
|
24
|
+
/** How many Bots' transcripts are held before the least recent is dropped. */
|
|
25
|
+
export const TRANSCRIPT_CACHE_LIMIT = 8;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long a cached transcript is served without a read behind it. Long
|
|
29
|
+
* enough that clicking between Bots is free, short enough that a conversation
|
|
30
|
+
* changed on another device is never stale for long.
|
|
31
|
+
*/
|
|
32
|
+
export const TRANSCRIPT_FRESH_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
/** Where the reader had the thread when they switched away. */
|
|
35
|
+
export interface TranscriptViewport {
|
|
36
|
+
scrollTop: number;
|
|
37
|
+
/**
|
|
38
|
+
* True when they were at the end. Restored as "the end" rather than as the
|
|
39
|
+
* pixel offset, so a transcript that grew while they were away comes back
|
|
40
|
+
* pinned to the newest Turn and not to where it used to be.
|
|
41
|
+
*/
|
|
42
|
+
pinnedToLatest: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One conversation, as the thread last drew it. */
|
|
46
|
+
export interface TranscriptSnapshot {
|
|
47
|
+
/** Distinguishes this conversation from the next one on the same Bot. */
|
|
48
|
+
conversationKey: string;
|
|
49
|
+
messages: WebChatMessage[];
|
|
50
|
+
activeRun?: WebActiveRun;
|
|
51
|
+
activeRunId?: string;
|
|
52
|
+
runningRunId?: string;
|
|
53
|
+
viewport?: TranscriptViewport;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CacheEntry extends TranscriptSnapshot {
|
|
57
|
+
writtenAt: number;
|
|
58
|
+
stale: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A restored transcript, and whether reading it back is still owed. */
|
|
62
|
+
export interface TranscriptRestore extends TranscriptSnapshot {
|
|
63
|
+
/** True when the caller should revalidate before trusting this for long. */
|
|
64
|
+
stale: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TranscriptCacheOptions {
|
|
68
|
+
limit?: number;
|
|
69
|
+
freshMs?: number;
|
|
70
|
+
now?: () => number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The last few Bots' conversations, most recently used last.
|
|
75
|
+
*
|
|
76
|
+
* `Map` iteration order is insertion order, so "touch on read" is a delete
|
|
77
|
+
* followed by a set and the first key is always the eviction candidate.
|
|
78
|
+
*/
|
|
79
|
+
export class TranscriptCache {
|
|
80
|
+
readonly #entries = new Map<string, CacheEntry>();
|
|
81
|
+
readonly #limit: number;
|
|
82
|
+
readonly #freshMs: number;
|
|
83
|
+
readonly #now: () => number;
|
|
84
|
+
|
|
85
|
+
constructor(options: TranscriptCacheOptions = {}) {
|
|
86
|
+
this.#limit = options.limit ?? TRANSCRIPT_CACHE_LIMIT;
|
|
87
|
+
this.#freshMs = options.freshMs ?? TRANSCRIPT_FRESH_MS;
|
|
88
|
+
this.#now = options.now ?? (() => Date.now());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The Bots held, least recently used first. */
|
|
92
|
+
get botIds(): readonly string[] {
|
|
93
|
+
return [...this.#entries.keys()];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get size(): number {
|
|
97
|
+
return this.#entries.size;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The transcript for this Bot's current conversation, if it is held.
|
|
102
|
+
*
|
|
103
|
+
* A key mismatch is a miss and drops the entry: the conversation it holds
|
|
104
|
+
* is over, and nothing will ask for it again.
|
|
105
|
+
*/
|
|
106
|
+
take(botId: string, conversationKey: string): TranscriptRestore | undefined {
|
|
107
|
+
const entry = this.#entries.get(botId);
|
|
108
|
+
if (!entry) return undefined;
|
|
109
|
+
if (entry.conversationKey !== conversationKey) {
|
|
110
|
+
this.#entries.delete(botId);
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
// Reading is using: this Bot is now the most recent and the last to go.
|
|
114
|
+
this.#entries.delete(botId);
|
|
115
|
+
this.#entries.set(botId, entry);
|
|
116
|
+
return {
|
|
117
|
+
conversationKey: entry.conversationKey,
|
|
118
|
+
// Copies, so the caller's edits never reach back into the cache.
|
|
119
|
+
messages: entry.messages.map((message) => ({ ...message })),
|
|
120
|
+
...(entry.activeRun ? { activeRun: { ...entry.activeRun } } : {}),
|
|
121
|
+
...(entry.activeRunId ? { activeRunId: entry.activeRunId } : {}),
|
|
122
|
+
...(entry.runningRunId ? { runningRunId: entry.runningRunId } : {}),
|
|
123
|
+
...(entry.viewport ? { viewport: { ...entry.viewport } } : {}),
|
|
124
|
+
stale: entry.stale || this.#now() - entry.writtenAt > this.#freshMs,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Holds this conversation, evicting the least recently used past the limit. */
|
|
129
|
+
save(botId: string, snapshot: TranscriptSnapshot): void {
|
|
130
|
+
// An empty transcript is not worth a slot: restoring it looks exactly like
|
|
131
|
+
// the read it would have saved.
|
|
132
|
+
if (snapshot.messages.length === 0) {
|
|
133
|
+
this.#entries.delete(botId);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// The scroll position is written after the transcript, by the thread that
|
|
137
|
+
// still has it on screen, so a save that carries none keeps the last one.
|
|
138
|
+
const viewport =
|
|
139
|
+
snapshot.viewport ?? this.#entries.get(botId)?.viewport ?? undefined;
|
|
140
|
+
this.#entries.delete(botId);
|
|
141
|
+
this.#entries.set(botId, {
|
|
142
|
+
conversationKey: snapshot.conversationKey,
|
|
143
|
+
messages: snapshot.messages.map((message) => ({ ...message })),
|
|
144
|
+
...(snapshot.activeRun ? { activeRun: { ...snapshot.activeRun } } : {}),
|
|
145
|
+
...(snapshot.activeRunId ? { activeRunId: snapshot.activeRunId } : {}),
|
|
146
|
+
...(snapshot.runningRunId ? { runningRunId: snapshot.runningRunId } : {}),
|
|
147
|
+
...(viewport ? { viewport: { ...viewport } } : {}),
|
|
148
|
+
writtenAt: this.#now(),
|
|
149
|
+
stale: false,
|
|
150
|
+
});
|
|
151
|
+
while (this.#entries.size > this.#limit) {
|
|
152
|
+
const oldest = this.#entries.keys().next();
|
|
153
|
+
if (oldest.done) break;
|
|
154
|
+
this.#entries.delete(oldest.value);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Records where the reader had the thread, without touching the transcript. */
|
|
159
|
+
rememberViewport(botId: string, viewport: TranscriptViewport): void {
|
|
160
|
+
const entry = this.#entries.get(botId);
|
|
161
|
+
if (!entry) return;
|
|
162
|
+
entry.viewport = { ...viewport };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Where the reader had this Bot's thread, if it is still held. */
|
|
166
|
+
viewportFor(botId: string): TranscriptViewport | undefined {
|
|
167
|
+
const viewport = this.#entries.get(botId)?.viewport;
|
|
168
|
+
return viewport ? { ...viewport } : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Says this Bot's runs have moved. The transcript still draws immediately —
|
|
173
|
+
* a stale answer beats an empty thread — but a read follows it.
|
|
174
|
+
*/
|
|
175
|
+
markStale(botId: string): void {
|
|
176
|
+
const entry = this.#entries.get(botId);
|
|
177
|
+
if (entry) entry.stale = true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drops what is held for this Bot, or for every Bot.
|
|
182
|
+
*
|
|
183
|
+
* Called where the cached transcript would be a lie rather than merely old:
|
|
184
|
+
* archive, delete, rename, and a change of signed-in User.
|
|
185
|
+
*/
|
|
186
|
+
forget(botId?: string): void {
|
|
187
|
+
if (botId === undefined) this.#entries.clear();
|
|
188
|
+
else this.#entries.delete(botId);
|
|
189
|
+
}
|
|
190
|
+
}
|