samagotchi 0.54.0 → 0.55.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/bin/chi +3 -2
- data/docs/configuration.md +29 -0
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +40 -18
- data/docs/sessions.md +2 -2
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +2 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +25 -2
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +33 -18
- data/lib/samagotchi/web/public/app.js +237 -1133
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +5 -0
- data/lib/samagotchi/web/public/info_bar.js +78 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/question_cards.js +2 -1
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +52 -0
- data/lib/samagotchi/web/public/stream_handlers.js +324 -0
- data/lib/samagotchi/web/public/thinking_chip.js +6 -7
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +5 -1
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +11 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// choice for the next create only (POST /api/sessions' llm_context), never
|
|
13
13
|
// remembered: nothing is sent unless the user sets one.
|
|
14
14
|
|
|
15
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
16
|
+
|
|
15
17
|
export const STRATEGIES = [
|
|
16
18
|
{ value: "none", label: "none" },
|
|
17
19
|
{ value: "stale", label: "stale" },
|
|
@@ -70,11 +72,12 @@ export function chipModel(summary, { kind = SESSION } = {}) {
|
|
|
70
72
|
return { text: `llm ctx ${summary.strategy}${own ? ` · ${kind}` : ""}`, title, own };
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
// The session bar's chip (a lit-html template; info_bar.js), nothing
|
|
76
|
+
// without a summary.
|
|
77
|
+
export function chipTemplate(summary) {
|
|
74
78
|
const chip = chipModel(summary);
|
|
75
|
-
if (!chip) return
|
|
76
|
-
return `<button type="button" class
|
|
77
|
-
`aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
|
|
79
|
+
if (!chip) return nothing;
|
|
80
|
+
return html`<button type="button" class=${`model llmctx-chip${chip.own ? " own" : ""}`} title=${chip.title} aria-haspopup="dialog">${chip.text}</button>`;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
// The /llm-context line for the form's values. With +initial+ (the values
|
|
@@ -201,8 +204,8 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
|
|
|
201
204
|
return height > room ? { ...place, maxHeight: room } : place;
|
|
202
205
|
}
|
|
203
206
|
|
|
204
|
-
// The chip's popover. el: where the chip is drawn (#infoText,
|
|
205
|
-
//
|
|
207
|
+
// The chip's popover. el: where the chip is drawn (#infoText, info_bar.js:
|
|
208
|
+
// clicks are delegated); summary(): the open session's llm_context;
|
|
206
209
|
// session(): the open session's id; running(): a turn runs; run(line):
|
|
207
210
|
// sends the command.
|
|
208
211
|
// The start page's (kind NEW_CHAT): summary() is the picked model's,
|
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
relayedToView, resultText, summaryText,
|
|
20
20
|
} from "./question_card.js";
|
|
21
21
|
import { sessionHash } from "./route.js";
|
|
22
|
-
import { clientLabel,
|
|
22
|
+
import { clientLabel, isOwn } from "./turn_events.js";
|
|
23
|
+
import { continueLine } from "./commands.js";
|
|
23
24
|
|
|
24
25
|
// @param sessionId () => the open session's id (answers go there)
|
|
25
26
|
// @param clientId this tab's id in the session's events
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Refs: quiet links from what a turn touched to the thing itself. A ref is
|
|
2
|
+
// a kind and a target: today a file tool's row title (the server's FileRef,
|
|
3
|
+
// {kind: "file", path, line?, end_line?}) and that row's diff lines (the
|
|
4
|
+
// same file at a line). The DOM carries data-ref-kind and the target's
|
|
5
|
+
// attributes, never what a click does: that is an action, kept per kind in
|
|
6
|
+
// REF_ACTIONS, and a click runs the first one available to this viewer. A
|
|
7
|
+
// ref with none (a phone: no editor) stays plain text; the page marks the
|
|
8
|
+
// kinds that have one on its body (refs-<kind>), which the hover styling
|
|
9
|
+
// keys on.
|
|
10
|
+
|
|
11
|
+
import { escapeHtml } from "./format.js";
|
|
12
|
+
|
|
13
|
+
// The data attributes a ref's element carries.
|
|
14
|
+
export function refAttrs(ref) {
|
|
15
|
+
if (!ref?.kind) return "";
|
|
16
|
+
const attr = (name, value) => (value ? ` data-ref-${name}="${escapeHtml(String(value))}"` : "");
|
|
17
|
+
return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The URL +template+ ({path}, {line}; web.editor) opens +path+ at +line+
|
|
21
|
+
// (1 when none) with: each path segment encoded, the slashes kept. "" with
|
|
22
|
+
// no template or path.
|
|
23
|
+
export function editorUrl(template, path, line) {
|
|
24
|
+
if (!template || !path) return "";
|
|
25
|
+
const encoded = String(path).split("/").map(encodeURIComponent).join("/");
|
|
26
|
+
return template.replaceAll("{path}", encoded).replaceAll("{line}", String(line || 1));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What a ref can do, per kind, in order: the first available one is a
|
|
30
|
+
// click's. +viewer+ is what this page's viewer can do ({editor, editorUrl}:
|
|
31
|
+
// the server's capabilities.editor and editor_url).
|
|
32
|
+
export const REF_ACTIONS = Object.freeze({
|
|
33
|
+
file: Object.freeze([
|
|
34
|
+
Object.freeze({
|
|
35
|
+
id: "editor",
|
|
36
|
+
available: (viewer) => viewer?.editor === true && !!viewer.editorUrl,
|
|
37
|
+
url: (target, viewer) => editorUrl(viewer.editorUrl, target.path, target.line),
|
|
38
|
+
// The hover hint: it opens the file as it is now.
|
|
39
|
+
hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
|
|
40
|
+
}),
|
|
41
|
+
]),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The first action +viewer+ has for a ref of +kind+, else null.
|
|
45
|
+
export function refAction(kind, viewer, actions = REF_ACTIONS) {
|
|
46
|
+
return (actions[kind] || []).find((action) => action.available(viewer)) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The kinds that have an action for +viewer+ (the page's refs-<kind> classes).
|
|
50
|
+
export function availableKinds(viewer, actions = REF_ACTIONS) {
|
|
51
|
+
return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What a click on +el+ points at: {kind, path, line, owner} or null. A row
|
|
55
|
+
// title opens its file at the lines the call named (an edit without any at
|
|
56
|
+
// its first changed line); a diff line (or a hunk header) in a row with a
|
|
57
|
+
// ref opens the row's file at that line. A diff line outside a row (an
|
|
58
|
+
// approval card's dry run) is none.
|
|
59
|
+
export function refTarget(el) {
|
|
60
|
+
const title = el?.closest?.("[data-ref-kind]");
|
|
61
|
+
if (title) {
|
|
62
|
+
const owner = title.closest(".activity-row");
|
|
63
|
+
return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title };
|
|
64
|
+
}
|
|
65
|
+
const diffLine = el?.closest?.(".diff-line[data-line]");
|
|
66
|
+
const owner = diffLine?.closest(".activity-row");
|
|
67
|
+
const ref = owner?.querySelector("[data-ref-kind]");
|
|
68
|
+
if (!ref) return null;
|
|
69
|
+
return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function targetOf(el) {
|
|
73
|
+
return { kind: el.dataset.refKind, path: el.dataset.refPath || "" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// An edit/write without a line range opens at its first changed line (its
|
|
77
|
+
// diff knows it, the call doesn't); null without a diff.
|
|
78
|
+
function firstChange(row) {
|
|
79
|
+
const line = row?.querySelector(".diff-line.diff-add[data-line], .diff-line.diff-del[data-line]");
|
|
80
|
+
return line ? Number(line.dataset.line) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A drag that selected text in the clicked ref's row isn't a click on the
|
|
84
|
+
// ref. A selection elsewhere on the page (or a collapsed one) doesn't count.
|
|
85
|
+
function selectingIn(owner, selection) {
|
|
86
|
+
if (!selection || selection.isCollapsed || !owner) return false;
|
|
87
|
+
return [selection.anchorNode, selection.focusNode].some((node) => node && owner.contains(node));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function defaultOpen(url) {
|
|
91
|
+
// The e2e seam (spec/e2e): a spec defines it before the page loads.
|
|
92
|
+
if (typeof window.__chiRefOpen === "function") window.__chiRefOpen(url);
|
|
93
|
+
else window.location.href = url;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// One delegated listener on +root+ for every ref on the page. +viewer+()
|
|
97
|
+
// is read per click and hover, so a capabilities change (another session,
|
|
98
|
+
// a reload) is seen; +open+(url) opens an action's URL.
|
|
99
|
+
export function installRefClicks(root, viewer, { open = defaultOpen, selection = () => window.getSelection?.() } = {}) {
|
|
100
|
+
root.addEventListener("click", (e) => {
|
|
101
|
+
if (e.button !== 0 || e.defaultPrevented) return;
|
|
102
|
+
const target = refTarget(e.target);
|
|
103
|
+
if (!target) return;
|
|
104
|
+
const action = refAction(target.kind, viewer());
|
|
105
|
+
if (!action) return;
|
|
106
|
+
if (selectingIn(target.owner, selection())) return;
|
|
107
|
+
const url = action.url(target, viewer());
|
|
108
|
+
if (!url) return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
open(url);
|
|
112
|
+
});
|
|
113
|
+
// The hover hint, on the ref's own element, worked out on each hover
|
|
114
|
+
// (the viewer can change): its own title (a row title's hover) first,
|
|
115
|
+
// then the action's hint when there is one.
|
|
116
|
+
root.addEventListener("mouseover", (e) => {
|
|
117
|
+
const target = refTarget(e.target);
|
|
118
|
+
if (!target) return;
|
|
119
|
+
const el = e.target.closest("[data-ref-kind]") || e.target.closest(".diff-line[data-line]");
|
|
120
|
+
if (!el) return;
|
|
121
|
+
if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
|
|
122
|
+
const own = el.dataset.refTitle;
|
|
123
|
+
const action = refAction(target.kind, viewer());
|
|
124
|
+
const title = [own, action ? action.hint(target) : ""].filter(Boolean).join("\n");
|
|
125
|
+
if (title) el.setAttribute("title", title);
|
|
126
|
+
else el.removeAttribute("title");
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The body's classes for +viewer+: refs-<kind> for each kind with an action.
|
|
131
|
+
export function setRefKinds(body, viewer, actions = REF_ACTIONS) {
|
|
132
|
+
const kinds = availableKinds(viewer, actions);
|
|
133
|
+
for (const kind of Object.keys(actions)) body.classList.toggle(`refs-${kind}`, kinds.includes(kind));
|
|
134
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Pure helpers for a failed turn's prompt handed back (prompt_restored):
|
|
2
|
+
// what goes back into this tab's composer, and when (a restore that comes
|
|
3
|
+
// before its /turn ack waits for it). Split out of turn_events.js.
|
|
4
|
+
|
|
5
|
+
import { clientLabel, isOwn, withImages } from "./turn_events.js";
|
|
6
|
+
|
|
7
|
+
// What a prompt_restored (the worker rolled a failed turn back and handed its
|
|
8
|
+
// prompt back) does in this tab: `refill` is the text for an empty composer,
|
|
9
|
+
// only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
|
|
10
|
+
// replay after a reload never refills stale text; `own`/`label` mark the
|
|
11
|
+
// failed bubble shown again after the resync.
|
|
12
|
+
export function restoreAction(event, { myId, sentIds, composerEmpty }) {
|
|
13
|
+
const origin = event?.origin || {};
|
|
14
|
+
const own = isOwn(origin.client_id, myId);
|
|
15
|
+
const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
|
|
16
|
+
// The images go back as chips with the text.
|
|
17
|
+
return withImages({
|
|
18
|
+
refill: sent && composerEmpty ? event.prompt : null,
|
|
19
|
+
own,
|
|
20
|
+
label: own ? null : clientLabel(origin.client_id),
|
|
21
|
+
}, sent && composerEmpty ? event.images : null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A turn that fails fast can have its prompt_restored come over the stream
|
|
25
|
+
// before the /turn ack names its enqueued_id: `early` (a Map) keeps such a
|
|
26
|
+
// restore of this tab's own prompt by that id, and the ack takes it back
|
|
27
|
+
// (restoreOnAck), so the prompt goes into the composer the ack would clear.
|
|
28
|
+
export function keepEarlyRestore(event, early, { myId, sentIds }) {
|
|
29
|
+
const origin = event?.origin || {};
|
|
30
|
+
const id = origin.enqueued_id;
|
|
31
|
+
if (!isOwn(origin.client_id, myId) || !id || sentIds.has(id)) return false;
|
|
32
|
+
early.set(id, event);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The ack's enqueued_id (already in sentIds) was restored early: what the
|
|
37
|
+
// composer gets back (as restoreAction for an empty composer), else null.
|
|
38
|
+
export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
|
|
39
|
+
const event = enqueuedId && early.get(enqueuedId);
|
|
40
|
+
if (!event) return null;
|
|
41
|
+
early.delete(enqueuedId);
|
|
42
|
+
return restoreAction(event, { myId, sentIds, composerEmpty: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The /turn this kept entry was for failed: no ack comes, so nothing would
|
|
46
|
+
// take it back. The whole map goes: this tab sends one turn at a time, so
|
|
47
|
+
// every entry waits for an ack that is now never coming (an older one is
|
|
48
|
+
// left over from a send that failed the same way).
|
|
49
|
+
export function dropEarlyRestores(early) {
|
|
50
|
+
const count = early.size;
|
|
51
|
+
early.clear();
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The composer's text with a restored prompt put back into it: the restored
|
|
56
|
+
// text alone when nothing is typed, else the typed text with the restored
|
|
57
|
+
// prompt under it (the user typed between the restore and the ack; their
|
|
58
|
+
// text must not be replaced).
|
|
59
|
+
export function restoreInto(refill, current) {
|
|
60
|
+
const restored = String(refill ?? "");
|
|
61
|
+
const typed = String(current ?? "");
|
|
62
|
+
if (!restored) return typed;
|
|
63
|
+
if (!typed.trim()) return restored;
|
|
64
|
+
return `${typed.replace(/\n+$/, "")}\n${restored}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What the composer holds once a /turn ack lands (4.13): the composer still
|
|
68
|
+
// carries the text that was just sent, so that text goes (it is the history's
|
|
69
|
+
// now) — but text typed since the send stays, and a prompt restored before
|
|
70
|
+
// this ack (restoreOnAck's refill) is put back without replacing it. The
|
|
71
|
+
// sent text is trimmed (turnText), so the composer's is too before they are
|
|
72
|
+
// compared: a trailing newline or space must not keep the message; and an
|
|
73
|
+
// image-only send (placeholders sent) leaves nothing worth keeping.
|
|
74
|
+
// @param sent the text the turn was sent with
|
|
75
|
+
// @param current the composer's text as the ack lands
|
|
76
|
+
// @param refill the prompt an early restore handed back, if any
|
|
77
|
+
export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
|
|
78
|
+
const typed = String(current ?? "").trim();
|
|
79
|
+
const kept = !typed || typed === String(sent ?? "").trim() ? "" : current;
|
|
80
|
+
return restoreInto(refill, kept);
|
|
81
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// the previous session set, its "delegated by" parent, its commands, never
|
|
5
5
|
// shows for the next one).
|
|
6
6
|
|
|
7
|
+
import { savedCtxPct } from "./ctx.js";
|
|
8
|
+
import { firstPreview, previewForCard } from "./format.js";
|
|
7
9
|
import { normalizeTiming } from "./timing.js";
|
|
8
10
|
|
|
9
11
|
export function emptySessionState() {
|
|
@@ -46,3 +48,53 @@ export function emptySessionState() {
|
|
|
46
48
|
timing: normalizeTiming(),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
51
|
+
|
|
52
|
+
// The fields a session's payload (GET /sessions/:id) sets, read from it
|
|
53
|
+
// alone. ctxPct is left out when the payload has no saved fill, ctxWindow
|
|
54
|
+
// and ctxWindowSource when it has no window: the page keeps what it had
|
|
55
|
+
// (a running turn's replay streams over it).
|
|
56
|
+
export function sessionStateFrom(data) {
|
|
57
|
+
const s = data.session;
|
|
58
|
+
const state = {
|
|
59
|
+
status: s.status,
|
|
60
|
+
model: s.model_name,
|
|
61
|
+
served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
|
|
62
|
+
dir: s.working_directory,
|
|
63
|
+
owner: s.owner || null,
|
|
64
|
+
timing: normalizeTiming(data.timing),
|
|
65
|
+
};
|
|
66
|
+
// The saved fill, so a reloaded or stopped session shows it before its
|
|
67
|
+
// next turn.
|
|
68
|
+
const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
|
|
69
|
+
if (savedPct !== null) state.ctxPct = savedPct;
|
|
70
|
+
if (data.timing?.context?.window_tokens) {
|
|
71
|
+
state.ctxWindow = data.timing.context.window_tokens;
|
|
72
|
+
state.ctxWindowSource = data.timing.context.window_source || null;
|
|
73
|
+
}
|
|
74
|
+
state.firstPreview = sessionFirstPreview(data);
|
|
75
|
+
state.usedMemories = s.used_memory_names || [];
|
|
76
|
+
state.preloadedMemories = s.preloaded_memory_names || [];
|
|
77
|
+
state.mutedMemories = s.muted_memory_names || [];
|
|
78
|
+
state.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
|
|
79
|
+
state.llmContext = s.llm_context || null;
|
|
80
|
+
state.thinking = s.thinking || null;
|
|
81
|
+
state.parentId = s.parent_id || null;
|
|
82
|
+
state.continues = s.continues || null;
|
|
83
|
+
state.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The info bar's title: the first user message when the payload has
|
|
88
|
+
// messages, else the card's preview, else the first prompt of its history.
|
|
89
|
+
function sessionFirstPreview(data) {
|
|
90
|
+
const s = data.session;
|
|
91
|
+
let preview = s.first_preview || previewForCard(s);
|
|
92
|
+
if (data.messages && data.messages.length) {
|
|
93
|
+
const first = data.messages.find((m) => m.role === "user");
|
|
94
|
+
if (first && first.content) preview = firstPreview(first.content);
|
|
95
|
+
} else if (!preview && data.history && data.history.length) {
|
|
96
|
+
const txt = firstPreview(data.history[0]);
|
|
97
|
+
if (txt) preview = txt;
|
|
98
|
+
}
|
|
99
|
+
return preview;
|
|
100
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// The open session's stream event handlers (GET /stream's typed events, by
|
|
2
|
+
// type); renderSessionView replays a snapshot through them too. Split out
|
|
3
|
+
// of app.js; no markup of its own: the page passes its views and hooks.
|
|
4
|
+
//
|
|
5
|
+
// Everything that changes while the page runs is read through deps at the
|
|
6
|
+
// event, never captured at creation: the open session (selected()), its
|
|
7
|
+
// fields (state(): resetSessionState replaces the object), the page's
|
|
8
|
+
// capabilities, the composer's text. A session switch mid-stream is handled
|
|
9
|
+
// as before: the handlers act on whatever is open when the event comes.
|
|
10
|
+
//
|
|
11
|
+
// deps:
|
|
12
|
+
// selected() the open session's id, or null
|
|
13
|
+
// state() the open session's fields (session_state.js)
|
|
14
|
+
// clientId this tab's id (own prompts and commands)
|
|
15
|
+
// capabilities() the page's capabilities (card.js shownElsewhere)
|
|
16
|
+
// historyEl the history element (keepFollowing, the failed-turn redraw)
|
|
17
|
+
// liveTurn, turnDom, shownTexts, questionCards, contextChips,
|
|
18
|
+
// llmContextControl, thinkingControl the page's views
|
|
19
|
+
// sentIds, earlyRestores restore.js's bookkeeping
|
|
20
|
+
// stream: {takeGuess(), restart()} the stream was opened from a
|
|
21
|
+
// guess (takeGuess: whether, and forget it); restart:
|
|
22
|
+
// close the stream and resync
|
|
23
|
+
// composer: {empty(), refill(text), setChips(chips)}
|
|
24
|
+
// getSession(id, opts) data.js
|
|
25
|
+
// resync(), updateInfoBar(), applyPromptOps(data), showSteers(steers),
|
|
26
|
+
// markRecapStale(), setTurnRunning(running), setLiveStatus(status),
|
|
27
|
+
// endTurnView(kind, endText, opts), appendChunk(text), readTurnEnd(),
|
|
28
|
+
// applyFinalMarkdown(messages), refreshTimingFromSession(),
|
|
29
|
+
// makeUserBubble(prompt, opts), addEndBubble(text), commandBubble(id),
|
|
30
|
+
// addCommandBubble(view), addNoteBubble(note), findInTurnRoots(selector),
|
|
31
|
+
// setUsedMemories(names), showRecap(text), showGuardrailWarning(text, label),
|
|
32
|
+
// showHookNotice(data), onInitEvent(type, data), upsertCard(card),
|
|
33
|
+
// refreshCardBodies() the page's hooks
|
|
34
|
+
|
|
35
|
+
import { routeChunk } from "./chunk_router.js";
|
|
36
|
+
import { extractCtxPct, generationTokens } from "./ctx.js";
|
|
37
|
+
import { failedTurnText } from "./format.js";
|
|
38
|
+
import { cancelLineText } from "./timing.js";
|
|
39
|
+
import { keepFollowing } from "./scroll.js";
|
|
40
|
+
import { shownElsewhere } from "./card.js";
|
|
41
|
+
import { restoredChips } from "./images.js";
|
|
42
|
+
import {
|
|
43
|
+
clientLabel, displayWait, emptyRetryLine, initWaitLine, isOwn, llmContextNotice, reminderText, replaysDrawnTurn,
|
|
44
|
+
retryStatusLine, steerCutLine, turnOutput,
|
|
45
|
+
} from "./turn_events.js";
|
|
46
|
+
import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
|
|
47
|
+
import { keepEarlyRestore, restoreAction } from "./restore.js";
|
|
48
|
+
|
|
49
|
+
export function createStreamHandlers(deps) {
|
|
50
|
+
const {
|
|
51
|
+
selected, state, clientId, capabilities, historyEl,
|
|
52
|
+
liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl,
|
|
53
|
+
sentIds, earlyRestores, stream, composer, getSession,
|
|
54
|
+
resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
|
|
55
|
+
appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
|
|
56
|
+
commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
|
|
57
|
+
showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies,
|
|
58
|
+
} = deps;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
generation_chunk: (data) => {
|
|
62
|
+
// Route the chunk's bytes to the text bubble and the thinking block (see
|
|
63
|
+
// chunk_router.js). Raw `content` is only a fallback for an older server.
|
|
64
|
+
const { text, thinking } = routeChunk(data);
|
|
65
|
+
liveTurn.timing.setNote("");
|
|
66
|
+
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
67
|
+
const current = state();
|
|
68
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
69
|
+
if (pct !== null && !Number.isNaN(pct)) {
|
|
70
|
+
current.ctxPct = pct;
|
|
71
|
+
updateInfoBar();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
generation_started: (data) => {
|
|
75
|
+
if (data?.context_window_tokens) {
|
|
76
|
+
const current = state();
|
|
77
|
+
current.ctxWindow = data.context_window_tokens;
|
|
78
|
+
current.ctxWindowSource = data.context_window_source || null;
|
|
79
|
+
}
|
|
80
|
+
liveTurn.timing.setNote("");
|
|
81
|
+
turnDom.generationStarted(data || {});
|
|
82
|
+
},
|
|
83
|
+
generation_completed: (data) => {
|
|
84
|
+
liveTurn.timing.setNote("");
|
|
85
|
+
const current = state();
|
|
86
|
+
if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
|
|
87
|
+
// The generation's speed and the session's token totals, from the server.
|
|
88
|
+
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
89
|
+
if (data?.served_model || data?.tokens) updateInfoBar();
|
|
90
|
+
turnDom.generationCompleted();
|
|
91
|
+
},
|
|
92
|
+
// Prompts sent while a turn ran merge into it at an iteration boundary:
|
|
93
|
+
// input_merged names them (their bubbles turn "steered"), then
|
|
94
|
+
// pending_input_merged carries the merged text, shown only for prompts
|
|
95
|
+
// this tab has no bubble for. Its `answer` (the answer the merge
|
|
96
|
+
// superseded) is the TUI's line: here it streamed as that step's text.
|
|
97
|
+
input_merged: (data) => applyPromptOps(data),
|
|
98
|
+
pending_input_merged: (data) => {
|
|
99
|
+
applyPromptOps(data);
|
|
100
|
+
showSteers(data.steers);
|
|
101
|
+
},
|
|
102
|
+
turn_enqueued: (data) => applyPromptOps(data),
|
|
103
|
+
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
104
|
+
if (stream.takeGuess()) {
|
|
105
|
+
// A replay of a turn the render drew: re-read and stream from there.
|
|
106
|
+
if (replaysDrawnTurn(data, state().timing.turnRecords)) {
|
|
107
|
+
stream.restart();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// A finished turn still in the stage goes first: the start op looks
|
|
112
|
+
// for a queued prompt among its rows.
|
|
113
|
+
turnDom.handOff?.({ immediate: true });
|
|
114
|
+
markRecapStale();
|
|
115
|
+
shownTexts.turnStarted();
|
|
116
|
+
const promptBubble = applyPromptOps(data);
|
|
117
|
+
setTurnRunning(true);
|
|
118
|
+
setLiveStatus("running");
|
|
119
|
+
liveTurn.timing.detach();
|
|
120
|
+
turnDom.turnStarted({ ...data, promptBubble });
|
|
121
|
+
liveTurn.timing.start(data.started_at, data.turn_id || null);
|
|
122
|
+
}),
|
|
123
|
+
turn_completed: async (data) => {
|
|
124
|
+
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
125
|
+
const out = turnOutput(data);
|
|
126
|
+
if (out) appendChunk(out);
|
|
127
|
+
// after_turn hooks are running and may present the answer: its
|
|
128
|
+
// answer_display (display: null when they didn't) comes next. A worker
|
|
129
|
+
// that dies before it never sends one: workerGone releases the wait, and
|
|
130
|
+
// displayWait's timeout ends it whatever happens (4.14).
|
|
131
|
+
const display = data.display_pending ? displayWait() : null;
|
|
132
|
+
if (display) liveTurn.displayPending = display;
|
|
133
|
+
liveTurn.endRead = readTurnEnd();
|
|
134
|
+
await liveTurn.endRead;
|
|
135
|
+
// The answer bubble the turn view held at its box's height pops now
|
|
136
|
+
// that the rendered markdown is in it (or the re-read failed: then the
|
|
137
|
+
// plain text pops), and the display if one was coming, so the links
|
|
138
|
+
// never swap in after the pop. The hold reveals by itself after
|
|
139
|
+
// HOLD_MS whatever comes. The stage has no hold.
|
|
140
|
+
if (display) await display.promise;
|
|
141
|
+
turnDom.answerReady?.();
|
|
142
|
+
},
|
|
143
|
+
// An after_turn hook presented the answer (AnswerDisplay): it comes after
|
|
144
|
+
// turn_completed, so re-read the answer once that read is done.
|
|
145
|
+
answer_display: async (data) => {
|
|
146
|
+
try {
|
|
147
|
+
await liveTurn.endRead;
|
|
148
|
+
if (!selected() || !data.display) return;
|
|
149
|
+
const d = await getSession(selected(), { tail: true, recent: true });
|
|
150
|
+
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
151
|
+
} catch (_) {
|
|
152
|
+
} finally {
|
|
153
|
+
liveTurn.releaseDisplay();
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
turn_canceled: (data) => {
|
|
157
|
+
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
|
|
158
|
+
setLiveStatus("idle");
|
|
159
|
+
refreshTimingFromSession();
|
|
160
|
+
},
|
|
161
|
+
// The turn raised (e.g. the model server stayed unreachable). The worker
|
|
162
|
+
// stays up: it rolls the turn back and hands the prompt back
|
|
163
|
+
// (prompt_restored), and the prompts queued behind it run as usual. A turn
|
|
164
|
+
// that got to tool steps stays as it is (kept_steps; no prompt_restored).
|
|
165
|
+
turn_failed: (data) => {
|
|
166
|
+
const text = failedTurnText(data);
|
|
167
|
+
// Only a rolled-back turn's line waits for its prompt_restored.
|
|
168
|
+
liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
|
|
169
|
+
endTurnView("failed", text);
|
|
170
|
+
setLiveStatus("idle");
|
|
171
|
+
},
|
|
172
|
+
// The rolled-back turn left the session: render it from the server again,
|
|
173
|
+
// then show the prompt once more, marked failed (with why). This tab's own
|
|
174
|
+
// prompt goes back into an empty composer.
|
|
175
|
+
prompt_restored: async (data) => {
|
|
176
|
+
keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
|
|
177
|
+
const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
|
|
178
|
+
if (refill) composer.refill(refill);
|
|
179
|
+
if (restoredImages?.length) composer.setChips(restoredChips(selected(), restoredImages));
|
|
180
|
+
const failedText = liveTurn.failedText;
|
|
181
|
+
liveTurn.failedText = null;
|
|
182
|
+
await resync();
|
|
183
|
+
// The re-render drew it from the session (failed_turn): drawn again
|
|
184
|
+
// below with this event's label and images.
|
|
185
|
+
const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
|
|
186
|
+
if (drawn) {
|
|
187
|
+
if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
|
|
188
|
+
drawn.remove();
|
|
189
|
+
}
|
|
190
|
+
// Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
|
|
191
|
+
// a resend's echo isn't matched to it.
|
|
192
|
+
makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
|
|
193
|
+
if (failedText) addEndBubble(failedText);
|
|
194
|
+
},
|
|
195
|
+
// A session command ran in the worker (any client's). One that changed
|
|
196
|
+
// the conversation (!rollback, !cmd, a continue answered no) re-reads it.
|
|
197
|
+
command_ran: async (data) => {
|
|
198
|
+
const view = commandView(data, clientId);
|
|
199
|
+
if (view.modelName && view.modelName !== state().model) {
|
|
200
|
+
state().model = view.modelName;
|
|
201
|
+
state().served = null;
|
|
202
|
+
updateInfoBar();
|
|
203
|
+
}
|
|
204
|
+
// /llm-context changed what the next turn runs under.
|
|
205
|
+
if (data.llm_context) {
|
|
206
|
+
state().llmContext = data.llm_context;
|
|
207
|
+
updateInfoBar();
|
|
208
|
+
llmContextControl.refresh();
|
|
209
|
+
}
|
|
210
|
+
// /thinking (or /model) changed the level the next turn runs at.
|
|
211
|
+
if (data.thinking) {
|
|
212
|
+
state().thinking = data.thinking;
|
|
213
|
+
updateInfoBar();
|
|
214
|
+
thinkingControl.refresh();
|
|
215
|
+
}
|
|
216
|
+
if (view.resync) await resync();
|
|
217
|
+
// A card's action that isn't anytime (the step-limit question's
|
|
218
|
+
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
219
|
+
// one's bubble was drawn at its command_queued: filled in here. One
|
|
220
|
+
// queued for after the turn ran after it: its bubble moves from among
|
|
221
|
+
// the turn's rows to after its answer and timing line.
|
|
222
|
+
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
223
|
+
if (view.hidden) queued?.remove();
|
|
224
|
+
else if (queued && view.queued && !view.anytime) {
|
|
225
|
+
queued.remove();
|
|
226
|
+
addCommandBubble(view);
|
|
227
|
+
} else if (queued) drawCommandBubble(queued, view);
|
|
228
|
+
// A command that changed the conversation (!cmd): the redrawn history
|
|
229
|
+
// ends with its command bubble already (the saved "!(…)\n<output>"
|
|
230
|
+
// message renders as one) — no second bubble, or the output shows twice.
|
|
231
|
+
else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
|
|
232
|
+
else addCommandBubble(view);
|
|
233
|
+
},
|
|
234
|
+
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
235
|
+
// turn: its bubble goes here, so the cards it shows come after it. One
|
|
236
|
+
// queued for after the turn (/model X mid-turn) shows waiting here.
|
|
237
|
+
command_queued: (data) => {
|
|
238
|
+
// A card's action waits for its command_ran: shown only if it answers.
|
|
239
|
+
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
240
|
+
},
|
|
241
|
+
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
242
|
+
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
243
|
+
// A context note joined the conversation (between turns; no turn runs).
|
|
244
|
+
context_added: (data) => {
|
|
245
|
+
addNoteBubble({ label: data.label, content: data.text || "" });
|
|
246
|
+
// An attached context source's note: its chip changed.
|
|
247
|
+
if (data.context_source) contextChips.refresh();
|
|
248
|
+
},
|
|
249
|
+
continue_offered: (data) => questionCards.renderContinue(data),
|
|
250
|
+
continue_resolved: (data) => questionCards.resolveContinue(data),
|
|
251
|
+
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
252
|
+
tool_call_completed: (data) => turnDom.toolCompleted(data),
|
|
253
|
+
// The engine's list, after every memory read and at a turn's end.
|
|
254
|
+
used_memories_updated: (data) => {
|
|
255
|
+
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
256
|
+
},
|
|
257
|
+
context_status: (data) => {
|
|
258
|
+
const current = state();
|
|
259
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
260
|
+
if (pct !== null) {
|
|
261
|
+
current.ctxPct = pct;
|
|
262
|
+
updateInfoBar();
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
question_requested: (data) => {
|
|
266
|
+
const pq = data.pending_question;
|
|
267
|
+
if (pq && pq.id) questionCards.renderQuestion(pq);
|
|
268
|
+
},
|
|
269
|
+
question_answered: (data) => {
|
|
270
|
+
questionCards.resolveQuestion(data.id, { answer: data.answer });
|
|
271
|
+
},
|
|
272
|
+
// A delegate's question relayed to its parent's card, or no longer.
|
|
273
|
+
question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
|
|
274
|
+
question_cancelled: (data) => {
|
|
275
|
+
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
276
|
+
},
|
|
277
|
+
// One collected just as a turn started describes the chat before it.
|
|
278
|
+
recap_ready: (data) => {
|
|
279
|
+
// After the turn it covers, never above it.
|
|
280
|
+
turnDom.handOff?.({ immediate: true });
|
|
281
|
+
if (!liveTurn.running) showRecap(data.recap);
|
|
282
|
+
},
|
|
283
|
+
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
284
|
+
// A notice during a turn is a row of its current step. One the page
|
|
285
|
+
// shows elsewhere (a hook's links in the rendered answer) isn't drawn,
|
|
286
|
+
// not even in the stage trail; a joined turn's notices come here too.
|
|
287
|
+
hook_notice: (data) => {
|
|
288
|
+
if (shownElsewhere(data, capabilities())) return;
|
|
289
|
+
if (!turnDom.notice(data)) showHookNotice(data);
|
|
290
|
+
},
|
|
291
|
+
// The loop asks again after an empty answer: a row of the empty step
|
|
292
|
+
// (a reload puts it back from the snapshot's cards).
|
|
293
|
+
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
294
|
+
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
|
|
295
|
+
// A batch of LLM context edits the server applied: a ✂ row of the
|
|
296
|
+
// current step, its hover listing the outputs.
|
|
297
|
+
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
298
|
+
// The provider is asked again after an error, or the turn waits for
|
|
299
|
+
// plugins' setup: a note on the live timing line until it streams.
|
|
300
|
+
generation_retrying: (data) => {
|
|
301
|
+
// A dropped stream's step asked again: what it streamed is void.
|
|
302
|
+
if (data?.restarted) turnDom.generationRestarted(data);
|
|
303
|
+
liveTurn.timing.setNote(retryStatusLine(data));
|
|
304
|
+
},
|
|
305
|
+
plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
|
|
306
|
+
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
307
|
+
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
308
|
+
// A plugin's card, or a new version of one (the same id).
|
|
309
|
+
card: (data) => {
|
|
310
|
+
upsertCard(data);
|
|
311
|
+
if (data.body) refreshCardBodies();
|
|
312
|
+
},
|
|
313
|
+
// The stream's cursor could not be replayed: render from scratch.
|
|
314
|
+
reset: () => resync(),
|
|
315
|
+
// Synthetic (snapshotEvents): a prompt merged into the turn in progress.
|
|
316
|
+
merged_input: (data) => {
|
|
317
|
+
const origin = (data.origins || [])[0] || {};
|
|
318
|
+
const own = isOwn(origin.client_id, clientId);
|
|
319
|
+
makeUserBubble(data.content, {
|
|
320
|
+
state: "steered", own, enqueuedId: origin.enqueued_id || null, label: own ? null : clientLabel(origin.client_id),
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|