samagotchi 0.64.0 → 0.65.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 +41 -1
- data/docs/architecture.md +20 -3
- data/docs/cli.md +25 -1
- data/docs/configuration.md +128 -3
- data/docs/internals/engine.md +15 -3
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/config.rb +5 -4
- data/lib/samagotchi/engine.rb +39 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +90 -13
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- metadata +8 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The composer's delivery line (web, steer delivery P4w): a row above the
|
|
2
|
+
// text field in a session, always there so the composer never changes
|
|
3
|
+
// height (the stage above it would jump). Idle it holds the key hint; while
|
|
4
|
+
// the open session's turn runs it says where a message typed now goes in,
|
|
5
|
+
// and offers the other two deliveries as links:
|
|
6
|
+
//
|
|
7
|
+
// ↳ goes in after this thinking step · 2:31 · 31k chars cut in now · queue for after the turn
|
|
8
|
+
// ↳ goes in after this tool call (execute · 0:12) queue for after the turn
|
|
9
|
+
//
|
|
10
|
+
// Send (Enter, the button) is next_step; "queue for after the turn" (or
|
|
11
|
+
// Alt/Option+Enter) runs the message as a turn of its own after this one;
|
|
12
|
+
// "cut in now" (a click only: it throws the thinking away) cuts a
|
|
13
|
+
// thinking-only generation (the worker's SteerCut rule: once it has
|
|
14
|
+
// thought for the model's cut_after seconds). A link clicked with an empty
|
|
15
|
+
// composer arms its choice for the next Enter; Esc or a second click
|
|
16
|
+
// disarms it.
|
|
17
|
+
//
|
|
18
|
+
// deliveryLine is pure (node tests); deliveryLineTemplate is its lit view.
|
|
19
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
20
|
+
|
|
21
|
+
export const QUEUE_LABEL = "queue for after the turn";
|
|
22
|
+
export const CUT_LABEL = "cut in now";
|
|
23
|
+
// A phone's row (≤ 600 px) is short: its text and chips.
|
|
24
|
+
export const QUEUE_SHORT = "queue";
|
|
25
|
+
export const CUT_SHORT = "cut in";
|
|
26
|
+
// Alt+Enter to a worker without the delivery feature: not sent (a queue
|
|
27
|
+
// would steer it), the text kept, this said.
|
|
28
|
+
export const QUEUE_NEEDS_WORKER = "Not sent: queueing needs a newer worker (it would steer the running turn). "
|
|
29
|
+
+ "Restart it (chi sessions restart ID, or the update badge), then send again.";
|
|
30
|
+
// The idle row: the keys (the placeholder's hint before the row existed).
|
|
31
|
+
export const IDLE_HINT = "Enter to send · Shift+Enter newline · ↑ history";
|
|
32
|
+
|
|
33
|
+
// m:ss (h:mm:ss past an hour) of +seconds+; "" when unknown.
|
|
34
|
+
export function clockText(seconds) {
|
|
35
|
+
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return "";
|
|
36
|
+
const total = Math.floor(seconds);
|
|
37
|
+
const h = Math.floor(total / 3600);
|
|
38
|
+
const m = Math.floor((total % 3600) / 60);
|
|
39
|
+
const s = String(total % 60).padStart(2, "0");
|
|
40
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// "812 chars", "31k chars"; "" for none.
|
|
44
|
+
export function charsText(count) {
|
|
45
|
+
if (!count) return "";
|
|
46
|
+
return count < 1000 ? `${count} chars` : `${Math.round(count / 1000)}k chars`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Seconds the current generation has run: from the page's own clock (its
|
|
50
|
+
// first thinking delta, thinkingAt, as the worker's cut counts; else its
|
|
51
|
+
// startedAt, set at generation_started), else from the worker's snapshot
|
|
52
|
+
// (+serverGen+: {thinking_only, age, at}; a page that joined mid-generation
|
|
53
|
+
// has no start of its own: age as of +at+, the page's clock when the
|
|
54
|
+
// snapshot came); null when neither.
|
|
55
|
+
export function genAge(gen, serverGen, now) {
|
|
56
|
+
const since = gen?.thinkingAt ?? gen?.startedAt;
|
|
57
|
+
if (since != null) return Math.max(0, (now - since) / 1000);
|
|
58
|
+
if (serverGen?.age != null && serverGen.at != null) return serverGen.age + Math.max(0, (now - serverGen.at) / 1000);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Whether the generation has streamed only thinking: thinking, no visible
|
|
63
|
+
// text, no tool call (the worker's GenerationPhase, freshness aside: a
|
|
64
|
+
// stale cut is the worker's to refuse or hold, its ack says which). The
|
|
65
|
+
// worker's own word (+serverGen+) wins: it also sees tool-call chunks.
|
|
66
|
+
function thinkingOnly(gen, serverGen) {
|
|
67
|
+
if (!gen?.thinking || gen.text?.trim() || gen.tools?.length) return false;
|
|
68
|
+
return serverGen?.thinking_only !== false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// What the line shows for the running turn:
|
|
72
|
+
// phase stage_model.js liveSlots(turn).phase
|
|
73
|
+
// gen the current gen (turn_model.js: thinking, text, tools, startedAt)
|
|
74
|
+
// tool the running call: {name, startedAt} (activity.js row), or null
|
|
75
|
+
// cutAfter the run's cut wait in seconds (turn_started / current_turn
|
|
76
|
+
// cut_after): 0 is cutting off, null unknown (an older worker)
|
|
77
|
+
// serverGen the snapshot's current_turn.generation with `at`, or null
|
|
78
|
+
// now the page's clock (ms)
|
|
79
|
+
// armed "cut" | "queue" | null: the choice the next Enter sends
|
|
80
|
+
// canQueue the worker takes delivery: queue (its "delivery" feature)
|
|
81
|
+
// model the session's model, for the cut-off reason
|
|
82
|
+
// → {text, short, cut: {enabled, shown, reason, label, short}, queue:
|
|
83
|
+
// {enabled, shown, label, short}, armed}: short is the phone's text;
|
|
84
|
+
// armed is what is still armed (a cut no longer possible disarms). With
|
|
85
|
+
// cutting off the cut link goes (the text says why); without the
|
|
86
|
+
// feature the queue link goes.
|
|
87
|
+
export function deliveryLine({ phase = "thinking", gen = null, tool = null, cutAfter = null, serverGen = null, now = Date.now(),
|
|
88
|
+
armed = null, canQueue = true, model = "" } = {}) {
|
|
89
|
+
const cutOff = cutAfter === 0 || (cutAfter != null && cutAfter < 0);
|
|
90
|
+
const thinking = phase === "thinking" && thinkingOnly(gen, serverGen);
|
|
91
|
+
const age = genAge(gen, serverGen, now);
|
|
92
|
+
let where;
|
|
93
|
+
let short;
|
|
94
|
+
let reason = "";
|
|
95
|
+
if (phase === "running a tool") {
|
|
96
|
+
const clock = clockText(tool?.startedAt != null ? (now - tool.startedAt) / 1000 : null);
|
|
97
|
+
where = `goes in after this tool call (${[tool?.name || "tool", clock].filter(Boolean).join(" · ")})`;
|
|
98
|
+
short = ["after this tool call", clock].filter(Boolean).join(" · ");
|
|
99
|
+
reason = "a tool is running: only a thinking step can be cut";
|
|
100
|
+
} else if (phase === "waiting for you") {
|
|
101
|
+
where = "goes in once the card above is answered";
|
|
102
|
+
short = "after the card's answer";
|
|
103
|
+
reason = "the turn waits for you";
|
|
104
|
+
} else if (phase === "writing" || phase === "writing the answer") {
|
|
105
|
+
where = "goes in after this answer; the turn may go on";
|
|
106
|
+
short = "after this answer";
|
|
107
|
+
reason = "the model is writing: only a thinking step can be cut";
|
|
108
|
+
} else if (thinking) {
|
|
109
|
+
where = ["goes in after this thinking step", clockText(age), charsText(gen.thinking.length)].filter(Boolean).join(" · ");
|
|
110
|
+
short = ["after this thinking", clockText(age)].filter(Boolean).join(" · ");
|
|
111
|
+
} else {
|
|
112
|
+
where = "goes in after this step";
|
|
113
|
+
short = "after this step";
|
|
114
|
+
reason = "nothing to cut until the model thinks";
|
|
115
|
+
}
|
|
116
|
+
if (cutOff) reason = `cutting is off for ${model || "this model"}`;
|
|
117
|
+
const cutEnabled = !cutOff && thinking;
|
|
118
|
+
let wait = "";
|
|
119
|
+
if (cutEnabled && cutAfter > 0 && age != null && age < cutAfter) wait = `cuts at about ${clockText(cutAfter)} if you choose ${CUT_LABEL}`;
|
|
120
|
+
const effective = (armed === "cut" && cutEnabled) || (armed === "queue" && canQueue) ? armed : null;
|
|
121
|
+
const parts = [cutOff ? reason : "", where, wait].filter(Boolean);
|
|
122
|
+
// A phone's short row leaves it out: its ticked chip says it.
|
|
123
|
+
if (effective) parts.push("Enter sends");
|
|
124
|
+
const tick = (label, which) => (effective === which ? `${label} \u2713` : label);
|
|
125
|
+
return {
|
|
126
|
+
text: `\u21B3 ${parts.join(" · ")}`,
|
|
127
|
+
short: `\u21B3 ${short}`,
|
|
128
|
+
cut: { enabled: cutEnabled, shown: !cutOff, reason: cutEnabled ? "" : reason, label: tick(CUT_LABEL, "cut"), short: tick(CUT_SHORT, "cut") },
|
|
129
|
+
queue: { enabled: canQueue, shown: canQueue, label: tick(QUEUE_LABEL, "queue"), short: tick(QUEUE_SHORT, "queue") },
|
|
130
|
+
armed: effective,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The line's view: +line+ is deliveryLine's; +onCut+ / +onQueue+ the
|
|
135
|
+
// links' clicks. A disabled cut stays in place, its reason on hover.
|
|
136
|
+
export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
|
|
137
|
+
const link = (which, part, onClick) => html`<button type="button" class="delivery-link ${which}${line.armed === which ? " armed" : ""}"
|
|
138
|
+
?disabled=${!part.enabled} title=${part.reason || nothing} aria-label=${part.label} aria-pressed=${line.armed === which ? "true" : "false"}
|
|
139
|
+
@mousedown=${(e) => e.preventDefault()} @click=${onClick}><span class="long">${part.label}</span><span class="short">${part.short}</span></button>`;
|
|
140
|
+
const links = [line.cut.shown ? link("cut", line.cut, onCut) : null, line.queue.shown ? link("queue", line.queue, onQueue) : null]
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
return html`<span class="delivery-where" title=${line.text}><span class="long">${line.text}</span><span class="short">${line.short}</span></span><span class="delivery-links">${links.map((l, i) =>
|
|
143
|
+
(i ? html`<span class="delivery-sep" aria-hidden="true">·</span>${l}` : l))}</span>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The idle row: the key hint (none on a phone: no keyboard keys there).
|
|
147
|
+
export function idleTemplate() {
|
|
148
|
+
return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The row on the page: the key hint while idle, the line while the turn
|
|
152
|
+
// runs (its clock ticking every TICK_MS), nothing outside a session; the
|
|
153
|
+
// armed choice is kept here. No page globals: the page passes its hooks.
|
|
154
|
+
// read() deliveryLine's inputs but now and armed (the turn's
|
|
155
|
+
// phase, gen, tool, cutAfter, serverGen, canQueue, model)
|
|
156
|
+
// composerEmpty() nothing typed and no image chips: a link arms
|
|
157
|
+
// send(delivery) send the composer's line with +delivery+ ("cut", "queue")
|
|
158
|
+
export const TICK_MS = 500;
|
|
159
|
+
|
|
160
|
+
export function createDeliveryControl({ el, read, composerEmpty, send, now = () => Date.now(),
|
|
161
|
+
setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
|
|
162
|
+
// "off" (no session: the row is gone), "idle" or "running".
|
|
163
|
+
let mode = "off";
|
|
164
|
+
let armed = null;
|
|
165
|
+
let timer = null;
|
|
166
|
+
let line = null;
|
|
167
|
+
|
|
168
|
+
const click = (which) => {
|
|
169
|
+
if (mode !== "running" || !line?.[which]?.enabled) return;
|
|
170
|
+
if (!composerEmpty()) {
|
|
171
|
+
armed = null;
|
|
172
|
+
send(which);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
armed = armed === which ? null : which;
|
|
176
|
+
refresh();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function refresh() {
|
|
180
|
+
if (mode === "off") return;
|
|
181
|
+
if (mode === "idle") {
|
|
182
|
+
render(idleTemplate(), el);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
line = deliveryLine({ ...read(), now: now(), armed });
|
|
186
|
+
// A choice no longer possible (a tool started) disarms.
|
|
187
|
+
armed = line.armed;
|
|
188
|
+
render(deliveryLineTemplate(line, { onCut: () => click("cut"), onQueue: () => click("queue") }), el);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
// +next+: "off", "idle" or "running".
|
|
193
|
+
show(next) {
|
|
194
|
+
if (next !== mode) {
|
|
195
|
+
mode = next;
|
|
196
|
+
el.hidden = mode === "off";
|
|
197
|
+
el.classList.toggle("running", mode === "running");
|
|
198
|
+
if (mode === "running") timer ??= setTimer(refresh, TICK_MS);
|
|
199
|
+
else {
|
|
200
|
+
clearTimer(timer);
|
|
201
|
+
timer = null;
|
|
202
|
+
armed = null;
|
|
203
|
+
line = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
refresh();
|
|
207
|
+
},
|
|
208
|
+
refresh,
|
|
209
|
+
click,
|
|
210
|
+
armed: () => armed,
|
|
211
|
+
// The armed choice for this send, and disarmed.
|
|
212
|
+
take() {
|
|
213
|
+
const was = armed;
|
|
214
|
+
if (was) {
|
|
215
|
+
armed = null;
|
|
216
|
+
refresh();
|
|
217
|
+
}
|
|
218
|
+
return was;
|
|
219
|
+
},
|
|
220
|
+
disarm() {
|
|
221
|
+
if (!armed) return false;
|
|
222
|
+
armed = null;
|
|
223
|
+
refresh();
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
line: () => line,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -511,6 +511,30 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
511
511
|
#composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
|
|
512
512
|
#composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
513
513
|
#composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
|
|
514
|
+
/* The delivery line (delivery_line.js): a session's row above the text field, always there so the composer never changes height: the key hint while idle; while a turn runs, where a message typed now goes in, then the cut and queue links. One quiet row, ellipsised; a phone gets the short text and chips. */
|
|
515
|
+
#deliveryLine{display:flex;align-items:center;gap:10px;min-width:0;flex:1 0 100%;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
|
|
516
|
+
#deliveryLine[hidden]{display:none}
|
|
517
|
+
#deliveryLine .delivery-where{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
518
|
+
#deliveryLine .delivery-where.idle{color:var(--text-faint)}
|
|
519
|
+
#deliveryLine .short{display:none}
|
|
520
|
+
#deliveryLine .delivery-links{flex:none;display:flex;align-items:center;gap:6px;white-space:nowrap}
|
|
521
|
+
#deliveryLine .delivery-sep{color:var(--text-faint)}
|
|
522
|
+
#deliveryLine .delivery-link{background:transparent;border:0;padding:0 2px;min-height:0;height:18px;font:inherit;line-height:18px;color:var(--accent);cursor:pointer;border-radius:4px}
|
|
523
|
+
#deliveryLine .delivery-link:hover:not(:disabled):not(.armed){text-decoration:underline;filter:none}
|
|
524
|
+
#deliveryLine .delivery-link:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
|
|
525
|
+
#deliveryLine .delivery-link:disabled{color:var(--text-faint);opacity:1;cursor:default}
|
|
526
|
+
#deliveryLine .delivery-link.armed{color:var(--on-solid);background:var(--accent-strong);padding:0 6px}
|
|
527
|
+
/* In a session the composer is a row: the line's row wraps above the text field and the buttons. */
|
|
528
|
+
body.has-session #composer{flex-wrap:wrap;row-gap:6px}
|
|
529
|
+
@media(max-width:600px){
|
|
530
|
+
#deliveryLine .long,#deliveryLine .delivery-sep{display:none}
|
|
531
|
+
#deliveryLine .short{display:inline}
|
|
532
|
+
#deliveryLine .delivery-link{padding:0 8px;border:1px solid var(--accent-border);border-radius:999px;background:var(--accent-bg);line-height:16px}
|
|
533
|
+
#deliveryLine .delivery-link:disabled{border-color:var(--border);background:transparent}
|
|
534
|
+
#deliveryLine .delivery-link.armed{border-color:var(--accent-strong);background:var(--accent-strong)}
|
|
535
|
+
}
|
|
536
|
+
/* A line queued for after the running turn: a dashed bubble, a turn of its own to come. */
|
|
537
|
+
.bubble.user.queued[data-delivery="queue"]{border:1px dashed var(--accent-border);border-left:3px dashed var(--accent);background:transparent}
|
|
514
538
|
#chips{display:flex;flex-wrap:wrap;gap:6px}
|
|
515
539
|
#chips[hidden]{display:none}
|
|
516
540
|
.chip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-bg);border:1px solid var(--border-strong);border-radius:8px;padding:3px 4px 3px 3px;font-size:12px;max-width:220px}
|
|
@@ -718,6 +742,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
718
742
|
/* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
|
|
719
743
|
/* The footer: the chip at the stage's bottom edge (the cloud's steps open above it, the scroller's last slot). */
|
|
720
744
|
.ts-foot{flex:none;padding:0 14px 10px}
|
|
745
|
+
/* Below the turn's card: the lines queued for after it (each a turn of its own), apart from the turn by a dashed rule. */
|
|
746
|
+
.ts-queued{flex:none;display:flex;flex-direction:column;gap:6px;padding:8px 14px 10px;border-top:1px dashed var(--border-strong);max-height:calc(var(--stage-max,60vh) * .3);overflow:auto;scrollbar-width:thin}
|
|
747
|
+
.ts-queued:empty{display:none}
|
|
721
748
|
.ts-foot[hidden],.turn-stage.collapsed .ts-foot{display:none}
|
|
722
749
|
.ts-chip{display:flex;align-items:center;gap:10px;flex-wrap:nowrap;width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--text-muted);text-align:left}
|
|
723
750
|
.ts-chip:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
@@ -750,6 +777,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
750
777
|
.ts-status{padding-left:10px}
|
|
751
778
|
.ts-scroll{padding:8px 10px 10px}
|
|
752
779
|
.ts-foot{padding:0 10px 10px}
|
|
780
|
+
.ts-queued{padding:8px 10px 10px}
|
|
753
781
|
.ts-defer{display:none}
|
|
754
782
|
.ts-trail-item{max-width:24ch}
|
|
755
783
|
}
|
|
@@ -1132,6 +1160,7 @@ a{color:var(--accent)}
|
|
|
1132
1160
|
<div id="modelList" role="listbox" aria-label="Models"></div>
|
|
1133
1161
|
</div>
|
|
1134
1162
|
<div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1163
|
+
<div id="deliveryLine" aria-live="off" hidden></div>
|
|
1135
1164
|
<div class="compose-col">
|
|
1136
1165
|
<div id="chips" hidden></div>
|
|
1137
1166
|
<textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
|
|
@@ -26,6 +26,12 @@ export function createLiveTurn(timingDeps) {
|
|
|
26
26
|
displayPending: null,
|
|
27
27
|
// The last turn_failed's line, shown again after prompt_restored's resync.
|
|
28
28
|
failedText: null,
|
|
29
|
+
// The run's cut wait in seconds (turn_started / the snapshot's
|
|
30
|
+
// cut_after; null: unknown, an older worker) and, on a join
|
|
31
|
+
// mid-generation, the snapshot's generation {thinking_only, age, at}
|
|
32
|
+
// until the next generation starts: the composer's delivery line.
|
|
33
|
+
cutAfter: null,
|
|
34
|
+
serverGen: null,
|
|
29
35
|
|
|
30
36
|
releaseDisplay() {
|
|
31
37
|
this.displayPending?.finish();
|
|
@@ -39,6 +45,8 @@ export function createLiveTurn(timingDeps) {
|
|
|
39
45
|
this.unmatchedMerge = false;
|
|
40
46
|
this.wakeStart = false;
|
|
41
47
|
this.failedText = null;
|
|
48
|
+
this.cutAfter = null;
|
|
49
|
+
this.serverGen = null;
|
|
42
50
|
this.releaseDisplay();
|
|
43
51
|
},
|
|
44
52
|
};
|
|
@@ -222,14 +222,30 @@ export function handOffDue({ ended, pending, inUse, quietSinceMs, nowMs }) {
|
|
|
222
222
|
// A handed-off turn's nodes in the order the turn view leaves them: the
|
|
223
223
|
// prompt bubble (none for a reminder turn), the block, the extras in
|
|
224
224
|
// arrival order, the answer's pieces, the timing line, the end line, the
|
|
225
|
-
// kept cards.
|
|
226
|
-
export function handOffOrder({ prompt = null, block = null, extras = [], answer = [], timing = null, end = null, kept = [] }) {
|
|
227
|
-
return [prompt, block, ...extras, ...answer, timing, end, ...kept].filter(Boolean);
|
|
225
|
+
// kept cards, then the lines queued for after the turn (they run next).
|
|
226
|
+
export function handOffOrder({ prompt = null, block = null, extras = [], answer = [], timing = null, end = null, kept = [], queued = [] }) {
|
|
227
|
+
return [prompt, block, ...extras, ...answer, timing, end, ...kept, ...queued].filter(Boolean);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
230
|
+
// The rows that wait for the running turn's end to run as their own: a
|
|
231
|
+
// prompt queued for after the turn (its delivery queue: the composer's
|
|
232
|
+
// queue, /queue, chi send --queue, an image line) and a command queued
|
|
233
|
+
// for the turn's end (commands.js drawCommandBubble's queued).
|
|
234
|
+
export const QUEUED_ROWS = '.bubble.user.queued[data-delivery="queue"], .bubble.command.queued';
|
|
235
|
+
|
|
236
|
+
// QUEUED_ROWS among +parent+'s own children (querySelectorAll on it).
|
|
237
|
+
export const QUEUED_CHILDREN = QUEUED_ROWS.split(", ").map((sel) => `:scope > ${sel}`).join(", ");
|
|
238
|
+
|
|
239
|
+
// The kind placeFor reads off an element app.js places.
|
|
240
|
+
export function placeKind(el) {
|
|
241
|
+
if (el.matches?.(".bubble.recap")) return "recap";
|
|
242
|
+
if (el.matches?.(QUEUED_ROWS)) return "queued";
|
|
243
|
+
return "row";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Where a row app.js would append goes (placeFor, at the end of this
|
|
247
|
+
// file): the stage's extras while a turn is live (and not handing off),
|
|
248
|
+
// else the history; the recap always the history (it is idle-only).
|
|
233
249
|
// What flashes in the trail for a few seconds while the turn runs (the
|
|
234
250
|
// rows themselves go into their step inside the closed cloud): a hook's
|
|
235
251
|
// notice, warn or info, as its row reads, the ✂ row of an applied LLM
|
|
@@ -258,7 +274,11 @@ export function flashOf(what, data = {}) {
|
|
|
258
274
|
return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
|
|
259
275
|
}
|
|
260
276
|
|
|
277
|
+
// A queued row ("queued", placeKind) goes after the running turn, not
|
|
278
|
+
// into it: the stage's queued slot below the turn's card while one is
|
|
279
|
+
// live, else the history's end (after the turn there too).
|
|
261
280
|
export function placeFor({ live, handingOff, kind }) {
|
|
262
281
|
if (kind === "recap") return "history";
|
|
263
|
-
|
|
282
|
+
if (!live || handingOff) return "history";
|
|
283
|
+
return kind === "queued" ? "queued" : "stage";
|
|
264
284
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// reload. Slots are patched in place, never rebuilt from HTML: a selection
|
|
14
14
|
// and annotate survive. The rules are pure in stage_model.js.
|
|
15
15
|
import { createTurnView } from "./turn_view.js";
|
|
16
|
-
import { flashOf, frameTarget, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
|
|
16
|
+
import { flashOf, frameTarget, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, placeKind, QUEUED_CHILDREN, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
|
|
17
17
|
import { formatDuration } from "./timing.js";
|
|
18
18
|
import { easeScrollTo } from "./scroll.js";
|
|
19
19
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
@@ -124,6 +124,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
124
124
|
chip.type = "button";
|
|
125
125
|
const chipLabel = node("span", "ts-chip-label", chip);
|
|
126
126
|
const ticks = node("span", "ts-ticks", chip);
|
|
127
|
+
// Below the turn's card: the lines queued for after it (placeKind
|
|
128
|
+
// "queued"), each to run as a turn of its own; they hand off last.
|
|
129
|
+
const queuedSlot = node("div", "ts-queued", stage);
|
|
127
130
|
|
|
128
131
|
// The turn in the stage, from turnStarted until the hand-off (or a reset).
|
|
129
132
|
let holding = false;
|
|
@@ -600,13 +603,14 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
600
603
|
maxContent = holds ? Math.max(maxContent, content) : 0;
|
|
601
604
|
const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
|
|
602
605
|
if (holdEl.style.top !== mark) holdEl.style.top = mark;
|
|
603
|
-
const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight)
|
|
606
|
+
const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight)
|
|
607
|
+
+ queuedSlot.offsetHeight;
|
|
604
608
|
held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
|
|
605
609
|
if (held === null) frameFree();
|
|
606
610
|
else frameTo(held, { animate: true });
|
|
607
611
|
}
|
|
608
612
|
const frameObserver = new ResizeObserver(() => fitFrame());
|
|
609
|
-
for (const el of [status, promptSlot, live, extras, answer, tail, cloudBody, cloud]) frameObserver.observe(el);
|
|
613
|
+
for (const el of [status, promptSlot, live, extras, answer, tail, cloudBody, cloud, queuedSlot]) frameObserver.observe(el);
|
|
610
614
|
|
|
611
615
|
// ── in use: pointer over it (tracked on the stage itself: :hover goes
|
|
612
616
|
// stale when children change), a touch or wheel in the last 4 s (phones
|
|
@@ -677,7 +681,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
677
681
|
}
|
|
678
682
|
|
|
679
683
|
function emptySlots() {
|
|
680
|
-
for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, asking, now, trail, ticks]) slot.replaceChildren();
|
|
684
|
+
for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot, headline, asking, now, trail, ticks]) slot.replaceChildren();
|
|
681
685
|
promptSlot.classList.remove("full");
|
|
682
686
|
asking.hidden = true;
|
|
683
687
|
shown.headline = shown.now = shown.trail = shown.asking = null;
|
|
@@ -744,6 +748,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
744
748
|
answer: [...answer.children],
|
|
745
749
|
timing: timingSlot.firstElementChild,
|
|
746
750
|
kept: [...tail.children],
|
|
751
|
+
queued: [...queuedSlot.children],
|
|
747
752
|
});
|
|
748
753
|
const animate = !immediate && !reducedMotion() && !document.hidden && !collapsed;
|
|
749
754
|
folding = { pieces, block: blockEl, wasAtEnd: historyNearBottom(), animate };
|
|
@@ -779,6 +784,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
779
784
|
return {
|
|
780
785
|
turnStarted(data) {
|
|
781
786
|
if (folding) finishFold();
|
|
787
|
+
// A turn that never ended (released, not handed off) leaves its queued
|
|
788
|
+
// lines waiting still: they stay.
|
|
789
|
+
const waiting = [...queuedSlot.children].filter((el) => el !== data?.promptBubble);
|
|
782
790
|
if (holding) {
|
|
783
791
|
if (endKind) handOff({ immediate: true });
|
|
784
792
|
else release();
|
|
@@ -794,15 +802,19 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
794
802
|
// The turn's prompt bubble (app.js found or drew it) sits in the prompt
|
|
795
803
|
// line until the hand-off takes it back first.
|
|
796
804
|
if (data?.promptBubble) promptSlot.appendChild(data.promptBubble);
|
|
805
|
+
// The lines still queued behind this turn went into the history with
|
|
806
|
+
// the turn before (the hand-off at this turn_started, or an earlier
|
|
807
|
+
// one): back below this turn's card, as a reload places them.
|
|
808
|
+
queuedSlot.append(...waiting.filter((el) => !el.isConnected), ...historyEl.querySelectorAll(QUEUED_CHILDREN));
|
|
797
809
|
view.turnStarted(data || {});
|
|
798
810
|
show();
|
|
799
811
|
refresh();
|
|
800
812
|
},
|
|
801
|
-
generationStarted(data) {
|
|
813
|
+
generationStarted(data, now = null) {
|
|
802
814
|
// The next step: the live slots' waiting height is let go.
|
|
803
815
|
stage.classList.remove("held-wait");
|
|
804
816
|
stage.style.removeProperty("--live-held");
|
|
805
|
-
view.generationStarted(data);
|
|
817
|
+
view.generationStarted(data, now);
|
|
806
818
|
},
|
|
807
819
|
chunk: (data) => view.chunk(data),
|
|
808
820
|
generationRestarted: (data) => view.generationRestarted(data),
|
|
@@ -853,18 +865,28 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
853
865
|
},
|
|
854
866
|
containsLive: (el) => view.containsLive(el),
|
|
855
867
|
askingText: () => view.askingText(),
|
|
868
|
+
// The turn model and its live phase as the status row reads it (the
|
|
869
|
+
// composer's delivery line).
|
|
870
|
+
current: () => view.current(),
|
|
871
|
+
phase: () => liveSlots(view.current().turn, { pending: pendingCard() || asksCard() }).phase,
|
|
856
872
|
reset() {
|
|
857
873
|
if (folding) finishFold();
|
|
858
874
|
release();
|
|
859
875
|
},
|
|
860
|
-
// The stage's extras while it holds a turn (a card, a
|
|
861
|
-
// command, a note, the end line)
|
|
876
|
+
// The stage's extras while it holds a turn (a card, a prompt for its
|
|
877
|
+
// next step, a command, a note, the end line), its queued slot for a
|
|
878
|
+
// line queued for after it; false: the history takes it.
|
|
862
879
|
place(el) {
|
|
863
880
|
// A turn still sliding away goes into the history first: what comes
|
|
864
881
|
// now comes after it.
|
|
865
882
|
if (folding) finishFold();
|
|
866
|
-
const
|
|
867
|
-
if (
|
|
883
|
+
const where = placeFor({ live: holding, handingOff: false, kind: placeKind(el) });
|
|
884
|
+
if (where === "history") return false;
|
|
885
|
+
if (where === "queued") {
|
|
886
|
+
queuedSlot.appendChild(el);
|
|
887
|
+
scheduleRefresh();
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
868
890
|
const slot = endKind ? tail : extras;
|
|
869
891
|
const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
|
|
870
892
|
slot.appendChild(el);
|
|
@@ -64,7 +64,7 @@ export function createStreamHandlers(deps) {
|
|
|
64
64
|
// chunk_router.js). Raw `content` is only a fallback for an older server.
|
|
65
65
|
const { text, thinking } = routeChunk(data);
|
|
66
66
|
liveTurn.timing.setNote("");
|
|
67
|
-
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
67
|
+
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null, now: data.replayed ? null : Date.now() });
|
|
68
68
|
const current = state();
|
|
69
69
|
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
70
70
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
@@ -79,7 +79,11 @@ export function createStreamHandlers(deps) {
|
|
|
79
79
|
current.ctxWindowSource = data.context_window_source || null;
|
|
80
80
|
}
|
|
81
81
|
liveTurn.timing.setNote("");
|
|
82
|
-
|
|
82
|
+
// The step's clock (the composer's delivery line): the page's own at a
|
|
83
|
+
// live start; a replay's comes from the snapshot's generation age,
|
|
84
|
+
// which a live start makes stale.
|
|
85
|
+
if (!data?.replayed) liveTurn.serverGen = null;
|
|
86
|
+
turnDom.generationStarted(data || {}, data?.replayed ? null : Date.now());
|
|
83
87
|
},
|
|
84
88
|
generation_completed: (data) => {
|
|
85
89
|
liveTurn.timing.setNote("");
|
|
@@ -115,6 +119,8 @@ export function createStreamHandlers(deps) {
|
|
|
115
119
|
markRecapStale();
|
|
116
120
|
shownTexts.turnStarted();
|
|
117
121
|
const promptBubble = applyPromptOps(data);
|
|
122
|
+
liveTurn.cutAfter = data.cut_after ?? null;
|
|
123
|
+
liveTurn.serverGen = null;
|
|
118
124
|
setTurnRunning(true);
|
|
119
125
|
setLiveStatus("running");
|
|
120
126
|
liveTurn.timing.detach();
|
|
@@ -197,9 +197,14 @@ export function llmContextNotice(data) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
// The one muted line for a turn that ended with no answer (turn_completed's
|
|
200
|
-
// turn_summary.empty_answer, or a reloaded turn's marker)
|
|
201
|
-
//
|
|
202
|
-
|
|
200
|
+
// turn_summary.empty_answer, or a reloaded turn's marker), or one the
|
|
201
|
+
// provider refused (its `refusal`, with the category when named): the TUI
|
|
202
|
+
// words it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
|
|
203
|
+
export function emptyAnswerLine({ retries = 0, refusal = null } = {}) {
|
|
204
|
+
if (refusal) {
|
|
205
|
+
const category = String(refusal.category ?? "").trim();
|
|
206
|
+
return `no answer: refused by the provider${category ? ` (${category})` : ""}`;
|
|
207
|
+
}
|
|
203
208
|
const count = Number(retries) || 0;
|
|
204
209
|
const after = count > 0 ? ` (after ${count} ${count === 1 ? "retry" : "retries"})` : "";
|
|
205
210
|
return `no answer: the model returned nothing${after}`;
|
|
@@ -254,9 +259,11 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
|
|
|
254
259
|
switch (event.type) {
|
|
255
260
|
case "turn_enqueued": {
|
|
256
261
|
const id = event.enqueued_id;
|
|
257
|
-
|
|
262
|
+
// Queued for after the running turn (delivery queue): its bubble says so.
|
|
263
|
+
const delivery = event.delivery ? { delivery: event.delivery } : {};
|
|
264
|
+
if (isOwn(event.client_id, myId)) return [withImages({ op: "tag", enqueuedId: id, prompt: event.prompt, ...delivery }, event.images)];
|
|
258
265
|
if (id && known(id)) return [];
|
|
259
|
-
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "queued", label: clientLabel(event.client_id) }, event.images)];
|
|
266
|
+
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "queued", label: clientLabel(event.client_id), ...delivery }, event.images)];
|
|
260
267
|
}
|
|
261
268
|
case "turn_started": {
|
|
262
269
|
if (event.continue || !event.prompt) return [];
|
|
@@ -308,7 +315,10 @@ export function replaysDrawnTurn(event, turnRecords = []) {
|
|
|
308
315
|
export function snapshotEvents({ current_turn: turn = null, queued = [], queued_commands: commands = [], started_at = undefined } = {}) {
|
|
309
316
|
const events = [];
|
|
310
317
|
if (turn) {
|
|
311
|
-
|
|
318
|
+
// The run's cut wait (TurnAccumulator, as turn_started carried it): the
|
|
319
|
+
// composer's delivery line reads it.
|
|
320
|
+
const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
|
|
321
|
+
events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...cutAfter }, turn.images));
|
|
312
322
|
let textIteration = null; // a text bubble is open for this iteration
|
|
313
323
|
const closeText = () => {
|
|
314
324
|
if (textIteration !== null) events.push({ type: "generation_completed" });
|
|
@@ -340,7 +350,10 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
340
350
|
// EVENT_FIELDS), as the live events carry them.
|
|
341
351
|
const title = pickFields(part, ACTIVITY_FIELDS);
|
|
342
352
|
const fields = pickFields(part, EVENT_FIELDS);
|
|
343
|
-
|
|
353
|
+
// A running call's time so far (TurnAccumulator elapsed_ms): a join
|
|
354
|
+
// clocks it from its start (activity.js markStart).
|
|
355
|
+
const elapsed = part.status === "running" && part.elapsed_ms != null ? { elapsed_ms: part.elapsed_ms } : {};
|
|
356
|
+
events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...fields, ...elapsed });
|
|
344
357
|
if (part.status !== "running") {
|
|
345
358
|
// The activity as the live event had it; action and duration_ms
|
|
346
359
|
// only in snapshots from a worker that keeps them (the TUI's
|
|
@@ -385,7 +398,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
385
398
|
}
|
|
386
399
|
}
|
|
387
400
|
for (const entry of queued || []) {
|
|
388
|
-
|
|
401
|
+
const delivery = entry.delivery ? { delivery: entry.delivery } : {};
|
|
402
|
+
events.push(withImages({ type: "turn_enqueued", enqueued_id: entry.enqueued_id, client_id: entry.client_id, prompt: entry.prompt, ...delivery }, entry.images));
|
|
389
403
|
}
|
|
390
404
|
// Commands waiting for the turn's end: their bubbles, as live.
|
|
391
405
|
for (const entry of commands || []) {
|
|
@@ -116,8 +116,22 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
|
|
|
116
116
|
// gave it back). Any other state ("started", none) is a plain bubble.
|
|
117
117
|
export const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// A queued line's badge by its delivery (the composer's choice, the
|
|
120
|
+
// turn_enqueued's): what it waits for. next_step: the running turn's next
|
|
121
|
+
// step; cut: the cut (the ack may say it waits); queue: a turn of its own
|
|
122
|
+
// after this one.
|
|
123
|
+
const QUEUED_BADGES = { next_step: "waits for the next step", cut: "cutting in\u2026", queue: "after this turn" };
|
|
124
|
+
|
|
125
|
+
// A bubble's badge words for +state+ (and +delivery+ while queued); null
|
|
126
|
+
// for a plain bubble.
|
|
127
|
+
export function badgeText(state, delivery = null) {
|
|
128
|
+
if (!BADGES[state]) return null;
|
|
129
|
+
return (state === "queued" && QUEUED_BADGES[delivery]) || BADGES[state];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function stateBadgeTemplate(state, delivery = null) {
|
|
133
|
+
const text = badgeText(state, delivery);
|
|
134
|
+
return text ? html`<span class="state-badge">${text}</span>` : nothing;
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
// A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
|
|
@@ -203,10 +217,12 @@ export function steerRowTemplate({ source, text } = {}, { bubble = false } = {})
|
|
|
203
217
|
// merged mid-turn (a reload's; a resolved question goes before it).
|
|
204
218
|
// The live page's own: +own+ (this tab sent it), +enqueuedId+ and +key+
|
|
205
219
|
// (its normalized text: an echo waiting for its id is matched by it).
|
|
206
|
-
|
|
220
|
+
// +delivery+: how it was sent into a running turn (next_step, cut, queue):
|
|
221
|
+
// its data-delivery and its queued badge's words.
|
|
222
|
+
export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null, delivery = null } = {}) {
|
|
207
223
|
const named = BADGES[state] ? state : null;
|
|
208
224
|
const from = label ? html`<span class="origin-label">${label}</span>` : nothing;
|
|
209
|
-
return html`<div class=${`bubble user${named ? ` ${named}` : ""}`} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named)}</div>`;
|
|
225
|
+
return html`<div class=${`bubble user${named ? ` ${named}` : ""}`} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
// The notice of a turn that ended with no answer (the server's
|