samagotchi 0.67.0 → 0.68.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/docs/architecture.md +6 -3
- data/docs/internals/engine.md +58 -9
- data/docs/sessions.md +1 -1
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +211 -358
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +55 -84
- data/lib/samagotchi/web/public/app.js +74 -505
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/ref_scan.js +1 -7
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +33 -0
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/worker.rb +11 -16
- metadata +14 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The history's bubbles, the open session's lines between its turns' rows:
|
|
2
|
+
// the placeholder hint, output chunks, end lines, the prompt bubbles (every
|
|
3
|
+
// client's, by enqueued_id), the idle recap, guardrail warnings, hooks'
|
|
4
|
+
// notices, plugins' steers, command and note bubbles. The markup is lit
|
|
5
|
+
// (turn_html.js elementOf); createBubbles puts them in the history.
|
|
6
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
7
|
+
import { normalize, recapLabel, recapPlace } from "./format.js";
|
|
8
|
+
import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
|
|
9
|
+
import { thumbsTemplate } from "./images.js";
|
|
10
|
+
import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
|
|
11
|
+
import { keepFollowing } from "./scroll.js";
|
|
12
|
+
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
13
|
+
import { drawCommandBubble } from "./commands.js";
|
|
14
|
+
|
|
15
|
+
// A prompt that started a turn: not a line steered into a running one.
|
|
16
|
+
export const TURN_PROMPT = ".bubble.user:not(.steered)";
|
|
17
|
+
|
|
18
|
+
// The history's turn prompts, oldest first (what turns_since counts).
|
|
19
|
+
export function turnPromptBubbles(historyEl) {
|
|
20
|
+
return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The history's one line while it has nothing else (loading, an error,
|
|
24
|
+
// no output yet).
|
|
25
|
+
export function hintTemplate(text) {
|
|
26
|
+
return html`<div class="hint">${text}</div>`;
|
|
27
|
+
}
|
|
28
|
+
export function hintEl(text) {
|
|
29
|
+
return elementOf(hintTemplate(text));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A piece of output text (a chunk, an older session's plain history item).
|
|
33
|
+
export function outputBubbleTemplate(text) {
|
|
34
|
+
return html`<div class="bubble output">${String(text)}</div>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
|
|
38
|
+
// the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
|
|
39
|
+
export function endBubbleTemplate(text, { stopped = false } = {}) {
|
|
40
|
+
return html`<div class=${stopped ? STOPPED_LINE_CLASS : "bubble cancel"}>${text}</div>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The idle recap, open; a stale one (+turnsSince+ > 0) says it is earlier.
|
|
44
|
+
export function recapTemplate(text, turnsSince = 0) {
|
|
45
|
+
return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} open><summary>${recapLabel(turnsSince)}</summary><div class="recap-body">${text}</div></details>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A guardrail hook or rule file that failed to load (once per worker), or
|
|
49
|
+
// a bundle's plugin (+label+ "plugins").
|
|
50
|
+
export function guardrailWarningTemplate(text, label = "guardrails") {
|
|
51
|
+
return html`<div class="bubble guardrail-warning">${`${label}: ${text}`}</div>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A hook's line to the user (event[:notify]), in the warning colour for
|
|
55
|
+
// level warn; +cls+ adds a kind's class (llm_context_edited's). +bubble+:
|
|
56
|
+
// one of the history's own, else a row of a turn's step.
|
|
57
|
+
export function hookNoticeTemplate({ line, level = null, cls = null, title = null }, { bubble = true } = {}) {
|
|
58
|
+
const klass = (bubble ? "bubble hook-notice" : "hook-notice") + (level === "warn" ? " warn" : "") + (cls ? ` ${cls}` : "");
|
|
59
|
+
return html`<div class=${klass} title=${title || nothing}>${line}</div>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A hook notice's line: its own (a snapshot's notice), else "<hook>: text".
|
|
63
|
+
export function hookNoticeLine(data) {
|
|
64
|
+
return data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A prompt bubble's state badge (queued, steered, failed) and class; none
|
|
68
|
+
// for any other state (a started turn's prompt).
|
|
69
|
+
export function setBubbleState(bubble, state) {
|
|
70
|
+
bubble.classList.remove("queued", "steered", "failed");
|
|
71
|
+
bubble.querySelector(".state-badge")?.remove();
|
|
72
|
+
if (!BADGES[state]) {
|
|
73
|
+
delete bubble.dataset.userState;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
bubble.classList.add(state);
|
|
77
|
+
bubble.dataset.userState = state;
|
|
78
|
+
bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A bubble's delivery changed (the ack's cut: off goes in at the next
|
|
82
|
+
// step; a queue to a turn that ended meanwhile is just the next turn): its
|
|
83
|
+
// badge follows.
|
|
84
|
+
export function setBubbleDelivery(bubble, delivery) {
|
|
85
|
+
if ((bubble.dataset.delivery || null) === (delivery || null)) return;
|
|
86
|
+
if (delivery) bubble.dataset.delivery = delivery;
|
|
87
|
+
else delete bubble.dataset.delivery;
|
|
88
|
+
if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// @param historyEl the history
|
|
92
|
+
// @param appendToHistory (el) puts +el+ at the history's end (or the stage's,
|
|
93
|
+
// while it holds a turn), above the live timing line
|
|
94
|
+
// @param isNearBottom () => whether the history follows its end
|
|
95
|
+
// @param turnDom the running turn's view (stage_view.js / turn_view.js)
|
|
96
|
+
// @param findInTurnRoots (selector) => the matching elements in the history
|
|
97
|
+
// and the stage
|
|
98
|
+
// @param shownTexts the running turn's texts on the page (shown_texts.js)
|
|
99
|
+
// @param liveTurn the running turn as the page follows it (live_turn.js)
|
|
100
|
+
// @param selected () => the open session's id
|
|
101
|
+
// @param clientId this tab's id in the session's events
|
|
102
|
+
export function createBubbles({
|
|
103
|
+
historyEl, appendToHistory, isNearBottom, turnDom, findInTurnRoots, shownTexts, liveTurn, selected, clientId,
|
|
104
|
+
}) {
|
|
105
|
+
// Placed with the sticky follow: the decision taken before the history
|
|
106
|
+
// grows. +before+: the element it goes above (a snapshot's), else the end.
|
|
107
|
+
function placeFollowing(el, before = null) {
|
|
108
|
+
const follow = isNearBottom();
|
|
109
|
+
if (before) historyEl.insertBefore(el, before);
|
|
110
|
+
else appendToHistory(el);
|
|
111
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
112
|
+
return el;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function removeHint() {
|
|
116
|
+
const hint = historyEl.querySelector(".hint");
|
|
117
|
+
if (hint) hint.remove();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function removeHintIfEmpty() {
|
|
121
|
+
const hint = historyEl.querySelector(".hint");
|
|
122
|
+
if (!hint) return;
|
|
123
|
+
// Remove the placeholder when the card will be the first content in history.
|
|
124
|
+
if (!historyEl.querySelector(".bubble")) hint.remove();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function appendChunk(text) {
|
|
128
|
+
if (!text) return;
|
|
129
|
+
const k = normalize(text);
|
|
130
|
+
if (!k || shownTexts.shows(k)) return;
|
|
131
|
+
const follow = isNearBottom();
|
|
132
|
+
removeHint();
|
|
133
|
+
appendToHistory(elementOf(outputBubbleTemplate(text)));
|
|
134
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function addEndBubble(text, { stopped = false } = {}) {
|
|
138
|
+
appendToHistory(elementOf(endBubbleTemplate(text, { stopped })));
|
|
139
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Prompt bubbles ───────────────────────────────────────────────────────
|
|
143
|
+
// Every client's prompts, keyed by enqueued_id: queued (badge) until their
|
|
144
|
+
// turn starts, or steered when merged into a running turn. This tab's own
|
|
145
|
+
// prompts are echoed at send (data-own) and tagged by their turn_enqueued.
|
|
146
|
+
|
|
147
|
+
// The bubble a reload draws (turn_html.js), with this page's own fields.
|
|
148
|
+
// +delivery+: how it went into a running turn (next_step, cut, queue): its
|
|
149
|
+
// queued badge says what it waits for.
|
|
150
|
+
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
|
|
151
|
+
removeHint();
|
|
152
|
+
const div = elementOf(userBubbleTemplate({ content: prompt }, {
|
|
153
|
+
label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected(), images),
|
|
154
|
+
}));
|
|
155
|
+
return placeFollowing(div);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function bubbleById(enqueuedId) {
|
|
159
|
+
if (!enqueuedId) return null;
|
|
160
|
+
return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
|
|
161
|
+
(el) => el.dataset.enqueuedId === enqueuedId,
|
|
162
|
+
) || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// This tab's oldest untagged echo, preferring one with the same text.
|
|
166
|
+
function untaggedOwnBubble(prompt) {
|
|
167
|
+
const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
|
|
168
|
+
const text = normalize(prompt || "");
|
|
169
|
+
return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
173
|
+
// into its prompt line), else null.
|
|
174
|
+
function applyPromptOps(event) {
|
|
175
|
+
if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
|
|
176
|
+
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
|
|
177
|
+
if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
|
|
178
|
+
if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
|
|
179
|
+
let started = null;
|
|
180
|
+
for (const op of ops) {
|
|
181
|
+
if (op.op === "report") {
|
|
182
|
+
liveTurn.unmatchedMerge = "report";
|
|
183
|
+
} else if (op.op === "add") {
|
|
184
|
+
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
185
|
+
if (op.state === "started") started = bubble;
|
|
186
|
+
if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
|
|
187
|
+
} else if (op.op === "tag") {
|
|
188
|
+
if (bubbleById(op.enqueuedId)) continue;
|
|
189
|
+
const echo = untaggedOwnBubble(op.prompt);
|
|
190
|
+
if (echo) {
|
|
191
|
+
echo.dataset.enqueuedId = op.enqueuedId;
|
|
192
|
+
if (op.delivery) setBubbleDelivery(echo, op.delivery);
|
|
193
|
+
}
|
|
194
|
+
// Re-rendered from a snapshot: the echo is gone, the prompt still queued.
|
|
195
|
+
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
196
|
+
} else if (op.op === "start") {
|
|
197
|
+
const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
|
|
198
|
+
// A resync (a new worker woken by this page's send) re-rendered the
|
|
199
|
+
// history and took the echo with it: draw the prompt again.
|
|
200
|
+
if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
201
|
+
else {
|
|
202
|
+
if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
|
|
203
|
+
setBubbleState(bubble, null);
|
|
204
|
+
started = bubble;
|
|
205
|
+
}
|
|
206
|
+
} else if (op.op === "steer") {
|
|
207
|
+
const bubble = bubbleById(op.enqueuedId);
|
|
208
|
+
if (bubble) setBubbleState(bubble, "steered");
|
|
209
|
+
else liveTurn.unmatchedMerge = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return started;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Idle recap ───────────────────────────────────────────────────────────
|
|
216
|
+
// A summary the worker writes after a quiet stretch or as it leaves: shown at
|
|
217
|
+
// the end of the history. A turn starting leaves it in place, marked
|
|
218
|
+
// earlier; only a new recap replaces it.
|
|
219
|
+
|
|
220
|
+
function showRecap(text, turnsSince = 0) {
|
|
221
|
+
if (!text) return;
|
|
222
|
+
removeRecap();
|
|
223
|
+
const details = elementOf(recapTemplate(text, turnsSince));
|
|
224
|
+
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
225
|
+
// Never in the stage: a recap is between turns.
|
|
226
|
+
turnDom.settleFold?.();
|
|
227
|
+
const before = recapPlace(turnPromptBubbles(historyEl), turnsSince);
|
|
228
|
+
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A turn started: the recap describes the chat before it.
|
|
232
|
+
function markRecapStale() {
|
|
233
|
+
historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
|
|
234
|
+
el.classList.add("stale");
|
|
235
|
+
const summary = el.querySelector("summary");
|
|
236
|
+
if (summary) summary.textContent = recapLabel(0, true);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function removeRecap() {
|
|
241
|
+
historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// +before+: the element it goes above (a snapshot's), else the end.
|
|
245
|
+
function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
|
|
246
|
+
if (!text) return;
|
|
247
|
+
placeFollowing(elementOf(guardrailWarningTemplate(text, label)), before);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A plugin's steers (pending_input_merged's steers): a row of the running
|
|
251
|
+
// turn's step, else (no turn runs) a bubble.
|
|
252
|
+
function showSteers(steers) {
|
|
253
|
+
for (const steer of steers || []) {
|
|
254
|
+
if (turnDom.steer(steer)) continue;
|
|
255
|
+
removeHintIfEmpty();
|
|
256
|
+
placeFollowing(elementOf(steerRowTemplate(steer, { bubble: true })));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// "<bundle>: text", or the notice's own line.
|
|
261
|
+
// +before+: the element it goes above (a snapshot's notice), else the end.
|
|
262
|
+
function showHookNotice(data, { before = null } = {}) {
|
|
263
|
+
removeHintIfEmpty();
|
|
264
|
+
placeFollowing(elementOf(hookNoticeTemplate({ ...data, line: hookNoticeLine(data) })), before);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function addCommandBubble(view) {
|
|
268
|
+
removeHintIfEmpty();
|
|
269
|
+
const div = document.createElement("div");
|
|
270
|
+
// Page-only: kept across a resync of this session (keptCommandBubbles).
|
|
271
|
+
const id = selected();
|
|
272
|
+
if (id) div.dataset.keptFor = id;
|
|
273
|
+
drawCommandBubble(div, view);
|
|
274
|
+
placeFollowing(div);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function commandBubble(commandId) {
|
|
278
|
+
if (!commandId) return null;
|
|
279
|
+
return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function addNoteBubble(note) {
|
|
283
|
+
removeHintIfEmpty();
|
|
284
|
+
placeFollowing(elementOf(noteBubbleTemplate(note)));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
289
|
+
markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Where the open session's plugin cards and a snapshot's notices go in the
|
|
2
|
+
// history (the cards' own markup is card.js'):
|
|
3
|
+
//
|
|
4
|
+
// A plugin's card (docs/plugins.md, Cards): a glass card between turns, or
|
|
5
|
+
// a row of the running turn's step. The same id updates the card in place.
|
|
6
|
+
// An action button runs its command line in the session (sendCommand), as
|
|
7
|
+
// if typed. A snapshot's cards and notices go back where they showed live:
|
|
8
|
+
// above the first turn after them, a finished turn's notice in its step, a
|
|
9
|
+
// finished turn's question resolved where it was asked.
|
|
10
|
+
import { getSession, sendCommand } from "./data.js";
|
|
11
|
+
import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, turnNoticePlace } from "./card.js";
|
|
12
|
+
import { elementOf } from "./turn_html.js";
|
|
13
|
+
import { stepText } from "./turn_view.js";
|
|
14
|
+
import { llmContextNotice, noticeLine } from "./turn_events.js";
|
|
15
|
+
import { hookNoticeTemplate, TURN_PROMPT, turnPromptBubbles } from "./bubbles.js";
|
|
16
|
+
|
|
17
|
+
// The elements of the reloaded turn that ends before +before+ (the next
|
|
18
|
+
// turn's prompt, or null for the last turn), its prompt first.
|
|
19
|
+
export function reloadedTurnEls(historyEl, before) {
|
|
20
|
+
const turnEls = [];
|
|
21
|
+
for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
|
|
22
|
+
turnEls.unshift(el);
|
|
23
|
+
if (el.matches(TURN_PROMPT)) break;
|
|
24
|
+
}
|
|
25
|
+
return turnEls;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// What goes after a reloaded turn's own rows and cards: its answer, else
|
|
29
|
+
// its timing line; null when it has neither.
|
|
30
|
+
export function turnAnswerEl(turnEls) {
|
|
31
|
+
return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// @param historyEl the history
|
|
35
|
+
// @param appendToHistory (el) puts +el+ at the history's end (or the stage's)
|
|
36
|
+
// @param isNearBottom () => whether the history follows its end
|
|
37
|
+
// @param turnDom the running turn's view (its card(el) takes a turn's card)
|
|
38
|
+
// @param findInTurnRoots (selector) => the matching elements in the history
|
|
39
|
+
// and the stage
|
|
40
|
+
// @param selected () => the open session's id
|
|
41
|
+
// @param clientId this tab's id in the session's events
|
|
42
|
+
// @param questionCards the history's question cards (question_cards.js)
|
|
43
|
+
// @param bubbles {removeHintIfEmpty, showGuardrailWarning, showHookNotice}
|
|
44
|
+
// (bubbles.js createBubbles)
|
|
45
|
+
// @param followWokenWorker () follows the worker a card's command woke
|
|
46
|
+
// @param api {getSession, sendCommand} (data.js)
|
|
47
|
+
// @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
|
|
48
|
+
// refreshCardBodies(), onCardAction(event)}
|
|
49
|
+
export function createCardsPlace({
|
|
50
|
+
historyEl, appendToHistory, isNearBottom, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
|
|
51
|
+
followWokenWorker, api = { getSession, sendCommand },
|
|
52
|
+
}) {
|
|
53
|
+
function cardElement(id) {
|
|
54
|
+
if (!id) return null;
|
|
55
|
+
return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Show +card+: update the one with its id where it is, else add it (a
|
|
59
|
+
// running turn's card as a row of its step; else before +before+ or at the
|
|
60
|
+
// end of the history). A running turn's card with actions asks the user
|
|
61
|
+
// something (check-in's Nudge / Keep going / Stop): it goes under the turn's
|
|
62
|
+
// block, not into a step that collapses when the next one starts.
|
|
63
|
+
function upsertCard(card, { before = null } = {}) {
|
|
64
|
+
if (!card || !card.id) return;
|
|
65
|
+
const existing = cardElement(card.id);
|
|
66
|
+
if (existing) {
|
|
67
|
+
const drawn = drawCard(existing, card);
|
|
68
|
+
drawn.classList.add("card-updated");
|
|
69
|
+
setTimeout(() => drawn.classList.remove("card-updated"), 900);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
bubbles.removeHintIfEmpty();
|
|
73
|
+
const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
|
|
74
|
+
const asks = (card.actions || []).some((a) => a && a.command);
|
|
75
|
+
if (card.in_turn && !before && !asks && turnDom.card(el)) return;
|
|
76
|
+
const follow = isNearBottom();
|
|
77
|
+
if (before) historyEl.insertBefore(el, before);
|
|
78
|
+
else appendToHistory(el);
|
|
79
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A snapshot's cards and notices, each above the first turn after it; a
|
|
83
|
+
// turn's own notice where it showed live (placeTurnNotice).
|
|
84
|
+
function placeSnapshotCards(entries) {
|
|
85
|
+
const users = turnPromptBubbles(historyEl);
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const before = cardPlace(users, entry);
|
|
88
|
+
if (isCard(entry)) upsertCard(entry, { before });
|
|
89
|
+
else if (entry.type === "question") placeResolvedQuestion(entry, before);
|
|
90
|
+
else if (entry.type === "guardrail_warning") bubbles.showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
|
|
91
|
+
else if (entry.in_turn) placeTurnNotice(entry, before);
|
|
92
|
+
else if (entry.type === "hook_notice") bubbles.showHookNotice(entry, { before });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A finished turn's row, a hook's notice or the loop's asking again
|
|
97
|
+
// (+before+: the next turn's prompt, or null for the last turn): a row of
|
|
98
|
+
// its step, above the row of the call it came before, as live; else (a
|
|
99
|
+
// plain answer, a step the history doesn't have) a bubble before the
|
|
100
|
+
// turn's answer, or before its timing line when it has none.
|
|
101
|
+
function placeTurnNotice(entry, before) {
|
|
102
|
+
const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
|
|
103
|
+
const line = noticeLine(entry);
|
|
104
|
+
const turnEls = reloadedTurnEls(historyEl, before);
|
|
105
|
+
const place = turnNoticePlace(entry);
|
|
106
|
+
const block = turnEls.find((el) => el.matches(".turn-work"));
|
|
107
|
+
const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
|
|
108
|
+
if (!step) {
|
|
109
|
+
bubbles.showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const el = elementOf(hookNoticeTemplate({ line, level: entry.level, cls: extra.cls, title: extra.title }, { bubble: false }));
|
|
113
|
+
let body = step.querySelector(":scope > .activity-body");
|
|
114
|
+
if (!body) {
|
|
115
|
+
body = document.createElement("div");
|
|
116
|
+
body.className = "activity-body";
|
|
117
|
+
step.appendChild(body);
|
|
118
|
+
}
|
|
119
|
+
const row = [...body.children].find((r) => r.dataset.key === place.rowKey);
|
|
120
|
+
body.insertBefore(el, row || null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A finished turn's question (the snapshot's cards keep it with its
|
|
124
|
+
// answer): the card as it was left live, resolved, before the lines the
|
|
125
|
+
// user sent into the turn after it (a steered bubble's data-step: the step
|
|
126
|
+
// that read it) and the turn's answer. A pending one is the running turn's
|
|
127
|
+
// (pending_question draws it).
|
|
128
|
+
function placeResolvedQuestion(entry, before) {
|
|
129
|
+
const pq = entry.pending_question;
|
|
130
|
+
if (!pq?.id || !(entry.answer || entry.cancelled)) return;
|
|
131
|
+
const turnEls = reloadedTurnEls(historyEl, before);
|
|
132
|
+
const asked = Number(entry.iteration) || 1;
|
|
133
|
+
// The reply that asked: its step's text in the reloaded block (the card
|
|
134
|
+
// keeps it, as live), never the running turn's.
|
|
135
|
+
const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
|
|
136
|
+
questionCards.renderQuestion(pq, { said });
|
|
137
|
+
const card = questionCards.questionCard();
|
|
138
|
+
if (!card || card.dataset.qid !== String(pq.id)) return;
|
|
139
|
+
questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
|
|
140
|
+
const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
|
|
141
|
+
historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A live card's body comes as text; the worker's snapshot (through the
|
|
145
|
+
// server) has it rendered. One re-read for a burst of cards.
|
|
146
|
+
let cardBodiesTimer = null;
|
|
147
|
+
function refreshCardBodies() {
|
|
148
|
+
clearTimeout(cardBodiesTimer);
|
|
149
|
+
const id = selected();
|
|
150
|
+
cardBodiesTimer = setTimeout(async () => {
|
|
151
|
+
try {
|
|
152
|
+
const data = await api.getSession(id, { cards: true });
|
|
153
|
+
if (id !== selected()) return;
|
|
154
|
+
for (const card of data.cards || []) {
|
|
155
|
+
const el = card.type === "card" && cardElement(card.id);
|
|
156
|
+
if (el) refreshCardBody(el, card);
|
|
157
|
+
}
|
|
158
|
+
} catch (_) {}
|
|
159
|
+
}, 150);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A card's action button (a click anywhere in the turn roots).
|
|
163
|
+
function onCardAction(e) {
|
|
164
|
+
const button = e.target.closest(".plugin-card .card-action");
|
|
165
|
+
const id = selected();
|
|
166
|
+
if (!button || !id) return;
|
|
167
|
+
const card = button.closest(".plugin-card");
|
|
168
|
+
const err = card.querySelector(".card-error");
|
|
169
|
+
err.classList.add("hidden");
|
|
170
|
+
button.disabled = true;
|
|
171
|
+
api.sendCommand(id, button.dataset.command, { clientId, card: true })
|
|
172
|
+
.then(() => {
|
|
173
|
+
// The command woke the worker: follow its stream if none is open.
|
|
174
|
+
followWokenWorker();
|
|
175
|
+
})
|
|
176
|
+
.catch((error) => {
|
|
177
|
+
err.textContent = error.message;
|
|
178
|
+
err.classList.remove("hidden");
|
|
179
|
+
})
|
|
180
|
+
.finally(() => { button.disabled = false; });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { cardElement, upsertCard, placeSnapshotCards, refreshCardBodies, onCardAction };
|
|
184
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The popover shell a session-bar chip opens (llm_context_chip.js,
|
|
2
|
+
// level_chip.js): a div.ctx-popover on the body, placed at the chip,
|
|
3
|
+
// opened by a click on it (delegated on +el+), closed by a second click,
|
|
4
|
+
// its × (data-act="close"), a click outside or Esc. Another session's
|
|
5
|
+
// summary (refresh) closes it. What it draws, and what a Set or another
|
|
6
|
+
// data-act does, is the caller's.
|
|
7
|
+
|
|
8
|
+
import { nothing, render } from "./vendor/lit-html.js";
|
|
9
|
+
|
|
10
|
+
const EDGE = 16;
|
|
11
|
+
const GAP = 8;
|
|
12
|
+
|
|
13
|
+
// Where the popover goes for the chip's +rect+: above it, or below when
|
|
14
|
+
// the room above is short of its height and below has more; capped to the
|
|
15
|
+
// room on that side (it scrolls), never past the window's edges.
|
|
16
|
+
// @return {{left, top?, bottom?, maxHeight?}}
|
|
17
|
+
export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
|
|
18
|
+
const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
|
|
19
|
+
const above = rect.top - GAP - EDGE;
|
|
20
|
+
const below = innerHeight - rect.bottom - GAP - EDGE;
|
|
21
|
+
const down = height > above && below > above;
|
|
22
|
+
const room = Math.max(0, down ? below : above);
|
|
23
|
+
const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
|
|
24
|
+
return height > room ? { ...place, maxHeight: room } : place;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// id, className: the popover's; width: its widest (px); el: where the chip
|
|
28
|
+
// is drawn; anchor: the chip's selector; summary(): what the form is drawn
|
|
29
|
+
// from (null: nothing opens); session(): the open session's id.
|
|
30
|
+
// show(data, opening): draws the form into the popover (opening: a fresh
|
|
31
|
+
// open, else a refresh with a newer summary); onClose(): the caller forgets
|
|
32
|
+
// its open state; onSubmit(form); onAct(name): a data-act other than close.
|
|
33
|
+
// @return {pop, close, refresh, isOpen}
|
|
34
|
+
export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, summary,
|
|
35
|
+
session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {},
|
|
36
|
+
doc = document, win = window }) {
|
|
37
|
+
const pop = doc.createElement("div");
|
|
38
|
+
pop.id = id;
|
|
39
|
+
pop.className = className;
|
|
40
|
+
pop.setAttribute("role", "dialog");
|
|
41
|
+
pop.hidden = true;
|
|
42
|
+
doc.body.appendChild(pop);
|
|
43
|
+
// The session the open form is for.
|
|
44
|
+
let openFor = null;
|
|
45
|
+
|
|
46
|
+
function place(anchor) {
|
|
47
|
+
const width = Math.min(widest, win.innerWidth - 32);
|
|
48
|
+
pop.style.width = `${width}px`;
|
|
49
|
+
pop.style.maxHeight = "";
|
|
50
|
+
const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
|
|
51
|
+
innerWidth: win.innerWidth, innerHeight: win.innerHeight });
|
|
52
|
+
pop.style.left = `${at.left}px`;
|
|
53
|
+
pop.style.top = at.top === undefined ? "" : `${at.top}px`;
|
|
54
|
+
pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
|
|
55
|
+
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function open(anchor) {
|
|
59
|
+
const data = summary();
|
|
60
|
+
if (!data) return;
|
|
61
|
+
openFor = session();
|
|
62
|
+
show(data, true);
|
|
63
|
+
pop.hidden = false;
|
|
64
|
+
place(anchor);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A closed popover is empty, so each open draws a fresh form (unsent
|
|
68
|
+
// edits are gone).
|
|
69
|
+
function close() {
|
|
70
|
+
pop.hidden = true;
|
|
71
|
+
openFor = null;
|
|
72
|
+
onClose();
|
|
73
|
+
render(nothing, pop);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isOpen() { return !pop.hidden; }
|
|
77
|
+
|
|
78
|
+
// A new summary (a command_ran, a re-read): the caller redraws the open
|
|
79
|
+
// form; another session closes it.
|
|
80
|
+
function refresh() {
|
|
81
|
+
if (!isOpen()) return;
|
|
82
|
+
const data = summary();
|
|
83
|
+
if (!data || session() !== openFor) return close();
|
|
84
|
+
show(data, false);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
el.addEventListener("click", (e) => {
|
|
88
|
+
const anchor = e.target.closest(anchorSel);
|
|
89
|
+
if (!anchor) return;
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
if (isOpen()) close();
|
|
92
|
+
else open(anchor);
|
|
93
|
+
});
|
|
94
|
+
pop.addEventListener("submit", (e) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
onSubmit(e.target);
|
|
97
|
+
});
|
|
98
|
+
pop.addEventListener("click", (e) => {
|
|
99
|
+
const act = e.target.closest("[data-act]")?.dataset.act;
|
|
100
|
+
if (act === "close") close();
|
|
101
|
+
else if (act) onAct(act);
|
|
102
|
+
});
|
|
103
|
+
doc.addEventListener("click", (e) => {
|
|
104
|
+
if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
|
|
105
|
+
});
|
|
106
|
+
doc.addEventListener("keydown", (e) => {
|
|
107
|
+
if (e.key === "Escape" && isOpen()) close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return { pop, close, refresh, isOpen };
|
|
111
|
+
}
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
// the session's commands (its worker's, plugins' too): arrows move, ⏎/Tab
|
|
4
4
|
// pick, Esc closes, a tap picks. A closed list takes no keys.
|
|
5
5
|
//
|
|
6
|
-
// The matching is command_complete.js'; this is the list's DOM
|
|
6
|
+
// The matching is command_complete.js'; this is the list's DOM (a lit-html
|
|
7
|
+
// template).
|
|
7
8
|
|
|
8
|
-
import { escapeHtml } from "./format.js";
|
|
9
9
|
import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
|
|
10
|
+
import { html, nothing, render as draw } from "./vendor/lit-html.js";
|
|
11
|
+
|
|
12
|
+
// One option row; +active+ is the arrows' row.
|
|
13
|
+
function optionTemplate(command, i, active) {
|
|
14
|
+
const row = commandRow(command);
|
|
15
|
+
return html`<div class=${`command-option${active ? " active" : ""}`} role="option" id=${`command-option-${i}`} aria-selected=${String(active)} data-index=${i}><span class="command-name">${row.name}</span><span class="command-desc">${row.description}</span>${
|
|
16
|
+
row.note ? html`<span class="command-note">${row.note}</span>` : nothing}</div>`;
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
// @param list the list element (#commandList)
|
|
12
20
|
// @param prompt the composer's textarea
|
|
@@ -30,13 +38,7 @@ export function createCommandList({ list, prompt, commands, afterPick = () => {}
|
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
function render() {
|
|
33
|
-
|
|
34
|
-
const row = commandRow(c);
|
|
35
|
-
const note = row.note ? `<span class="command-note">${escapeHtml(row.note)}</span>` : "";
|
|
36
|
-
return `<div class="command-option${i === index ? " active" : ""}" role="option" id="command-option-${i}" ` +
|
|
37
|
-
`aria-selected="${i === index}" data-index="${i}"><span class="command-name">${escapeHtml(row.name)}</span>` +
|
|
38
|
-
`<span class="command-desc">${escapeHtml(row.description)}</span>${note}</div>`;
|
|
39
|
-
}).join("");
|
|
41
|
+
draw(shown.map((c, i) => optionTemplate(c, i, i === index)), list);
|
|
40
42
|
list.hidden = false;
|
|
41
43
|
prompt.setAttribute("aria-expanded", "true");
|
|
42
44
|
prompt.setAttribute("aria-activedescendant", `command-option-${index}`);
|
|
@@ -47,7 +49,7 @@ export function createCommandList({ list, prompt, commands, afterPick = () => {}
|
|
|
47
49
|
shown = [];
|
|
48
50
|
index = 0;
|
|
49
51
|
list.hidden = true;
|
|
50
|
-
list
|
|
52
|
+
draw(nothing, list);
|
|
51
53
|
prompt.setAttribute("aria-expanded", "false");
|
|
52
54
|
prompt.removeAttribute("aria-activedescendant");
|
|
53
55
|
}
|