samagotchi 0.54.0 → 0.56.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 +50 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +6 -2
- data/docs/cli.md +29 -7
- data/docs/configuration.md +113 -8
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +54 -18
- data/docs/internals/prompt-caching.md +8 -0
- 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/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +9 -3
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -4
- 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 +256 -1133
- data/lib/samagotchi/web/public/commands.js +269 -0
- data/lib/samagotchi/web/public/composer.js +105 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +11 -3
- data/lib/samagotchi/web/public/info_bar.js +80 -0
- data/lib/samagotchi/web/public/level_chip.js +174 -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/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +2 -1
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- 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 +57 -0
- data/lib/samagotchi/web/public/stream_handlers.js +332 -0
- data/lib/samagotchi/web/public/thinking_chip.js +38 -144
- 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
- data/lib/samagotchi/worker.rb +17 -5
- metadata +18 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -0,0 +1,332 @@
|
|
|
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,
|
|
19
|
+
// reasoningHistoryControl the page's views
|
|
20
|
+
// sentIds, earlyRestores restore.js's bookkeeping
|
|
21
|
+
// stream: {takeGuess(), restart()} the stream was opened from a
|
|
22
|
+
// guess (takeGuess: whether, and forget it); restart:
|
|
23
|
+
// close the stream and resync
|
|
24
|
+
// composer: {empty(), refill(text), setChips(chips)}
|
|
25
|
+
// getSession(id, opts) data.js
|
|
26
|
+
// resync(), updateInfoBar(), applyPromptOps(data), showSteers(steers),
|
|
27
|
+
// markRecapStale(), setTurnRunning(running), setLiveStatus(status),
|
|
28
|
+
// endTurnView(kind, endText, opts), appendChunk(text), readTurnEnd(),
|
|
29
|
+
// applyFinalMarkdown(messages), refreshTimingFromSession(),
|
|
30
|
+
// makeUserBubble(prompt, opts), addEndBubble(text), commandBubble(id),
|
|
31
|
+
// addCommandBubble(view), addNoteBubble(note), findInTurnRoots(selector),
|
|
32
|
+
// setUsedMemories(names), showRecap(text), showGuardrailWarning(text, label),
|
|
33
|
+
// showHookNotice(data), onInitEvent(type, data), upsertCard(card),
|
|
34
|
+
// refreshCardBodies() the page's hooks
|
|
35
|
+
|
|
36
|
+
import { routeChunk } from "./chunk_router.js";
|
|
37
|
+
import { extractCtxPct, generationTokens } from "./ctx.js";
|
|
38
|
+
import { failedTurnText } from "./format.js";
|
|
39
|
+
import { cancelLineText } from "./timing.js";
|
|
40
|
+
import { keepFollowing } from "./scroll.js";
|
|
41
|
+
import { shownElsewhere } from "./card.js";
|
|
42
|
+
import { restoredChips } from "./images.js";
|
|
43
|
+
import {
|
|
44
|
+
clientLabel, displayWait, emptyRetryLine, initWaitLine, isOwn, llmContextNotice, reminderText, replaysDrawnTurn,
|
|
45
|
+
retryStatusLine, steerCutLine, turnOutput,
|
|
46
|
+
} from "./turn_events.js";
|
|
47
|
+
import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
|
|
48
|
+
import { keepEarlyRestore, restoreAction } from "./restore.js";
|
|
49
|
+
|
|
50
|
+
export function createStreamHandlers(deps) {
|
|
51
|
+
const {
|
|
52
|
+
selected, state, clientId, capabilities, historyEl,
|
|
53
|
+
liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl, reasoningHistoryControl,
|
|
54
|
+
sentIds, earlyRestores, stream, composer, getSession,
|
|
55
|
+
resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
|
|
56
|
+
appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
|
|
57
|
+
commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
|
|
58
|
+
showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies,
|
|
59
|
+
} = deps;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
generation_chunk: (data) => {
|
|
63
|
+
// Route the chunk's bytes to the text bubble and the thinking block (see
|
|
64
|
+
// chunk_router.js). Raw `content` is only a fallback for an older server.
|
|
65
|
+
const { text, thinking } = routeChunk(data);
|
|
66
|
+
liveTurn.timing.setNote("");
|
|
67
|
+
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
68
|
+
const current = state();
|
|
69
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
70
|
+
if (pct !== null && !Number.isNaN(pct)) {
|
|
71
|
+
current.ctxPct = pct;
|
|
72
|
+
updateInfoBar();
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
generation_started: (data) => {
|
|
76
|
+
if (data?.context_window_tokens) {
|
|
77
|
+
const current = state();
|
|
78
|
+
current.ctxWindow = data.context_window_tokens;
|
|
79
|
+
current.ctxWindowSource = data.context_window_source || null;
|
|
80
|
+
}
|
|
81
|
+
liveTurn.timing.setNote("");
|
|
82
|
+
turnDom.generationStarted(data || {});
|
|
83
|
+
},
|
|
84
|
+
generation_completed: (data) => {
|
|
85
|
+
liveTurn.timing.setNote("");
|
|
86
|
+
const current = state();
|
|
87
|
+
if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
|
|
88
|
+
// The generation's speed and the session's token totals, from the server.
|
|
89
|
+
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
90
|
+
if (data?.served_model || data?.tokens) updateInfoBar();
|
|
91
|
+
turnDom.generationCompleted();
|
|
92
|
+
},
|
|
93
|
+
// Prompts sent while a turn ran merge into it at an iteration boundary:
|
|
94
|
+
// input_merged names them (their bubbles turn "steered"), then
|
|
95
|
+
// pending_input_merged carries the merged text, shown only for prompts
|
|
96
|
+
// this tab has no bubble for. Its `answer` (the answer the merge
|
|
97
|
+
// superseded) is the TUI's line: here it streamed as that step's text.
|
|
98
|
+
input_merged: (data) => applyPromptOps(data),
|
|
99
|
+
pending_input_merged: (data) => {
|
|
100
|
+
applyPromptOps(data);
|
|
101
|
+
showSteers(data.steers);
|
|
102
|
+
},
|
|
103
|
+
turn_enqueued: (data) => applyPromptOps(data),
|
|
104
|
+
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
105
|
+
if (stream.takeGuess()) {
|
|
106
|
+
// A replay of a turn the render drew: re-read and stream from there.
|
|
107
|
+
if (replaysDrawnTurn(data, state().timing.turnRecords)) {
|
|
108
|
+
stream.restart();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// A finished turn still in the stage goes first: the start op looks
|
|
113
|
+
// for a queued prompt among its rows.
|
|
114
|
+
turnDom.handOff?.({ immediate: true });
|
|
115
|
+
markRecapStale();
|
|
116
|
+
shownTexts.turnStarted();
|
|
117
|
+
const promptBubble = applyPromptOps(data);
|
|
118
|
+
setTurnRunning(true);
|
|
119
|
+
setLiveStatus("running");
|
|
120
|
+
liveTurn.timing.detach();
|
|
121
|
+
turnDom.turnStarted({ ...data, promptBubble });
|
|
122
|
+
liveTurn.timing.start(data.started_at, data.turn_id || null);
|
|
123
|
+
}),
|
|
124
|
+
turn_completed: async (data) => {
|
|
125
|
+
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
126
|
+
const out = turnOutput(data);
|
|
127
|
+
if (out) appendChunk(out);
|
|
128
|
+
// after_turn hooks are running and may present the answer: its
|
|
129
|
+
// answer_display (display: null when they didn't) comes next. A worker
|
|
130
|
+
// that dies before it never sends one: workerGone releases the wait, and
|
|
131
|
+
// displayWait's timeout ends it whatever happens (4.14).
|
|
132
|
+
const display = data.display_pending ? displayWait() : null;
|
|
133
|
+
if (display) liveTurn.displayPending = display;
|
|
134
|
+
liveTurn.endRead = readTurnEnd();
|
|
135
|
+
await liveTurn.endRead;
|
|
136
|
+
// The answer bubble the turn view held at its box's height pops now
|
|
137
|
+
// that the rendered markdown is in it (or the re-read failed: then the
|
|
138
|
+
// plain text pops), and the display if one was coming, so the links
|
|
139
|
+
// never swap in after the pop. The hold reveals by itself after
|
|
140
|
+
// HOLD_MS whatever comes. The stage has no hold.
|
|
141
|
+
if (display) await display.promise;
|
|
142
|
+
turnDom.answerReady?.();
|
|
143
|
+
},
|
|
144
|
+
// An after_turn hook presented the answer (AnswerDisplay): it comes after
|
|
145
|
+
// turn_completed, so re-read the answer once that read is done.
|
|
146
|
+
answer_display: async (data) => {
|
|
147
|
+
try {
|
|
148
|
+
await liveTurn.endRead;
|
|
149
|
+
if (!selected() || !data.display) return;
|
|
150
|
+
const d = await getSession(selected(), { tail: true, recent: true });
|
|
151
|
+
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
152
|
+
} catch (_) {
|
|
153
|
+
} finally {
|
|
154
|
+
liveTurn.releaseDisplay();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
turn_canceled: (data) => {
|
|
158
|
+
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
|
|
159
|
+
setLiveStatus("idle");
|
|
160
|
+
refreshTimingFromSession();
|
|
161
|
+
},
|
|
162
|
+
// The turn raised (e.g. the model server stayed unreachable). The worker
|
|
163
|
+
// stays up: it rolls the turn back and hands the prompt back
|
|
164
|
+
// (prompt_restored), and the prompts queued behind it run as usual. A turn
|
|
165
|
+
// that got to tool steps stays as it is (kept_steps; no prompt_restored).
|
|
166
|
+
turn_failed: (data) => {
|
|
167
|
+
const text = failedTurnText(data);
|
|
168
|
+
// Only a rolled-back turn's line waits for its prompt_restored.
|
|
169
|
+
liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
|
|
170
|
+
endTurnView("failed", text);
|
|
171
|
+
setLiveStatus("idle");
|
|
172
|
+
},
|
|
173
|
+
// The rolled-back turn left the session: render it from the server again,
|
|
174
|
+
// then show the prompt once more, marked failed (with why). This tab's own
|
|
175
|
+
// prompt goes back into an empty composer.
|
|
176
|
+
prompt_restored: async (data) => {
|
|
177
|
+
keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
|
|
178
|
+
const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
|
|
179
|
+
if (refill) composer.refill(refill);
|
|
180
|
+
if (restoredImages?.length) composer.setChips(restoredChips(selected(), restoredImages));
|
|
181
|
+
const failedText = liveTurn.failedText;
|
|
182
|
+
liveTurn.failedText = null;
|
|
183
|
+
await resync();
|
|
184
|
+
// The re-render drew it from the session (failed_turn): drawn again
|
|
185
|
+
// below with this event's label and images.
|
|
186
|
+
const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
|
|
187
|
+
if (drawn) {
|
|
188
|
+
if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
|
|
189
|
+
drawn.remove();
|
|
190
|
+
}
|
|
191
|
+
// Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
|
|
192
|
+
// a resend's echo isn't matched to it.
|
|
193
|
+
makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
|
|
194
|
+
if (failedText) addEndBubble(failedText);
|
|
195
|
+
},
|
|
196
|
+
// A session command ran in the worker (any client's). One that changed
|
|
197
|
+
// the conversation (!rollback, !cmd, a continue answered no) re-reads it.
|
|
198
|
+
command_ran: async (data) => {
|
|
199
|
+
const view = commandView(data, clientId);
|
|
200
|
+
if (view.modelName && view.modelName !== state().model) {
|
|
201
|
+
state().model = view.modelName;
|
|
202
|
+
state().served = null;
|
|
203
|
+
updateInfoBar();
|
|
204
|
+
}
|
|
205
|
+
// /llm-context changed what the next turn runs under.
|
|
206
|
+
if (data.llm_context) {
|
|
207
|
+
state().llmContext = data.llm_context;
|
|
208
|
+
updateInfoBar();
|
|
209
|
+
llmContextControl.refresh();
|
|
210
|
+
}
|
|
211
|
+
// /thinking (or /model) changed the level the next turn runs at.
|
|
212
|
+
if (data.thinking) {
|
|
213
|
+
state().thinking = data.thinking;
|
|
214
|
+
updateInfoBar();
|
|
215
|
+
thinkingControl.refresh();
|
|
216
|
+
}
|
|
217
|
+
// /reasoning-history (or /model) changed it; null: the model now runs
|
|
218
|
+
// on a native host, which ignores it (the chip goes).
|
|
219
|
+
if ("reasoning_history" in data) {
|
|
220
|
+
state().reasoningHistory = data.reasoning_history || null;
|
|
221
|
+
updateInfoBar();
|
|
222
|
+
reasoningHistoryControl.refresh();
|
|
223
|
+
}
|
|
224
|
+
if (view.resync) await resync();
|
|
225
|
+
// A card's action that isn't anytime (the step-limit question's
|
|
226
|
+
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
227
|
+
// one's bubble was drawn at its command_queued: filled in here. One
|
|
228
|
+
// queued for after the turn ran after it: its bubble moves from among
|
|
229
|
+
// the turn's rows to after its answer and timing line.
|
|
230
|
+
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
231
|
+
if (view.hidden) queued?.remove();
|
|
232
|
+
else if (queued && view.queued && !view.anytime) {
|
|
233
|
+
queued.remove();
|
|
234
|
+
addCommandBubble(view);
|
|
235
|
+
} else if (queued) drawCommandBubble(queued, view);
|
|
236
|
+
// A command that changed the conversation (!cmd): the redrawn history
|
|
237
|
+
// ends with its command bubble already (the saved "!(…)\n<output>"
|
|
238
|
+
// message renders as one) — no second bubble, or the output shows twice.
|
|
239
|
+
else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
|
|
240
|
+
else addCommandBubble(view);
|
|
241
|
+
},
|
|
242
|
+
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
243
|
+
// turn: its bubble goes here, so the cards it shows come after it. One
|
|
244
|
+
// queued for after the turn (/model X mid-turn) shows waiting here.
|
|
245
|
+
command_queued: (data) => {
|
|
246
|
+
// A card's action waits for its command_ran: shown only if it answers.
|
|
247
|
+
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
248
|
+
},
|
|
249
|
+
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
250
|
+
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
251
|
+
// A context note joined the conversation (between turns; no turn runs).
|
|
252
|
+
context_added: (data) => {
|
|
253
|
+
addNoteBubble({ label: data.label, content: data.text || "" });
|
|
254
|
+
// An attached context source's note: its chip changed.
|
|
255
|
+
if (data.context_source) contextChips.refresh();
|
|
256
|
+
},
|
|
257
|
+
continue_offered: (data) => questionCards.renderContinue(data),
|
|
258
|
+
continue_resolved: (data) => questionCards.resolveContinue(data),
|
|
259
|
+
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
260
|
+
tool_call_completed: (data) => turnDom.toolCompleted(data),
|
|
261
|
+
// The engine's list, after every memory read and at a turn's end.
|
|
262
|
+
used_memories_updated: (data) => {
|
|
263
|
+
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
264
|
+
},
|
|
265
|
+
context_status: (data) => {
|
|
266
|
+
const current = state();
|
|
267
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
268
|
+
if (pct !== null) {
|
|
269
|
+
current.ctxPct = pct;
|
|
270
|
+
updateInfoBar();
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
question_requested: (data) => {
|
|
274
|
+
const pq = data.pending_question;
|
|
275
|
+
if (pq && pq.id) questionCards.renderQuestion(pq);
|
|
276
|
+
},
|
|
277
|
+
question_answered: (data) => {
|
|
278
|
+
questionCards.resolveQuestion(data.id, { answer: data.answer });
|
|
279
|
+
},
|
|
280
|
+
// A delegate's question relayed to its parent's card, or no longer.
|
|
281
|
+
question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
|
|
282
|
+
question_cancelled: (data) => {
|
|
283
|
+
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
284
|
+
},
|
|
285
|
+
// One collected just as a turn started describes the chat before it.
|
|
286
|
+
recap_ready: (data) => {
|
|
287
|
+
// After the turn it covers, never above it.
|
|
288
|
+
turnDom.handOff?.({ immediate: true });
|
|
289
|
+
if (!liveTurn.running) showRecap(data.recap);
|
|
290
|
+
},
|
|
291
|
+
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
292
|
+
// A notice during a turn is a row of its current step. One the page
|
|
293
|
+
// shows elsewhere (a hook's links in the rendered answer) isn't drawn,
|
|
294
|
+
// not even in the stage trail; a joined turn's notices come here too.
|
|
295
|
+
hook_notice: (data) => {
|
|
296
|
+
if (shownElsewhere(data, capabilities())) return;
|
|
297
|
+
if (!turnDom.notice(data)) showHookNotice(data);
|
|
298
|
+
},
|
|
299
|
+
// The loop asks again after an empty answer: a row of the empty step
|
|
300
|
+
// (a reload puts it back from the snapshot's cards).
|
|
301
|
+
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
302
|
+
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
|
|
303
|
+
// A batch of LLM context edits the server applied: a ✂ row of the
|
|
304
|
+
// current step, its hover listing the outputs.
|
|
305
|
+
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
306
|
+
// The provider is asked again after an error, or the turn waits for
|
|
307
|
+
// plugins' setup: a note on the live timing line until it streams.
|
|
308
|
+
generation_retrying: (data) => {
|
|
309
|
+
// A dropped stream's step asked again: what it streamed is void.
|
|
310
|
+
if (data?.restarted) turnDom.generationRestarted(data);
|
|
311
|
+
liveTurn.timing.setNote(retryStatusLine(data));
|
|
312
|
+
},
|
|
313
|
+
plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
|
|
314
|
+
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
315
|
+
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
316
|
+
// A plugin's card, or a new version of one (the same id).
|
|
317
|
+
card: (data) => {
|
|
318
|
+
upsertCard(data);
|
|
319
|
+
if (data.body) refreshCardBodies();
|
|
320
|
+
},
|
|
321
|
+
// The stream's cursor could not be replayed: render from scratch.
|
|
322
|
+
reset: () => resync(),
|
|
323
|
+
// Synthetic (snapshotEvents): a prompt merged into the turn in progress.
|
|
324
|
+
merged_input: (data) => {
|
|
325
|
+
const origin = (data.origins || [])[0] || {};
|
|
326
|
+
const own = isOwn(origin.client_id, clientId);
|
|
327
|
+
makeUserBubble(data.content, {
|
|
328
|
+
state: "steered", own, enqueuedId: origin.enqueued_id || null, label: own ? null : clientLabel(origin.client_id),
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
@@ -2,20 +2,35 @@
|
|
|
2
2
|
// low"; "· session" when the session set its own), and a popover with a
|
|
3
3
|
// level select that runs /thinking in the session's worker (the command's
|
|
4
4
|
// reply shows in the conversation, like a typed one). The level and where
|
|
5
|
-
// it came from are the session's thinking (
|
|
5
|
+
// it came from are the session's thinking (SettingExplained#summary:
|
|
6
6
|
// {level, source, own}; the worker's snapshot, a command_ran, or the
|
|
7
|
-
// server's own reading of the session file).
|
|
7
|
+
// server's own reading of the session file). A config on level_chip.js.
|
|
8
8
|
//
|
|
9
9
|
// The start page has one too (kind "new chat"): it shows the picked
|
|
10
10
|
// model's level (GET /api/models' thinking) and keeps a choice for the
|
|
11
11
|
// next create only (POST /api/sessions' thinking), never remembered:
|
|
12
12
|
// nothing is sent unless the user sets one.
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
14
|
+
import { SESSION } from "./llm_context_chip.js";
|
|
15
|
+
import * as level from "./level_chip.js";
|
|
16
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
16
17
|
|
|
17
18
|
export const LEVELS = ["off", "low", "medium", "high"];
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
export const CONFIG = Object.freeze({
|
|
21
|
+
name: "Thinking",
|
|
22
|
+
prefix: "think",
|
|
23
|
+
command: "/thinking",
|
|
24
|
+
levels: LEVELS,
|
|
25
|
+
anchor: "[data-think-chip]",
|
|
26
|
+
formClass: "think-form",
|
|
27
|
+
popoverClass: "think-popover",
|
|
28
|
+
nowClass: "think-now",
|
|
29
|
+
when: (fresh) => (fresh ? "For the new chat's first turn on; not remembered for the next one."
|
|
30
|
+
: "From the next turn's start. On Gemma the whole prompt is read again; a hosted API reads the whole conversation again."),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const { nowText } = level;
|
|
19
34
|
|
|
20
35
|
// The new chat's choice (a level, or null) laid over the model's summary:
|
|
21
36
|
// what the chip shows. The model's own summary without a choice.
|
|
@@ -28,152 +43,31 @@ export function withChoice(summary, choice) {
|
|
|
28
43
|
// set its own; null without a summary (an older worker). The default with
|
|
29
44
|
// nothing set anywhere shows too ("think default"): the chip is where the
|
|
30
45
|
// level is set.
|
|
31
|
-
export function chipModel(summary,
|
|
32
|
-
|
|
33
|
-
const own = !!summary.own;
|
|
34
|
-
const what = kind === NEW_CHAT ? "the new chat" : "this session";
|
|
35
|
-
const from = own ? what : summary.source || "the model's own";
|
|
36
|
-
const title = `Thinking: ${summary.level} (${from}). Click to change it for ${what}.`;
|
|
37
|
-
return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
|
|
46
|
+
export function chipModel(summary, opts) {
|
|
47
|
+
return level.chipModel(CONFIG, summary, opts);
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
// The session bar's chip
|
|
41
|
-
//
|
|
42
|
-
export function
|
|
50
|
+
// The session bar's chip (a lit-html template; info_bar.js), nothing
|
|
51
|
+
// without a summary.
|
|
52
|
+
export function chipTemplate(summary) {
|
|
43
53
|
const chip = chipModel(summary);
|
|
44
|
-
if (!chip) return
|
|
45
|
-
return `<button type="button" class
|
|
46
|
-
`data-think-chip aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// The /thinking line for a select value ("default" unsets the session's
|
|
50
|
-
// own); null when it is what the form opened with.
|
|
51
|
-
export function commandLine(level, initial = null) {
|
|
52
|
-
if (initial !== null && level === initial) return null;
|
|
53
|
-
return `/thinking ${level}`;
|
|
54
|
+
if (!chip) return nothing;
|
|
55
|
+
return html`<button type="button" class=${`model think-chip${chip.own ? " own" : ""}`} title=${chip.title} data-think-chip aria-haspopup="dialog">${chip.text}</button>`;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// The /thinking line for a select value, with --yes (level_chip.js
|
|
59
|
+
// commandLine); null when it is what the form opened with.
|
|
60
|
+
export function commandLine(choice, initial = null) {
|
|
61
|
+
return level.commandLine(CONFIG, choice, initial);
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// NEW_CHAT), +summary+ is the model's and +choice+ the level the form
|
|
65
|
-
// shows; +now+ is the summary its "Now:" line reads (refresh passes a
|
|
66
|
-
// newer one, the rest stays as the form opened).
|
|
67
|
-
export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
|
|
68
|
-
const fresh = kind === NEW_CHAT;
|
|
69
|
-
const chosen = (fresh ? choice : summary.own) || DEFAULT;
|
|
70
|
-
const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
|
|
71
|
-
const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
|
|
72
|
-
: "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
|
|
73
|
-
return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
|
|
74
|
-
><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
|
|
75
|
-
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
76
|
-
><div class="ctx-pop-meta think-now">${nowText(now)}</div
|
|
77
|
-
><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
|
|
78
|
-
LEVELS.map((level) => option(level, level, chosen))}</select></label
|
|
79
|
-
><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
|
|
80
|
-
><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
64
|
+
// The popover's form (level_chip.js formTemplate).
|
|
65
|
+
export function formTemplate(summary, opts) {
|
|
66
|
+
return level.formTemplate(CONFIG, summary, opts);
|
|
81
67
|
}
|
|
82
68
|
|
|
83
|
-
// The chip's popover
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// NEW_CHAT): summary() is the picked model's, choice() the pending level
|
|
88
|
-
// and choose(next) takes a Set's (null for "follow the model"). id and
|
|
89
|
-
// anchor (the chip's selector) keep it apart from the llm ctx control on
|
|
90
|
-
// the same element.
|
|
91
|
-
export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
|
|
92
|
-
kind = SESSION, choice = () => null, choose = null,
|
|
93
|
-
id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
|
|
94
|
-
doc = document, win = window }) {
|
|
95
|
-
const fresh = kind === NEW_CHAT;
|
|
96
|
-
const pop = doc.createElement("div");
|
|
97
|
-
pop.id = id;
|
|
98
|
-
pop.className = "ctx-popover think-popover";
|
|
99
|
-
pop.setAttribute("role", "dialog");
|
|
100
|
-
pop.hidden = true;
|
|
101
|
-
doc.body.appendChild(pop);
|
|
102
|
-
let initial = null;
|
|
103
|
-
let openFor = null;
|
|
104
|
-
// What the open form was drawn from: the model's or session's summary
|
|
105
|
-
// and the form's options. A closed popover is empty, so each open draws
|
|
106
|
-
// a fresh form (an unsent pick is gone).
|
|
107
|
-
let shown = null;
|
|
108
|
-
const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
|
|
109
|
-
|
|
110
|
-
function place(anchor) {
|
|
111
|
-
const width = Math.min(320, win.innerWidth - 32);
|
|
112
|
-
pop.style.width = `${width}px`;
|
|
113
|
-
pop.style.maxHeight = "";
|
|
114
|
-
const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
|
|
115
|
-
innerWidth: win.innerWidth, innerHeight: win.innerHeight });
|
|
116
|
-
pop.style.left = `${at.left}px`;
|
|
117
|
-
pop.style.top = at.top === undefined ? "" : `${at.top}px`;
|
|
118
|
-
pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
|
|
119
|
-
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function open(anchor) {
|
|
123
|
-
const data = summary();
|
|
124
|
-
if (!data) return;
|
|
125
|
-
initial = (fresh ? choice() : data.own) || DEFAULT;
|
|
126
|
-
openFor = session();
|
|
127
|
-
shown = { data, opts: { running: running(), kind, choice: choice() } };
|
|
128
|
-
draw(data);
|
|
129
|
-
pop.hidden = false;
|
|
130
|
-
place(anchor);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function close() {
|
|
134
|
-
pop.hidden = true;
|
|
135
|
-
initial = null;
|
|
136
|
-
openFor = null;
|
|
137
|
-
shown = null;
|
|
138
|
-
render(nothing, pop);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function isOpen() { return !pop.hidden; }
|
|
142
|
-
|
|
143
|
-
// A new summary: an open form keeps the user's pick; only "Now:" follows
|
|
144
|
-
// (the same template again: lit writes that line alone).
|
|
145
|
-
function refresh() {
|
|
146
|
-
if (!isOpen()) return;
|
|
147
|
-
const data = summary();
|
|
148
|
-
if (!data || session() !== openFor) return close();
|
|
149
|
-
draw(data);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
el.addEventListener("click", (e) => {
|
|
153
|
-
const anchor = e.target.closest(anchorSel);
|
|
154
|
-
if (!anchor) return;
|
|
155
|
-
e.stopPropagation();
|
|
156
|
-
if (isOpen()) close();
|
|
157
|
-
else open(anchor);
|
|
158
|
-
});
|
|
159
|
-
pop.addEventListener("submit", (e) => {
|
|
160
|
-
e.preventDefault();
|
|
161
|
-
const level = e.target.elements.level.value;
|
|
162
|
-
const was = initial;
|
|
163
|
-
close();
|
|
164
|
-
if (fresh) return choose(level === DEFAULT ? null : level);
|
|
165
|
-
const line = commandLine(level, was);
|
|
166
|
-
if (line) run(line);
|
|
167
|
-
});
|
|
168
|
-
pop.addEventListener("click", (e) => {
|
|
169
|
-
if (e.target.closest("[data-act]")?.dataset.act === "close") close();
|
|
170
|
-
});
|
|
171
|
-
doc.addEventListener("click", (e) => {
|
|
172
|
-
if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
|
|
173
|
-
});
|
|
174
|
-
doc.addEventListener("keydown", (e) => {
|
|
175
|
-
if (e.key === "Escape" && isOpen()) close();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return { close, refresh, get open() { return isOpen(); } };
|
|
69
|
+
// The chip's popover (level_chip.js createLevelControl); the start page's
|
|
70
|
+
// passes kind NEW_CHAT, choice and choose.
|
|
71
|
+
export function createThinkingControl({ id = "thinkingPopover", ...opts }) {
|
|
72
|
+
return level.createLevelControl({ cfg: CONFIG, id, ...opts });
|
|
179
73
|
}
|