samagotchi 0.67.0 → 0.69.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 +62 -1
- data/docs/architecture.md +7 -4
- data/docs/cli.md +6 -1
- data/docs/configuration.md +28 -6
- data/docs/guardrails.md +23 -1
- data/docs/hooks.md +4 -0
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +73 -17
- data/docs/internals/prompt-caching.md +9 -2
- data/docs/plugins.md +42 -19
- data/docs/releasing.md +14 -6
- data/docs/sessions.md +3 -3
- data/docs/testing.md +8 -2
- data/lib/samagotchi/bootstrap/probe.rb +1 -3
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +0 -19
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +216 -360
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/host_config.rb +1 -29
- data/lib/samagotchi/host_model.rb +0 -7
- data/lib/samagotchi/kernel_loop.rb +24 -7
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/errors.rb +47 -1
- data/lib/samagotchi/llm/http.rb +20 -2
- data/lib/samagotchi/llm_context_stale.rb +6 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +24 -3
- data/lib/samagotchi/toolkit.rb +27 -14
- data/lib/samagotchi/toolkit_binding.rb +8 -2
- data/lib/samagotchi/toolkit_command.rb +20 -4
- data/lib/samagotchi/tools/builtin_calls.rb +45 -1
- data/lib/samagotchi/tools/registry.rb +95 -8
- data/lib/samagotchi/tools/tool_index.rb +223 -72
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +69 -93
- data/lib/samagotchi/web/message_parts.rb +5 -2
- data/lib/samagotchi/web/public/activity.js +3 -1
- data/lib/samagotchi/web/public/app.js +196 -526
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +21 -11
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
- data/lib/samagotchi/web/public/model_picker.js +47 -28
- data/lib/samagotchi/web/public/ref_scan.js +1 -7
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +33 -0
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/stream_handlers.js +5 -2
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/toast_place.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/worker.rb +11 -16
- metadata +15 -1
|
@@ -32,47 +32,49 @@ import {
|
|
|
32
32
|
} from "./thinking_chip.js";
|
|
33
33
|
import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
|
|
34
34
|
import { renderInfoBar } from "./info_bar.js";
|
|
35
|
+
import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
|
|
35
36
|
import { createShownTexts } from "./shown_texts.js";
|
|
36
37
|
import { MODEL_KEY } from "./model_pick.js";
|
|
37
38
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
38
39
|
import { createModelPicker } from "./model_picker.js";
|
|
39
40
|
import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
40
|
-
import { normalize, firstPreview, failedTurnText,
|
|
41
|
-
import { createTurnView,
|
|
42
|
-
import {
|
|
41
|
+
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
|
|
42
|
+
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
43
|
+
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
44
|
+
import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
|
|
45
|
+
import { createCardsPlace } from "./cards_place.js";
|
|
43
46
|
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
44
|
-
import { CONTINUE_OPENER,
|
|
47
|
+
import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
|
|
45
48
|
import { createTaskStopper } from "./task_stop.js";
|
|
46
49
|
import { appendQuote } from "./annotations.js";
|
|
47
50
|
import { parsePresets } from "./annotate_presets.js";
|
|
48
51
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
49
52
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
50
53
|
import { copyText, installCopy } from "./copy.js";
|
|
51
|
-
import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
|
|
52
54
|
import { createQuestionCards } from "./question_cards.js";
|
|
53
|
-
import {
|
|
55
|
+
import { splitSnapshotCards } from "./card.js";
|
|
54
56
|
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
55
57
|
import { createCommandList } from "./command_list.js";
|
|
56
58
|
import { createPromptHistory } from "./prompt_history.js";
|
|
57
59
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
58
60
|
import { scopeDir, scopeTemplate } from "./scope.js";
|
|
59
61
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
60
|
-
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll
|
|
62
|
+
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
|
|
61
63
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
62
|
-
import { applySessionEvent,
|
|
64
|
+
import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
63
65
|
import { createNotifications } from "./notifications.js";
|
|
64
66
|
import { tabTitle, topBarTitle } from "./session_title.js";
|
|
65
|
-
import { restartConfirmText, versionNotice
|
|
67
|
+
import { restartConfirmText, versionNotice } from "./update_notice.js";
|
|
66
68
|
import {
|
|
67
|
-
appendAboveLiveTiming,
|
|
69
|
+
appendAboveLiveTiming, mergeTiming, normalizeTiming,
|
|
68
70
|
} from "./timing.js";
|
|
69
71
|
import { createLiveTurn } from "./live_turn.js";
|
|
70
72
|
import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
|
|
71
73
|
import { installRefClicks } from "./refs.js";
|
|
72
74
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
73
|
-
import { DONE_MS, applyInitEvent, dropDone,
|
|
74
|
-
import {
|
|
75
|
-
import { applyConfirm,
|
|
75
|
+
import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
76
|
+
import { newClientId, snapshotEvents, workerGoneText } from "./turn_events.js";
|
|
77
|
+
import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
|
|
76
78
|
import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
|
|
77
79
|
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
78
80
|
import { createStreamHandlers } from "./stream_handlers.js";
|
|
@@ -81,6 +83,7 @@ import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
|
|
|
81
83
|
import { liveSlots } from "./stage_model.js";
|
|
82
84
|
import { currentGen } from "./turn_model.js";
|
|
83
85
|
import { toolName } from "./activity.js";
|
|
86
|
+
import { toastBottom } from "./toast_place.js";
|
|
84
87
|
|
|
85
88
|
const $ = (s) => document.querySelector(s);
|
|
86
89
|
const topStripEl = $("#topStrip");
|
|
@@ -88,7 +91,6 @@ const allListEl = $("#allList");
|
|
|
88
91
|
const historyEl = $("#history");
|
|
89
92
|
const emptyEl = $("#empty");
|
|
90
93
|
const composerEl = $("#composer");
|
|
91
|
-
const infoBarEl = $("#infoBar");
|
|
92
94
|
// The open session's title in the top bar, when no strip card in sight
|
|
93
95
|
// shows it; the tab's base ("Chi · <project>"), set by renderScope.
|
|
94
96
|
const sessionTitleEl = $("#sessionTitle");
|
|
@@ -117,7 +119,7 @@ const llmContextControl = createLlmContextControl({
|
|
|
117
119
|
// drops it too, a failed create keeps it).
|
|
118
120
|
const llmContextNewEl = $("#llmContextNew");
|
|
119
121
|
let newChatLlmContext = null;
|
|
120
|
-
const newChatLlmSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
|
|
122
|
+
const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
|
|
121
123
|
const llmContextNewControl = createLlmContextControl({
|
|
122
124
|
el: llmContextNewEl,
|
|
123
125
|
summary: newChatLlmSummary,
|
|
@@ -153,7 +155,7 @@ const reasoningHistoryControl = createReasoningHistoryControl({
|
|
|
153
155
|
// for the next new chat only (sent with its create, then dropped).
|
|
154
156
|
const thinkingNewEl = $("#thinkingNew");
|
|
155
157
|
let newChatThinking = null;
|
|
156
|
-
const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
158
|
+
const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
157
159
|
const thinkingNewControl = createThinkingControl({
|
|
158
160
|
el: thinkingNewEl,
|
|
159
161
|
summary: newChatThinkingSummary,
|
|
@@ -291,6 +293,38 @@ const questionCards = createQuestionCards({
|
|
|
291
293
|
removeHintIfEmpty: () => removeHintIfEmpty(),
|
|
292
294
|
askingText: () => turnDom.askingText(),
|
|
293
295
|
});
|
|
296
|
+
// The history's bubbles (bubbles.js): output, end lines, every client's
|
|
297
|
+
// prompts, the recap, warnings and notices, command and note bubbles.
|
|
298
|
+
const bubbles = createBubbles({
|
|
299
|
+
historyEl,
|
|
300
|
+
appendToHistory: (el) => appendToHistory(el),
|
|
301
|
+
isNearBottom: () => isNearBottom(),
|
|
302
|
+
turnDom,
|
|
303
|
+
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
304
|
+
shownTexts,
|
|
305
|
+
liveTurn,
|
|
306
|
+
selected: () => selected,
|
|
307
|
+
clientId,
|
|
308
|
+
});
|
|
309
|
+
const {
|
|
310
|
+
removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
|
|
311
|
+
showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
312
|
+
} = bubbles;
|
|
313
|
+
// The plugin cards and a snapshot's notices in the history (cards_place.js).
|
|
314
|
+
const cards = createCardsPlace({
|
|
315
|
+
historyEl,
|
|
316
|
+
appendToHistory: (el) => appendToHistory(el),
|
|
317
|
+
isNearBottom: () => isNearBottom(),
|
|
318
|
+
turnDom,
|
|
319
|
+
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
320
|
+
selected: () => selected,
|
|
321
|
+
clientId,
|
|
322
|
+
questionCards,
|
|
323
|
+
bubbles,
|
|
324
|
+
followWokenWorker: () => followWokenWorker(),
|
|
325
|
+
});
|
|
326
|
+
const { upsertCard, placeSnapshotCards, refreshCardBodies } = cards;
|
|
327
|
+
turnRoots().forEach((root) => root.addEventListener("click", cards.onCardAction));
|
|
294
328
|
// The enqueued_ids this page sent (from the /turn acks): only these come back
|
|
295
329
|
// into the composer when their turn fails.
|
|
296
330
|
const sentIds = new Set();
|
|
@@ -554,11 +588,13 @@ function updateComposerMode() {
|
|
|
554
588
|
// In a session the box is one line: its minimum follows the class.
|
|
555
589
|
if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
|
|
556
590
|
hadSession = !hadSession;
|
|
591
|
+
// Back on the start page: its remembered model, not the last link's.
|
|
592
|
+
if (!hadSession) restoreStartPick();
|
|
557
593
|
fitPrompt();
|
|
558
594
|
placeToast();
|
|
559
595
|
}
|
|
596
|
+
syncModelPick();
|
|
560
597
|
if (selected) {
|
|
561
|
-
modelPicker.close();
|
|
562
598
|
llmContextNewControl.close();
|
|
563
599
|
// The start page's choice was for a new chat: opening a session drops it.
|
|
564
600
|
if (newChatLlmContext) {
|
|
@@ -615,6 +651,8 @@ function syncDeliveryLine() {
|
|
|
615
651
|
|
|
616
652
|
function setTurnRunning(running) {
|
|
617
653
|
liveTurn.running = !!running;
|
|
654
|
+
// A fresh link's picker goes now, not at the timing re-read after the turn.
|
|
655
|
+
if (running && selected) current.turnStarted = true;
|
|
618
656
|
updateComposerMode();
|
|
619
657
|
updateInfoBar();
|
|
620
658
|
}
|
|
@@ -639,7 +677,7 @@ function setLiveStatus(status) {
|
|
|
639
677
|
const initRowEl = $("#initRow");
|
|
640
678
|
const initTasks = newInitTasks();
|
|
641
679
|
function renderInitRow() {
|
|
642
|
-
|
|
680
|
+
render(initRowTemplate(initTasks), initRowEl);
|
|
643
681
|
initRowEl.hidden = initTasks.size === 0;
|
|
644
682
|
}
|
|
645
683
|
function onInitEvent(type, data) {
|
|
@@ -649,7 +687,8 @@ function onInitEvent(type, data) {
|
|
|
649
687
|
const session = selected;
|
|
650
688
|
setTimeout(() => {
|
|
651
689
|
if (session !== selected) return;
|
|
652
|
-
|
|
690
|
+
fadeDone(initTasks, done);
|
|
691
|
+
renderInitRow();
|
|
653
692
|
setTimeout(() => {
|
|
654
693
|
if (session !== selected) return;
|
|
655
694
|
dropDone(initTasks, done);
|
|
@@ -847,15 +886,6 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
847
886
|
updateComposerMode();
|
|
848
887
|
}
|
|
849
888
|
|
|
850
|
-
// The history's one line while it has nothing else (loading, an error,
|
|
851
|
-
// no output yet).
|
|
852
|
-
function hintEl(text) {
|
|
853
|
-
const el = document.createElement("div");
|
|
854
|
-
el.className = "hint";
|
|
855
|
-
el.textContent = text;
|
|
856
|
-
return el;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
889
|
function renderHistory(items) {
|
|
860
890
|
if (!items.length) {
|
|
861
891
|
historyEl.replaceChildren(hintEl("No output yet. Send a message."));
|
|
@@ -865,12 +895,7 @@ function renderHistory(items) {
|
|
|
865
895
|
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
|
|
866
896
|
linkTextRefs();
|
|
867
897
|
} else {
|
|
868
|
-
historyEl.replaceChildren(...items.map((c) =>
|
|
869
|
-
const el = document.createElement("div");
|
|
870
|
-
el.className = "bubble output";
|
|
871
|
-
el.textContent = String(c);
|
|
872
|
-
return el;
|
|
873
|
-
}));
|
|
898
|
+
historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
|
|
874
899
|
}
|
|
875
900
|
historyEl.scrollTop = historyEl.scrollHeight;
|
|
876
901
|
}
|
|
@@ -978,30 +1003,6 @@ function atHistoryEnd() {
|
|
|
978
1003
|
return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
|
|
979
1004
|
}
|
|
980
1005
|
|
|
981
|
-
function appendChunk(text) {
|
|
982
|
-
if (!text) return;
|
|
983
|
-
const k = normalize(text);
|
|
984
|
-
if (!k || shownTexts.shows(k)) return;
|
|
985
|
-
const follow = isNearBottom();
|
|
986
|
-
const hint = historyEl.querySelector(".hint");
|
|
987
|
-
if (hint) hint.remove();
|
|
988
|
-
const div = document.createElement("div");
|
|
989
|
-
div.className = "bubble output";
|
|
990
|
-
div.textContent = text;
|
|
991
|
-
appendToHistory(div);
|
|
992
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
// +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
|
|
996
|
-
// the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
|
|
997
|
-
function addEndBubble(text, { stopped = false } = {}) {
|
|
998
|
-
const div = document.createElement("div");
|
|
999
|
-
div.className = stopped ? STOPPED_LINE_CLASS : "bubble cancel";
|
|
1000
|
-
div.textContent = text;
|
|
1001
|
-
appendToHistory(div);
|
|
1002
|
-
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
1006
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
1006
1007
|
// end line (+endText+, the cancel or failed line), then the step cards that
|
|
1007
1008
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
@@ -1025,349 +1026,6 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
1025
1026
|
if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
|
|
1026
1027
|
}
|
|
1027
1028
|
|
|
1028
|
-
// ── Prompt bubbles ─────────────────────────────────────────────────────────
|
|
1029
|
-
// Every client's prompts, keyed by enqueued_id: queued (badge) until their
|
|
1030
|
-
// turn starts, or steered when merged into a running turn. This tab's own
|
|
1031
|
-
// prompts are echoed at send (data-own) and tagged by their turn_enqueued.
|
|
1032
|
-
|
|
1033
|
-
// The bubble a reload draws (turn_html.js), with this page's own fields.
|
|
1034
|
-
// +delivery+: how it went into a running turn (next_step, cut, queue): its
|
|
1035
|
-
// queued badge says what it waits for.
|
|
1036
|
-
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
|
|
1037
|
-
const hint = historyEl.querySelector(".hint");
|
|
1038
|
-
if (hint) hint.remove();
|
|
1039
|
-
const div = elementOf(userBubbleTemplate({ content: prompt }, {
|
|
1040
|
-
label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1041
|
-
}));
|
|
1042
|
-
const follow = isNearBottom();
|
|
1043
|
-
appendToHistory(div);
|
|
1044
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1045
|
-
return div;
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
function setBubbleState(bubble, state) {
|
|
1049
|
-
bubble.classList.remove("queued", "steered", "failed");
|
|
1050
|
-
bubble.querySelector(".state-badge")?.remove();
|
|
1051
|
-
if (!BADGES[state]) {
|
|
1052
|
-
delete bubble.dataset.userState;
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
bubble.classList.add(state);
|
|
1056
|
-
bubble.dataset.userState = state;
|
|
1057
|
-
bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// A bubble's delivery changed (the ack's cut: off goes in at the next
|
|
1061
|
-
// step; a queue to a turn that ended meanwhile is just the next turn): its
|
|
1062
|
-
// badge follows.
|
|
1063
|
-
function setBubbleDelivery(bubble, delivery) {
|
|
1064
|
-
if ((bubble.dataset.delivery || null) === (delivery || null)) return;
|
|
1065
|
-
if (delivery) bubble.dataset.delivery = delivery;
|
|
1066
|
-
else delete bubble.dataset.delivery;
|
|
1067
|
-
if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
function bubbleById(enqueuedId) {
|
|
1071
|
-
if (!enqueuedId) return null;
|
|
1072
|
-
return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
|
|
1073
|
-
(el) => el.dataset.enqueuedId === enqueuedId,
|
|
1074
|
-
) || null;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
// This tab's oldest untagged echo, preferring one with the same text.
|
|
1078
|
-
function untaggedOwnBubble(prompt) {
|
|
1079
|
-
const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
|
|
1080
|
-
const text = normalize(prompt || "");
|
|
1081
|
-
return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
1085
|
-
// into its prompt line), else null.
|
|
1086
|
-
function applyPromptOps(event) {
|
|
1087
|
-
if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
|
|
1088
|
-
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
|
|
1089
|
-
if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
|
|
1090
|
-
if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
|
|
1091
|
-
let started = null;
|
|
1092
|
-
for (const op of ops) {
|
|
1093
|
-
if (op.op === "report") {
|
|
1094
|
-
liveTurn.unmatchedMerge = "report";
|
|
1095
|
-
} else if (op.op === "add") {
|
|
1096
|
-
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1097
|
-
if (op.state === "started") started = bubble;
|
|
1098
|
-
if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
|
|
1099
|
-
} else if (op.op === "tag") {
|
|
1100
|
-
if (bubbleById(op.enqueuedId)) continue;
|
|
1101
|
-
const echo = untaggedOwnBubble(op.prompt);
|
|
1102
|
-
if (echo) {
|
|
1103
|
-
echo.dataset.enqueuedId = op.enqueuedId;
|
|
1104
|
-
if (op.delivery) setBubbleDelivery(echo, op.delivery);
|
|
1105
|
-
}
|
|
1106
|
-
// Re-rendered from a snapshot: the echo is gone, the prompt still queued.
|
|
1107
|
-
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1108
|
-
} else if (op.op === "start") {
|
|
1109
|
-
const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
|
|
1110
|
-
// A resync (a new worker woken by this page's send) re-rendered the
|
|
1111
|
-
// history and took the echo with it: draw the prompt again.
|
|
1112
|
-
if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
1113
|
-
else {
|
|
1114
|
-
if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
|
|
1115
|
-
setBubbleState(bubble, null);
|
|
1116
|
-
started = bubble;
|
|
1117
|
-
}
|
|
1118
|
-
} else if (op.op === "steer") {
|
|
1119
|
-
const bubble = bubbleById(op.enqueuedId);
|
|
1120
|
-
if (bubble) setBubbleState(bubble, "steered");
|
|
1121
|
-
else liveTurn.unmatchedMerge = true;
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
return started;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// ── Idle recap ─────────────────────────────────────────────────────────────
|
|
1128
|
-
// A summary the worker writes after a quiet stretch or as it leaves: shown at
|
|
1129
|
-
// the end of the history. A turn starting leaves it in place, marked
|
|
1130
|
-
// earlier; only a new recap replaces it.
|
|
1131
|
-
|
|
1132
|
-
function showRecap(text, turnsSince = 0) {
|
|
1133
|
-
if (!text) return;
|
|
1134
|
-
removeRecap();
|
|
1135
|
-
const details = document.createElement("details");
|
|
1136
|
-
details.className = "bubble recap";
|
|
1137
|
-
if (turnsSince > 0) details.classList.add("stale");
|
|
1138
|
-
details.open = true;
|
|
1139
|
-
const summary = document.createElement("summary");
|
|
1140
|
-
summary.textContent = recapLabel(turnsSince);
|
|
1141
|
-
const body = document.createElement("div");
|
|
1142
|
-
body.className = "recap-body";
|
|
1143
|
-
body.textContent = text;
|
|
1144
|
-
details.appendChild(summary);
|
|
1145
|
-
details.appendChild(body);
|
|
1146
|
-
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1147
|
-
// Never in the stage: a recap is between turns.
|
|
1148
|
-
turnDom.settleFold?.();
|
|
1149
|
-
const before = recapPlace(turnPromptBubbles(), turnsSince);
|
|
1150
|
-
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
// A turn started: the recap describes the chat before it.
|
|
1154
|
-
function markRecapStale() {
|
|
1155
|
-
historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
|
|
1156
|
-
el.classList.add("stale");
|
|
1157
|
-
const summary = el.querySelector("summary");
|
|
1158
|
-
if (summary) summary.textContent = recapLabel(0, true);
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
// A guardrail hook or rule file that failed to load (once per worker), or
|
|
1163
|
-
// a bundle's plugin (+label+ "plugins").
|
|
1164
|
-
// +before+: the element it goes above (a snapshot's), else the end.
|
|
1165
|
-
function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
|
|
1166
|
-
if (!text) return;
|
|
1167
|
-
const el = document.createElement("div");
|
|
1168
|
-
el.className = "bubble guardrail-warning";
|
|
1169
|
-
el.textContent = `${label}: ${text}`;
|
|
1170
|
-
const follow = isNearBottom();
|
|
1171
|
-
if (before) historyEl.insertBefore(el, before);
|
|
1172
|
-
else appendToHistory(el);
|
|
1173
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// A hook's line to the user (event[:notify]): "<bundle>: text", in the
|
|
1177
|
-
// warning colour for level warn.
|
|
1178
|
-
// +before+: the element it goes above (a snapshot's notice), else the end.
|
|
1179
|
-
// A plugin's steers (pending_input_merged's steers): a row of the running
|
|
1180
|
-
// turn's step, else (no turn runs) a bubble.
|
|
1181
|
-
function showSteers(steers) {
|
|
1182
|
-
for (const steer of steers || []) {
|
|
1183
|
-
if (turnDom.steer(steer)) continue;
|
|
1184
|
-
removeHintIfEmpty();
|
|
1185
|
-
const row = elementOf(steerRowTemplate(steer, { bubble: true }));
|
|
1186
|
-
const follow = isNearBottom();
|
|
1187
|
-
appendToHistory(row);
|
|
1188
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
function showHookNotice(data, { before = null } = {}) {
|
|
1193
|
-
removeHintIfEmpty();
|
|
1194
|
-
const el = document.createElement("div");
|
|
1195
|
-
el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
|
|
1196
|
-
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
1197
|
-
if (data.title) el.title = data.title;
|
|
1198
|
-
const follow = isNearBottom();
|
|
1199
|
-
if (before) historyEl.insertBefore(el, before);
|
|
1200
|
-
else appendToHistory(el);
|
|
1201
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
// ── plugin cards ───────────────────────────────────────────────────────────
|
|
1205
|
-
// A plugin's card (docs/plugins.md, Cards): a glass card between turns, or
|
|
1206
|
-
// a row of the running turn's step. The same id updates the card in place.
|
|
1207
|
-
// An action button runs its command line in the session (sendCommand), as
|
|
1208
|
-
// if typed.
|
|
1209
|
-
|
|
1210
|
-
function cardElement(id) {
|
|
1211
|
-
if (!id) return null;
|
|
1212
|
-
return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// Show +card+: update the one with its id where it is, else add it (a
|
|
1216
|
-
// running turn's card as a row of its step; else before +before+ or at the
|
|
1217
|
-
// end of the history). A running turn's card with actions asks the user
|
|
1218
|
-
// something (check-in's Nudge / Keep going / Stop): it goes under the turn's
|
|
1219
|
-
// block, not into a step that collapses when the next one starts.
|
|
1220
|
-
function upsertCard(card, { before = null } = {}) {
|
|
1221
|
-
if (!card || !card.id) return;
|
|
1222
|
-
const existing = cardElement(card.id);
|
|
1223
|
-
if (existing) {
|
|
1224
|
-
const drawn = drawCard(existing, card);
|
|
1225
|
-
drawn.classList.add("card-updated");
|
|
1226
|
-
setTimeout(() => drawn.classList.remove("card-updated"), 900);
|
|
1227
|
-
return;
|
|
1228
|
-
}
|
|
1229
|
-
removeHintIfEmpty();
|
|
1230
|
-
const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
|
|
1231
|
-
const asks = (card.actions || []).some((a) => a && a.command);
|
|
1232
|
-
if (card.in_turn && !before && !asks && turnDom.card(el)) return;
|
|
1233
|
-
const follow = isNearBottom();
|
|
1234
|
-
if (before) historyEl.insertBefore(el, before);
|
|
1235
|
-
else appendToHistory(el);
|
|
1236
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
// A snapshot's cards and notices, each above the first turn after it; a
|
|
1240
|
-
// turn's own notice where it showed live (placeTurnNotice).
|
|
1241
|
-
function placeSnapshotCards(entries) {
|
|
1242
|
-
const users = turnPromptBubbles();
|
|
1243
|
-
for (const entry of entries) {
|
|
1244
|
-
const before = cardPlace(users, entry);
|
|
1245
|
-
if (isCard(entry)) upsertCard(entry, { before });
|
|
1246
|
-
else if (entry.type === "question") placeResolvedQuestion(entry, before);
|
|
1247
|
-
else if (entry.type === "guardrail_warning") showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
|
|
1248
|
-
else if (entry.in_turn) placeTurnNotice(entry, before);
|
|
1249
|
-
else if (entry.type === "hook_notice") showHookNotice(entry, { before });
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// A finished turn's row, a hook's notice or the loop's asking again
|
|
1254
|
-
// (+before+: the next turn's prompt, or null for the last turn): a row of
|
|
1255
|
-
// its step, above the row of the call it came before, as live; else (a
|
|
1256
|
-
// plain answer, a step the history doesn't have) a bubble before the
|
|
1257
|
-
// turn's answer, or before its timing line when it has none.
|
|
1258
|
-
function placeTurnNotice(entry, before) {
|
|
1259
|
-
const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
|
|
1260
|
-
const line = noticeLine(entry);
|
|
1261
|
-
const turnEls = reloadedTurnEls(before);
|
|
1262
|
-
const place = turnNoticePlace(entry);
|
|
1263
|
-
const block = turnEls.find((el) => el.matches(".turn-work"));
|
|
1264
|
-
const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
|
|
1265
|
-
if (!step) {
|
|
1266
|
-
showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
const el = document.createElement("div");
|
|
1270
|
-
el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
|
|
1271
|
-
el.textContent = line;
|
|
1272
|
-
if (extra.title) el.title = extra.title;
|
|
1273
|
-
let body = step.querySelector(":scope > .activity-body");
|
|
1274
|
-
if (!body) {
|
|
1275
|
-
body = document.createElement("div");
|
|
1276
|
-
body.className = "activity-body";
|
|
1277
|
-
step.appendChild(body);
|
|
1278
|
-
}
|
|
1279
|
-
const row = [...body.children].find((r) => r.dataset.key === place.rowKey);
|
|
1280
|
-
body.insertBefore(el, row || null);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
// A prompt that started a turn: not a line steered into a running one.
|
|
1284
|
-
const TURN_PROMPT = ".bubble.user:not(.steered)";
|
|
1285
|
-
|
|
1286
|
-
// The history's turn prompts, oldest first (what turns_since counts).
|
|
1287
|
-
function turnPromptBubbles() {
|
|
1288
|
-
return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
// The elements of the reloaded turn that ends before +before+ (the next
|
|
1292
|
-
// turn's prompt, or null for the last turn), its prompt first.
|
|
1293
|
-
function reloadedTurnEls(before) {
|
|
1294
|
-
const turnEls = [];
|
|
1295
|
-
for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
|
|
1296
|
-
turnEls.unshift(el);
|
|
1297
|
-
if (el.matches(TURN_PROMPT)) break;
|
|
1298
|
-
}
|
|
1299
|
-
return turnEls;
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
// What goes after a reloaded turn's own rows and cards: its answer, else
|
|
1303
|
-
// its timing line; null when it has neither.
|
|
1304
|
-
function turnAnswerEl(turnEls) {
|
|
1305
|
-
return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// A finished turn's question (the snapshot's cards keep it with its
|
|
1309
|
-
// answer): the card as it was left live, resolved, before the lines the
|
|
1310
|
-
// user sent into the turn after it (a steered bubble's data-step: the step
|
|
1311
|
-
// that read it) and the turn's answer. A pending one is the running turn's
|
|
1312
|
-
// (pending_question draws it).
|
|
1313
|
-
function placeResolvedQuestion(entry, before) {
|
|
1314
|
-
const pq = entry.pending_question;
|
|
1315
|
-
if (!pq?.id || !(entry.answer || entry.cancelled)) return;
|
|
1316
|
-
const turnEls = reloadedTurnEls(before);
|
|
1317
|
-
const asked = Number(entry.iteration) || 1;
|
|
1318
|
-
// The reply that asked: its step's text in the reloaded block (the card
|
|
1319
|
-
// keeps it, as live), never the running turn's.
|
|
1320
|
-
const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
|
|
1321
|
-
questionCards.renderQuestion(pq, { said });
|
|
1322
|
-
const card = questionCards.questionCard();
|
|
1323
|
-
if (!card || card.dataset.qid !== String(pq.id)) return;
|
|
1324
|
-
questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
|
|
1325
|
-
const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
|
|
1326
|
-
historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// A live card's body comes as text; the worker's snapshot (through the
|
|
1330
|
-
// server) has it rendered. One re-read for a burst of cards.
|
|
1331
|
-
let cardBodiesTimer = null;
|
|
1332
|
-
function refreshCardBodies() {
|
|
1333
|
-
clearTimeout(cardBodiesTimer);
|
|
1334
|
-
const id = selected;
|
|
1335
|
-
cardBodiesTimer = setTimeout(async () => {
|
|
1336
|
-
try {
|
|
1337
|
-
const data = await getSession(id, { cards: true });
|
|
1338
|
-
if (id !== selected) return;
|
|
1339
|
-
for (const card of data.cards || []) {
|
|
1340
|
-
const el = card.type === "card" && cardElement(card.id);
|
|
1341
|
-
if (el) refreshCardBody(el, card);
|
|
1342
|
-
}
|
|
1343
|
-
} catch (_) {}
|
|
1344
|
-
}, 150);
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
function onCardAction(e) {
|
|
1348
|
-
const button = e.target.closest(".plugin-card .card-action");
|
|
1349
|
-
if (!button || !selected) return;
|
|
1350
|
-
const card = button.closest(".plugin-card");
|
|
1351
|
-
const err = card.querySelector(".card-error");
|
|
1352
|
-
err.classList.add("hidden");
|
|
1353
|
-
button.disabled = true;
|
|
1354
|
-
sendCommand(selected, button.dataset.command, { clientId, card: true })
|
|
1355
|
-
.then(() => {
|
|
1356
|
-
// The command woke the worker: follow its stream if none is open.
|
|
1357
|
-
followWokenWorker();
|
|
1358
|
-
})
|
|
1359
|
-
.catch((error) => {
|
|
1360
|
-
err.textContent = error.message;
|
|
1361
|
-
err.classList.remove("hidden");
|
|
1362
|
-
})
|
|
1363
|
-
.finally(() => { button.disabled = false; });
|
|
1364
|
-
}
|
|
1365
|
-
turnRoots().forEach((root) => root.addEventListener("click", onCardAction));
|
|
1366
|
-
|
|
1367
|
-
function removeRecap() {
|
|
1368
|
-
historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
1029
|
// The info bar's "copy chi --attach": the full id, which the page shows
|
|
1372
1030
|
// only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
|
|
1373
1031
|
infoTextEl.addEventListener("click", (e) => {
|
|
@@ -1387,12 +1045,9 @@ infoTextEl.addEventListener("click", async (e) => {
|
|
|
1387
1045
|
setTimeout(updateInfoBar, 2000);
|
|
1388
1046
|
});
|
|
1389
1047
|
|
|
1390
|
-
// The open session's worker
|
|
1391
|
-
// a badge, with Restart when the worker can (else the stop command).
|
|
1048
|
+
// The open session's worker badge (info_bar_model.js workerBadgeFor).
|
|
1392
1049
|
function currentWorkerBadge() {
|
|
1393
|
-
|
|
1394
|
-
return workerBadge({ workerVersion: current.workerVersion, features: current.workerFeatures,
|
|
1395
|
-
installed: versions.installed, served: versions.served, sessionId: selected });
|
|
1050
|
+
return workerBadgeFor(selected, current, versions);
|
|
1396
1051
|
}
|
|
1397
1052
|
|
|
1398
1053
|
// Restart: the session moves to a new worker on the newest chi; the page
|
|
@@ -1415,76 +1070,40 @@ async function restartWorker() {
|
|
|
1415
1070
|
}
|
|
1416
1071
|
}
|
|
1417
1072
|
|
|
1073
|
+
// The session bar (info_bar_model.js sessionBar): its buttons, and the
|
|
1074
|
+
// info line rendered by info_bar.js.
|
|
1418
1075
|
function updateInfoBar() {
|
|
1076
|
+
const { buttons, bar } = sessionBar({
|
|
1077
|
+
id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
|
|
1078
|
+
continuing: continuingIds, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
|
|
1079
|
+
now: Date.now(),
|
|
1080
|
+
});
|
|
1419
1081
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1420
1082
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
1421
1083
|
const archiveBtn = document.getElementById("infoArchiveBtn");
|
|
1422
1084
|
const continueBtn = document.getElementById("infoContinueBtn");
|
|
1423
|
-
const chain = selected ? chainNeighbours(allSessions, selected, { continues: current.continues }) : { prev: null, next: null };
|
|
1424
1085
|
if (continueBtn) {
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
continueBtn.
|
|
1428
|
-
const busy = !!selected && continuingIds.has(selected);
|
|
1429
|
-
continueBtn.disabled = busy;
|
|
1430
|
-
continueBtn.textContent = busy ? "continuing…" : "continue →";
|
|
1086
|
+
continueBtn.style.display = buttons.continue.shown ? "" : "none";
|
|
1087
|
+
continueBtn.disabled = buttons.continue.disabled;
|
|
1088
|
+
continueBtn.textContent = buttons.continue.text;
|
|
1431
1089
|
}
|
|
1432
|
-
if (deleteBtn) deleteBtn.style.display =
|
|
1090
|
+
if (deleteBtn) deleteBtn.style.display = buttons.delete.shown ? "" : "none";
|
|
1433
1091
|
if (archiveBtn) {
|
|
1434
|
-
archiveBtn.style.display =
|
|
1435
|
-
|
|
1436
|
-
archiveBtn.
|
|
1437
|
-
archiveBtn.
|
|
1438
|
-
archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
|
|
1092
|
+
archiveBtn.style.display = buttons.archive.shown ? "" : "none";
|
|
1093
|
+
archiveBtn.textContent = buttons.archive.text;
|
|
1094
|
+
archiveBtn.classList.toggle("archived", buttons.archive.archived);
|
|
1095
|
+
archiveBtn.title = buttons.archive.title;
|
|
1439
1096
|
}
|
|
1440
|
-
if (
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
}
|
|
1447
|
-
if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
|
|
1448
|
-
const tokens = current.timing.tokens;
|
|
1449
|
-
const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
|
|
1450
|
-
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
|
|
1451
|
-
budget: current.llmContext?.budget_tokens });
|
|
1452
|
-
const speedTextNow = lastSpeedText(tokens);
|
|
1453
|
-
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
1454
|
-
const sessionDuration = currentSessionDuration();
|
|
1455
|
-
// A delegated session names its parent, as a link to it.
|
|
1456
|
-
const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
|
|
1457
|
-
const badge = currentWorkerBadge();
|
|
1458
|
-
// The session clock ticks every second while a turn runs: lit writes its
|
|
1459
|
-
// text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
1460
|
-
renderInfoBar(infoTextEl, {
|
|
1461
|
-
id: selected,
|
|
1462
|
-
copied: Date.now() - current.attachCopiedAt < 2000,
|
|
1463
|
-
status: current.status,
|
|
1464
|
-
model: modelLabel(current.model, ...(current.served || [])),
|
|
1465
|
-
// A link of a session chain: ← the previous link, → the next one (each
|
|
1466
|
-
// only while the list holds it).
|
|
1467
|
-
chain: { prev: chainLink(chain.prev), next: chainLink(chain.next) },
|
|
1468
|
-
parent: parentChip ? { id: current.parentId, title: parentChip.title } : null,
|
|
1469
|
-
// A parent names its delegates; the link opens all sessions.
|
|
1470
|
-
kids: childrenSummary({ id: selected }, allSessions),
|
|
1471
|
-
llmContext: current.llmContext,
|
|
1472
|
-
thinking: current.thinking,
|
|
1473
|
-
reasoningHistory: current.reasoningHistory,
|
|
1474
|
-
// The tools chip (toolkit_chip.js): null on full.
|
|
1475
|
-
toolkit: current.toolkit,
|
|
1476
|
-
// The model notes the session's prompt carried.
|
|
1477
|
-
notes: promptNotesChip(current.promptNotes),
|
|
1478
|
-
memories: current.usedMemories,
|
|
1479
|
-
preloaded: current.preloadedMemories,
|
|
1480
|
-
muted: current.mutedMemories,
|
|
1481
|
-
dir: current.dir,
|
|
1482
|
-
ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
|
|
1483
|
-
speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
|
|
1484
|
-
clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
|
|
1485
|
-
worker: badge ? { badge, busy: restartingIds.has(selected) } : null,
|
|
1486
|
-
});
|
|
1097
|
+
if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
|
|
1098
|
+
// No session: the start page hides the info bar (the storage path is on
|
|
1099
|
+
// #/sessions). The session clock ticks every second while a turn runs:
|
|
1100
|
+
// lit writes its text alone, the bar's other nodes (a tooltip, a button
|
|
1101
|
+
// mid-press) stay.
|
|
1102
|
+
renderInfoBar(infoTextEl, bar);
|
|
1487
1103
|
updateSessionTitle();
|
|
1104
|
+
// A fresh link's picker follows what the bar follows: its model, its
|
|
1105
|
+
// owner, a next link continued from elsewhere.
|
|
1106
|
+
syncModelPick();
|
|
1488
1107
|
}
|
|
1489
1108
|
|
|
1490
1109
|
// The open session's title, once on screen (session_title.js): its strip
|
|
@@ -1513,18 +1132,6 @@ function updateSessionTitle() {
|
|
|
1513
1132
|
}
|
|
1514
1133
|
}
|
|
1515
1134
|
|
|
1516
|
-
// A chain's link (chains.js chainNeighbours) as the info bar's ← / →
|
|
1517
|
-
// shows it (info_bar.js chainLinkTemplate), or null.
|
|
1518
|
-
function chainLink(link) {
|
|
1519
|
-
if (!link) return null;
|
|
1520
|
-
return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? lists.cardTitle(link.session) : "",
|
|
1521
|
-
archived: !!link.session?.archived };
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
function currentSessionDuration() {
|
|
1525
|
-
return sessionDurationNow(current.timing, liveTurn.running);
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
1135
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
1529
1136
|
function appendToHistory(el) {
|
|
1530
1137
|
// (The stage's place first moves a turn still sliding away into the history.)
|
|
@@ -1690,6 +1297,10 @@ const streamHandlers = createStreamHandlers({
|
|
|
1690
1297
|
onInitEvent: (type, data) => onInitEvent(type, data),
|
|
1691
1298
|
upsertCard: (card) => upsertCard(card),
|
|
1692
1299
|
refreshCardBodies: () => refreshCardBodies(),
|
|
1300
|
+
syncModelPick: () => {
|
|
1301
|
+
if (pendingPick) pickRanEarly = true;
|
|
1302
|
+
syncModelPick();
|
|
1303
|
+
},
|
|
1693
1304
|
});
|
|
1694
1305
|
|
|
1695
1306
|
async function refreshTimingFromSession() {
|
|
@@ -1705,12 +1316,6 @@ async function refreshTimingFromSession() {
|
|
|
1705
1316
|
}
|
|
1706
1317
|
|
|
1707
1318
|
|
|
1708
|
-
function removeHintIfEmpty() {
|
|
1709
|
-
const hint = historyEl.querySelector(".hint");
|
|
1710
|
-
if (!hint) return;
|
|
1711
|
-
// Remove the placeholder when the card will be the first content in history.
|
|
1712
|
-
if (!historyEl.querySelector(".bubble")) hint.remove();
|
|
1713
|
-
}
|
|
1714
1319
|
function closeStream() {
|
|
1715
1320
|
turnDom.reset();
|
|
1716
1321
|
if (streamControl) {
|
|
@@ -1739,6 +1344,9 @@ function handleCreate() {
|
|
|
1739
1344
|
// /queue wins over both.
|
|
1740
1345
|
async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
1741
1346
|
if (current.owner === "tui") return;
|
|
1347
|
+
// A fresh link's pick first: its /model is queued in the worker once
|
|
1348
|
+
// acked, and the worker runs queued commands before a turn's input.
|
|
1349
|
+
if (pendingPick) await pendingPick;
|
|
1742
1350
|
let prompt = turnText(promptEl.value, chips);
|
|
1743
1351
|
if (!prompt) return;
|
|
1744
1352
|
const armed = deliveryControl.take();
|
|
@@ -1897,30 +1505,6 @@ function clearComposer() {
|
|
|
1897
1505
|
fitPrompt();
|
|
1898
1506
|
}
|
|
1899
1507
|
|
|
1900
|
-
function addCommandBubble(view) {
|
|
1901
|
-
removeHintIfEmpty();
|
|
1902
|
-
const div = document.createElement("div");
|
|
1903
|
-
// Page-only: kept across a resync of this session (keptCommandBubbles).
|
|
1904
|
-
if (selected) div.dataset.keptFor = selected;
|
|
1905
|
-
drawCommandBubble(div, view);
|
|
1906
|
-
const follow = isNearBottom();
|
|
1907
|
-
appendToHistory(div);
|
|
1908
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
function commandBubble(commandId) {
|
|
1912
|
-
if (!commandId) return null;
|
|
1913
|
-
return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
|
|
1914
|
-
}
|
|
1915
|
-
|
|
1916
|
-
function addNoteBubble(note) {
|
|
1917
|
-
removeHintIfEmpty();
|
|
1918
|
-
const div = elementOf(noteBubbleTemplate(note));
|
|
1919
|
-
const follow = isNearBottom();
|
|
1920
|
-
appendToHistory(div);
|
|
1921
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
1508
|
async function handleCancel() {
|
|
1925
1509
|
if (!selected) return;
|
|
1926
1510
|
cancelBtn.disabled = true;
|
|
@@ -1947,10 +1531,24 @@ $("#newBtn").addEventListener("click", () => {
|
|
|
1947
1531
|
// ── The start page's model picker ──────────────────────────────────────────
|
|
1948
1532
|
// The hosts' models from GET /api/models, the choice this browser made last
|
|
1949
1533
|
// preselected while it is still offered (else the server's default). Hidden
|
|
1950
|
-
// until the list is in, and in a session (its model is in the info bar)
|
|
1951
|
-
//
|
|
1952
|
-
//
|
|
1953
|
-
//
|
|
1534
|
+
// until the list is in, and in a session (its model is in the info bar),
|
|
1535
|
+
// but a fresh chain link's (chains.js freshLink): there it shows the link's
|
|
1536
|
+
// model and a pick runs /model in the link, remembered neither as this
|
|
1537
|
+
// browser's choice nor in Recent. A host that failed is the picker's
|
|
1538
|
+
// tooltip; a route that fails leaves the picker hidden and the server's
|
|
1539
|
+
// default in charge. The search list itself is model_picker.js'; a pick
|
|
1540
|
+
// sends the focus to the message box.
|
|
1541
|
+
const START_PICK_TITLE = "The model the new chat starts on";
|
|
1542
|
+
let startPickTitle = START_PICK_TITLE;
|
|
1543
|
+
// The fresh link the picker shows (null: the start page, or a session it
|
|
1544
|
+
// is hidden in), and a link pick's /model still in flight (a send waits).
|
|
1545
|
+
let pickLinkId = null;
|
|
1546
|
+
let pickSelected = null;
|
|
1547
|
+
let pendingPick = null;
|
|
1548
|
+
let pendingPickId = null;
|
|
1549
|
+
// A command_ran came while the pick was in flight (its sync was skipped):
|
|
1550
|
+
// the ack syncs then.
|
|
1551
|
+
let pickRanEarly = false;
|
|
1954
1552
|
const modelPicker = createModelPicker({
|
|
1955
1553
|
button: modelPickEl,
|
|
1956
1554
|
panel: $("#modelPanel"),
|
|
@@ -1958,12 +1556,70 @@ const modelPicker = createModelPicker({
|
|
|
1958
1556
|
list: $("#modelList"),
|
|
1959
1557
|
composer: $("#composer"),
|
|
1960
1558
|
center: $("#center"),
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1559
|
+
remember: () => !selected,
|
|
1560
|
+
onPick: (name) => {
|
|
1561
|
+
if (selected) pickForLink(name);
|
|
1562
|
+
else {
|
|
1563
|
+
renderNewChatLlmContext();
|
|
1564
|
+
renderNewChatThinking();
|
|
1565
|
+
}
|
|
1964
1566
|
promptEl.focus();
|
|
1965
1567
|
},
|
|
1966
1568
|
});
|
|
1569
|
+
// A pick on a fresh link: /model in its worker (its command bubble and the
|
|
1570
|
+
// info bar follow from command_ran); refused, the button goes back to the
|
|
1571
|
+
// link's model, if the page is still on that link.
|
|
1572
|
+
function pickForLink(name) {
|
|
1573
|
+
const id = selected;
|
|
1574
|
+
const run = runSessionCommand(`/model ${name}`).then((ok) => {
|
|
1575
|
+
const ranEarly = pendingPick === run && pickRanEarly;
|
|
1576
|
+
if (pendingPick === run) {
|
|
1577
|
+
pendingPick = null;
|
|
1578
|
+
pendingPickId = null;
|
|
1579
|
+
pickRanEarly = false;
|
|
1580
|
+
}
|
|
1581
|
+
if ((!ok || ranEarly) && selected === id) syncModelPick();
|
|
1582
|
+
return ok;
|
|
1583
|
+
});
|
|
1584
|
+
pendingPick = run;
|
|
1585
|
+
pendingPickId = id;
|
|
1586
|
+
pickRanEarly = false;
|
|
1587
|
+
}
|
|
1588
|
+
// The button's name, tooltip and label for what the page shows: a fresh
|
|
1589
|
+
// link's model, else the start page's (whose choice stays as it is).
|
|
1590
|
+
// Toggles the fresh-link class (the CSS shows the button in a session
|
|
1591
|
+
// only with it); the panel closes when the link changes or stops being fresh.
|
|
1592
|
+
function syncModelPick() {
|
|
1593
|
+
// The list walk only for a link that is fresh otherwise (the info bar
|
|
1594
|
+
// calls this every second while a turn runs).
|
|
1595
|
+
const fresh = !!selected && freshLink(current, { running: liveTurn.running })
|
|
1596
|
+
&& !chainNeighbours(allSessions, selected, { continues: current.continues }).next;
|
|
1597
|
+
document.body.classList.toggle("fresh-link", fresh);
|
|
1598
|
+
const linkId = fresh ? selected : null;
|
|
1599
|
+
// Another session (or the start page), or the link stopped being fresh:
|
|
1600
|
+
// a panel left open would come back open where it no longer belongs.
|
|
1601
|
+
if (selected !== pickSelected || linkId !== pickLinkId) modelPicker.close();
|
|
1602
|
+
pickSelected = selected;
|
|
1603
|
+
pickLinkId = linkId;
|
|
1604
|
+
if (fresh) {
|
|
1605
|
+
// A pick in flight keeps its name until its /model is taken (its
|
|
1606
|
+
// command_ran syncs again then).
|
|
1607
|
+
if (!(pendingPick && pendingPickId === selected)) modelPicker.show(current.model);
|
|
1608
|
+
modelPickEl.title = "The model this link starts on";
|
|
1609
|
+
modelPickEl.setAttribute("aria-label", "Model this link starts on");
|
|
1610
|
+
} else {
|
|
1611
|
+
modelPickEl.title = startPickTitle;
|
|
1612
|
+
modelPickEl.setAttribute("aria-label", "Model for the new chat");
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// Leaving a session: the start page's remembered model again (no fetch).
|
|
1616
|
+
function restoreStartPick() {
|
|
1617
|
+
let stored = null;
|
|
1618
|
+
try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
|
|
1619
|
+
modelPicker.restore(stored);
|
|
1620
|
+
renderNewChatLlmContext();
|
|
1621
|
+
renderNewChatThinking();
|
|
1622
|
+
}
|
|
1967
1623
|
async function loadModels() {
|
|
1968
1624
|
let payload;
|
|
1969
1625
|
try {
|
|
@@ -1974,8 +1630,10 @@ async function loadModels() {
|
|
|
1974
1630
|
let stored = null;
|
|
1975
1631
|
try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
|
|
1976
1632
|
if (!modelPicker.setModels(payload, stored)) return;
|
|
1977
|
-
|
|
1633
|
+
startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
|
|
1978
1634
|
modelPickEl.hidden = false;
|
|
1635
|
+
// A fresh link open meanwhile shows its own model again, not the stored one.
|
|
1636
|
+
syncModelPick();
|
|
1979
1637
|
renderNewChatLlmContext();
|
|
1980
1638
|
renderNewChatThinking();
|
|
1981
1639
|
}
|
|
@@ -2250,13 +1908,15 @@ async function continueSelected() {
|
|
|
2250
1908
|
markArchived([id], true);
|
|
2251
1909
|
addCreatedSession(created);
|
|
2252
1910
|
const next = created.id;
|
|
1911
|
+
// A model fallback's warning stays until closed.
|
|
1912
|
+
const sticky = !!created.model_warning;
|
|
2253
1913
|
if (selected !== id) {
|
|
2254
|
-
showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) });
|
|
1914
|
+
showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
|
|
2255
1915
|
return;
|
|
2256
1916
|
}
|
|
2257
1917
|
await select(next);
|
|
2258
1918
|
if (selected === next) fillOpener();
|
|
2259
|
-
showToast(continuedText(id, created));
|
|
1919
|
+
showToast(continuedText(id, created), null, { sticky });
|
|
2260
1920
|
} catch (e) {
|
|
2261
1921
|
const refusal = continueRefusal(e, allSessions);
|
|
2262
1922
|
if (refusal.kind === "open") {
|
|
@@ -2343,22 +2003,32 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
2343
2003
|
|
|
2344
2004
|
// The toast sits above the session's composer card (measured: it grows with
|
|
2345
2005
|
// a wrapping footer or a taller box), so it never covers the card's
|
|
2346
|
-
// controls, and above the stage while it shows
|
|
2347
|
-
//
|
|
2006
|
+
// controls, and above the stage while it shows and a fresh link's open
|
|
2007
|
+
// model panel (toast_place.js); with no card in sight (the start page, all
|
|
2008
|
+
// sessions) the CSS's 24 px from the bottom.
|
|
2348
2009
|
function placeToast() {
|
|
2349
|
-
const gap = 12;
|
|
2350
2010
|
const dock = document.getElementById("dock");
|
|
2351
2011
|
const r = dock.getBoundingClientRect();
|
|
2352
|
-
const
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2012
|
+
const shownTop = (el) => {
|
|
2013
|
+
const box = el && !el.hidden ? el.getBoundingClientRect() : null;
|
|
2014
|
+
return box && box.height > 0 ? box.top : null;
|
|
2015
|
+
};
|
|
2016
|
+
const px = toastBottom({
|
|
2017
|
+
inSession: document.body.classList.contains("has-session") && r.height > 0,
|
|
2018
|
+
viewportHeight: window.innerHeight,
|
|
2019
|
+
dockTop: r.top,
|
|
2020
|
+
tops: [shownTop(document.getElementById("turnStage")), shownTop(document.getElementById("modelPanel"))],
|
|
2021
|
+
toastHeight: document.getElementById("toast").offsetHeight,
|
|
2022
|
+
});
|
|
2023
|
+
const bottom = px === null ? "" : `${px}px`;
|
|
2356
2024
|
const root = document.documentElement.style;
|
|
2357
2025
|
if (root.getPropertyValue("--toast-bottom") !== bottom) {
|
|
2358
2026
|
if (bottom) root.setProperty("--toast-bottom", bottom);
|
|
2359
2027
|
else root.removeProperty("--toast-bottom");
|
|
2360
2028
|
}
|
|
2361
2029
|
}
|
|
2030
|
+
// The model panel opening, closing or changing height (its list filters).
|
|
2031
|
+
new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
|
|
2362
2032
|
|
|
2363
2033
|
// sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
|
|
2364
2034
|
// words, or a template (chains.js openChildrenTemplate: a link in it, a
|
|
@@ -2370,8 +2040,8 @@ function showToast(text, action = null, { sticky = false } = {}) {
|
|
|
2370
2040
|
render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
|
|
2371
2041
|
? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
|
|
2372
2042
|
: nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
|
|
2373
|
-
placeToast();
|
|
2374
2043
|
el.hidden = false;
|
|
2044
|
+
placeToast();
|
|
2375
2045
|
clearTimeout(toastTimer);
|
|
2376
2046
|
if (!sticky) toastTimer = setTimeout(hide, 6000);
|
|
2377
2047
|
}
|