samagotchi 0.4.0 → 0.5.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 +80 -1
- data/README.md +13 -2
- data/bin/chi +29 -39
- data/docs/cli.md +135 -73
- data/docs/configuration.md +15 -20
- data/docs/hooks.md +1 -1
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +9 -6
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +16 -11
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +16 -20
- data/lib/samagotchi/config.rb +40 -100
- data/lib/samagotchi/engine.rb +50 -354
- data/lib/samagotchi/kernel_loop.rb +33 -44
- data/lib/samagotchi/live_versions.rb +7 -1
- data/lib/samagotchi/llm/errors.rb +17 -0
- data/lib/samagotchi/llm/http.rb +4 -18
- data/lib/samagotchi/llm/openai_chat.rb +17 -0
- data/lib/samagotchi/model_profile.rb +4 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +20 -2
- data/lib/samagotchi/send_command.rb +84 -6
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_manager.rb +18 -37
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +95 -690
- data/lib/samagotchi/thinking.rb +11 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +3 -0
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/update_command.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +170 -35
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +15 -11
- data/lib/samagotchi/web/public/activity.js +7 -0
- data/lib/samagotchi/web/public/app.js +99 -54
- data/lib/samagotchi/web/public/chat_view.js +5 -1
- data/lib/samagotchi/web/public/index.html +163 -17
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +9 -5
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +74 -19
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- metadata +27 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -55,15 +55,17 @@ module Samagotchi
|
|
|
55
55
|
# @param message [Hash] a saved model message (symbol or string keys)
|
|
56
56
|
# @param responses [Array<Hash>] the tool_response messages after it
|
|
57
57
|
# @param registry [Tools::Registry] the session's tools
|
|
58
|
-
# @
|
|
59
|
-
#
|
|
60
|
-
|
|
58
|
+
# @param cwd [String, nil] the session's working directory (a file
|
|
59
|
+
# tool's title is relative to it)
|
|
60
|
+
# @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
|
|
61
|
+
# output:, output_truncated:, images: }] } with only what it found; nil for nothing
|
|
62
|
+
def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil)
|
|
61
63
|
content = field(message, :content).to_s
|
|
62
64
|
calls = field(message, :tool_calls)
|
|
63
65
|
tools = if calls.is_a?(Array) && !calls.empty?
|
|
64
|
-
native_tools(calls, responses, registry)
|
|
66
|
+
native_tools(calls, responses, registry, cwd)
|
|
65
67
|
else
|
|
66
|
-
markup_tools(content, responses, registry)
|
|
68
|
+
markup_tools(content, responses, registry, cwd)
|
|
67
69
|
end
|
|
68
70
|
parts = {}
|
|
69
71
|
thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
|
|
@@ -83,7 +85,7 @@ module Samagotchi
|
|
|
83
85
|
blocks.map(&:strip).reject(&:empty?).join("\n\n")
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
def markup_tools(content, responses, registry)
|
|
88
|
+
def markup_tools(content, responses, registry, cwd = nil)
|
|
87
89
|
calls = []
|
|
88
90
|
calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
|
|
89
91
|
calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
|
|
@@ -96,7 +98,7 @@ module Samagotchi
|
|
|
96
98
|
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
97
99
|
images = split_images(responses, calls.length)
|
|
98
100
|
calls.each_with_index.map do |call, i|
|
|
99
|
-
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i])
|
|
101
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd)
|
|
100
102
|
end
|
|
101
103
|
end
|
|
102
104
|
|
|
@@ -118,7 +120,7 @@ module Samagotchi
|
|
|
118
120
|
counts.map { |n| images.shift(n) }
|
|
119
121
|
end
|
|
120
122
|
|
|
121
|
-
def native_tools(calls, responses, registry)
|
|
123
|
+
def native_tools(calls, responses, registry, cwd = nil)
|
|
122
124
|
by_id = responses.to_h { |r| [field(r, :tool_call_id), r] }
|
|
123
125
|
calls.each_with_index.map do |raw, i|
|
|
124
126
|
ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
|
|
@@ -126,7 +128,7 @@ module Samagotchi
|
|
|
126
128
|
response = by_id[field(raw, :id)] || (field(responses[i], :tool_call_id).nil? ? responses[i] : nil)
|
|
127
129
|
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
128
130
|
response && field(response, :images), label: field(response, :tool_labels),
|
|
129
|
-
diff: field(response, :tool_diffs))
|
|
131
|
+
diff: field(response, :tool_diffs), cwd: cwd)
|
|
130
132
|
end
|
|
131
133
|
end
|
|
132
134
|
|
|
@@ -148,11 +150,13 @@ module Samagotchi
|
|
|
148
150
|
|
|
149
151
|
# +shown+ is the saved params line, when it is a String.
|
|
150
152
|
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
151
|
-
# tool_diffs.
|
|
152
|
-
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil)
|
|
153
|
+
# tool_diffs. +cwd+: the session's working directory, for the title.
|
|
154
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil)
|
|
153
155
|
name = call[:name].to_s
|
|
154
156
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
155
157
|
part = { tool: name, params: params.to_s }
|
|
158
|
+
title = ToolActivity.tool_title(name, call, cwd: cwd)
|
|
159
|
+
part[:title] = title if title
|
|
156
160
|
part[:label] = label if label.is_a?(String) && !label.empty?
|
|
157
161
|
unless output.nil?
|
|
158
162
|
part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
|
|
@@ -46,6 +46,7 @@ export function addStarted(model, event = {}) {
|
|
|
46
46
|
row.tool = event.tool || row.tool;
|
|
47
47
|
row.label = event.label || row.label;
|
|
48
48
|
row.params = event.params || row.params;
|
|
49
|
+
row.title = event.title || row.title;
|
|
49
50
|
return { row, created: false };
|
|
50
51
|
}
|
|
51
52
|
row = {
|
|
@@ -55,6 +56,10 @@ export function addStarted(model, event = {}) {
|
|
|
55
56
|
tool: event.tool || "tool",
|
|
56
57
|
label: event.label || null,
|
|
57
58
|
params: event.params || "",
|
|
59
|
+
// What the call did in a few words (the server's tool title: a
|
|
60
|
+
// project-relative path, a command without its "cd … &&"); rows show it
|
|
61
|
+
// in place of the params when there is one.
|
|
62
|
+
title: event.title || null,
|
|
58
63
|
status: ROW_STATUS.RUNNING,
|
|
59
64
|
output: "",
|
|
60
65
|
output_truncated: false,
|
|
@@ -78,6 +83,7 @@ export function addCompleted(model, event = {}) {
|
|
|
78
83
|
tool: event.tool,
|
|
79
84
|
label: event.label,
|
|
80
85
|
params: event.activity?.params || "",
|
|
86
|
+
title: event.activity?.title,
|
|
81
87
|
});
|
|
82
88
|
row = synthesized.row;
|
|
83
89
|
}
|
|
@@ -85,6 +91,7 @@ export function addCompleted(model, event = {}) {
|
|
|
85
91
|
row.output = event.output || "";
|
|
86
92
|
row.output_truncated = !!event.output_truncated;
|
|
87
93
|
if (event.activity?.params) row.params = event.activity.params;
|
|
94
|
+
if (event.activity?.title) row.title = event.activity.title;
|
|
88
95
|
// The images the tool returned (a reload in the turn view draws them from
|
|
89
96
|
// the saved parts, turn_view.js reloadRowHtml).
|
|
90
97
|
if (event.images?.length) row.images = event.images;
|
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
uploadImage,
|
|
18
18
|
listModels,
|
|
19
19
|
} from "./data.js";
|
|
20
|
-
import { MODEL_KEY
|
|
20
|
+
import { MODEL_KEY } from "./model_pick.js";
|
|
21
|
+
import { createModelPicker } from "./model_picker.js";
|
|
21
22
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
22
23
|
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
23
24
|
import { chatHistoryHtml, createChatView } from "./chat_view.js";
|
|
24
25
|
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
26
|
+
import { createStageView } from "./stage_view.js";
|
|
25
27
|
import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
|
|
26
28
|
import { parsePresets, presetNote } from "./annotate_presets.js";
|
|
27
29
|
import { COPY_SOURCE_ATTR, copySource, copySourceAttr, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
@@ -37,7 +39,7 @@ import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js
|
|
|
37
39
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
38
40
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
39
41
|
import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
|
|
40
|
-
import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, trackAttention } from "./notify.js";
|
|
42
|
+
import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention } from "./notify.js";
|
|
41
43
|
import {
|
|
42
44
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
43
45
|
} from "./timing.js";
|
|
@@ -58,7 +60,6 @@ const promptEl = $("#prompt");
|
|
|
58
60
|
const actionBtn = $("#actionBtn");
|
|
59
61
|
const cancelBtn = $("#cancelBtn");
|
|
60
62
|
const modelPickEl = $("#modelPick");
|
|
61
|
-
const modelSelectEl = $("#modelSelect");
|
|
62
63
|
const composeHintEl = $("#composeHint");
|
|
63
64
|
|
|
64
65
|
let selected = null;
|
|
@@ -72,18 +73,32 @@ let resyncsInFlight = 0;
|
|
|
72
73
|
let resyncAgain = false;
|
|
73
74
|
let allSessions = [];
|
|
74
75
|
let seenContent = new Set();
|
|
75
|
-
// The
|
|
76
|
-
// with its steps (turn_view.js),
|
|
77
|
-
|
|
76
|
+
// The page's view of a turn (web.view, or ?view=turn|stage|chat): one block
|
|
77
|
+
// per turn with its steps (turn_view.js), the running turn pinned above the
|
|
78
|
+
// composer (the stage), or a row of bubbles (chat_view.js).
|
|
79
|
+
const view = ["turn", "stage", "chat"].includes(document.body.dataset.view) ? document.body.dataset.view : "turn";
|
|
80
|
+
// The turn and stage views draw a turn as steps (a reload asks for parts).
|
|
81
|
+
const turnView = view !== "chat";
|
|
78
82
|
// The running turn's DOM: the streaming bubbles, the thinking block and the
|
|
79
|
-
// activity panel, or the turn block.
|
|
80
|
-
const
|
|
83
|
+
// activity panel, the turn block, or the stage (the turn block in its cloud).
|
|
84
|
+
const viewArgs = {
|
|
81
85
|
historyEl,
|
|
82
86
|
appendToHistory: (el) => appendToHistory(el),
|
|
83
87
|
isNearBottom: () => isNearBottom(),
|
|
84
88
|
sawText: (k) => seenContent.add(k),
|
|
85
89
|
sessionId: () => selected,
|
|
86
|
-
}
|
|
90
|
+
};
|
|
91
|
+
const turnDom = view === "stage"
|
|
92
|
+
? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
|
|
93
|
+
: (turnView ? createTurnView : createChatView)(viewArgs);
|
|
94
|
+
// Where a running turn's rows can be: the history, and the stage while it
|
|
95
|
+
// holds a turn (lookups by id, copy, annotate, card actions).
|
|
96
|
+
function turnRoots() {
|
|
97
|
+
return turnDom.root ? [historyEl, turnDom.root] : [historyEl];
|
|
98
|
+
}
|
|
99
|
+
function findInTurnRoots(selector) {
|
|
100
|
+
return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
|
|
101
|
+
}
|
|
87
102
|
let currentFirstPreview = "";
|
|
88
103
|
let currentUsedMemories = [];
|
|
89
104
|
// The session's --memory and --mute lists (from the session card; shown in
|
|
@@ -482,6 +497,7 @@ const SEND_HINT = "Enter to send · Shift+Enter for a newline";
|
|
|
482
497
|
// its next step (steering). Cancel is its own button then.
|
|
483
498
|
function updateComposerMode() {
|
|
484
499
|
document.body.classList.toggle("has-session", !!selected);
|
|
500
|
+
if (selected) modelPicker.close();
|
|
485
501
|
const running = turnRunning && !!selected;
|
|
486
502
|
cancelBtn.style.display = running ? "" : "none";
|
|
487
503
|
if (running) {
|
|
@@ -829,7 +845,7 @@ function applyFinalMarkdown(messages) {
|
|
|
829
845
|
);
|
|
830
846
|
if (!message) return;
|
|
831
847
|
|
|
832
|
-
const bubble =
|
|
848
|
+
const bubble = findInTurnRoots(".bubble.output")
|
|
833
849
|
.reverse()
|
|
834
850
|
.find((item) => showsAnswer(item, message, normalize));
|
|
835
851
|
if (!bubble) return;
|
|
@@ -841,7 +857,7 @@ function applyFinalMarkdown(messages) {
|
|
|
841
857
|
|
|
842
858
|
function seedSeenFromDom() {
|
|
843
859
|
seenContent = new Set();
|
|
844
|
-
|
|
860
|
+
findInTurnRoots(".bubble").forEach((b) => {
|
|
845
861
|
const k = normalize(b.textContent);
|
|
846
862
|
if (k) seenContent.add(k);
|
|
847
863
|
});
|
|
@@ -850,8 +866,13 @@ function seedSeenFromDom() {
|
|
|
850
866
|
// Sticky-follow scroll: only auto-scroll to the bottom while the user is
|
|
851
867
|
// already near it; never yank them back down mid-read. Capture the decision
|
|
852
868
|
// BEFORE any DOM mutation that grows the content (new chunk / row / thinking
|
|
853
|
-
// delta), so the gap measured is the one the user was sitting at.
|
|
869
|
+
// delta), so the gap measured is the one the user was sitting at. While the
|
|
870
|
+
// stage holds a turn its rows go there: nothing it does moves the history.
|
|
854
871
|
function isNearBottom() {
|
|
872
|
+
return !turnDom.holds?.() && atHistoryEnd();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function atHistoryEnd() {
|
|
855
876
|
const { scrollHeight, scrollTop, clientHeight } = historyEl;
|
|
856
877
|
return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
|
|
857
878
|
}
|
|
@@ -875,7 +896,7 @@ function addEndBubble(text) {
|
|
|
875
896
|
div.className = "bubble cancel";
|
|
876
897
|
div.textContent = text;
|
|
877
898
|
appendToHistory(div);
|
|
878
|
-
historyEl.scrollTop = historyEl.scrollHeight;
|
|
899
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
879
900
|
}
|
|
880
901
|
|
|
881
902
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
@@ -888,7 +909,7 @@ function endTurnView(kind, endText = null) {
|
|
|
888
909
|
if (endText) addEndBubble(endText);
|
|
889
910
|
if (kept.length) {
|
|
890
911
|
kept.forEach((el) => appendToHistory(el));
|
|
891
|
-
historyEl.scrollTop = historyEl.scrollHeight;
|
|
912
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
892
913
|
}
|
|
893
914
|
// The question dies with its turn (the worker stopped waiting).
|
|
894
915
|
if (pendingQuestion) resolveQuestionCard(pendingQuestion.id, { cancelled: true, reason: "turn ended" });
|
|
@@ -944,25 +965,29 @@ function setBubbleState(bubble, state) {
|
|
|
944
965
|
|
|
945
966
|
function bubbleById(enqueuedId) {
|
|
946
967
|
if (!enqueuedId) return null;
|
|
947
|
-
return
|
|
968
|
+
return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
|
|
948
969
|
(el) => el.dataset.enqueuedId === enqueuedId,
|
|
949
970
|
) || null;
|
|
950
971
|
}
|
|
951
972
|
|
|
952
973
|
// This tab's oldest untagged echo, preferring one with the same text.
|
|
953
974
|
function untaggedOwnBubble(prompt) {
|
|
954
|
-
const echoes =
|
|
975
|
+
const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
|
|
955
976
|
const text = normalize(prompt || "");
|
|
956
977
|
return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
|
|
957
978
|
}
|
|
958
979
|
|
|
980
|
+
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
981
|
+
// into its prompt line), else null.
|
|
959
982
|
function applyPromptOps(event) {
|
|
960
983
|
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
|
|
961
984
|
if (event.type === "input_merged") unmatchedMerge = false;
|
|
962
985
|
if (event.type === "pending_input_merged") unmatchedMerge = false;
|
|
986
|
+
let started = null;
|
|
963
987
|
for (const op of ops) {
|
|
964
988
|
if (op.op === "add") {
|
|
965
|
-
makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
989
|
+
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
990
|
+
if (op.state === "started") started = bubble;
|
|
966
991
|
} else if (op.op === "tag") {
|
|
967
992
|
if (bubbleById(op.enqueuedId)) continue;
|
|
968
993
|
const echo = untaggedOwnBubble(op.prompt);
|
|
@@ -973,10 +998,11 @@ function applyPromptOps(event) {
|
|
|
973
998
|
const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
|
|
974
999
|
// A resync (a new worker woken by this page's send) re-rendered the
|
|
975
1000
|
// history and took the echo with it: draw the prompt again.
|
|
976
|
-
if (!bubble) makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
1001
|
+
if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
977
1002
|
else {
|
|
978
1003
|
if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
|
|
979
1004
|
setBubbleState(bubble, null);
|
|
1005
|
+
started = bubble;
|
|
980
1006
|
}
|
|
981
1007
|
} else if (op.op === "steer") {
|
|
982
1008
|
const bubble = bubbleById(op.enqueuedId);
|
|
@@ -984,6 +1010,7 @@ function applyPromptOps(event) {
|
|
|
984
1010
|
else unmatchedMerge = true;
|
|
985
1011
|
}
|
|
986
1012
|
}
|
|
1013
|
+
return started;
|
|
987
1014
|
}
|
|
988
1015
|
|
|
989
1016
|
// ── Idle recap ─────────────────────────────────────────────────────────────
|
|
@@ -1006,8 +1033,9 @@ function showRecap(text, turnsSince = 0) {
|
|
|
1006
1033
|
details.appendChild(summary);
|
|
1007
1034
|
details.appendChild(body);
|
|
1008
1035
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1036
|
+
// Never in the stage: a recap is between turns.
|
|
1009
1037
|
const before = recapPlace([...historyEl.querySelectorAll(":scope > .bubble.user")], turnsSince);
|
|
1010
|
-
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) :
|
|
1038
|
+
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
|
|
1011
1039
|
}
|
|
1012
1040
|
|
|
1013
1041
|
// A turn started: the recap describes the chat before it.
|
|
@@ -1067,7 +1095,7 @@ function showHookNotice(data, { before = null } = {}) {
|
|
|
1067
1095
|
|
|
1068
1096
|
function cardElement(id) {
|
|
1069
1097
|
if (!id) return null;
|
|
1070
|
-
return
|
|
1098
|
+
return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
|
|
1071
1099
|
}
|
|
1072
1100
|
|
|
1073
1101
|
function drawCard(el, card) {
|
|
@@ -1175,7 +1203,7 @@ function refreshCardBodies() {
|
|
|
1175
1203
|
}, 150);
|
|
1176
1204
|
}
|
|
1177
1205
|
|
|
1178
|
-
|
|
1206
|
+
function onCardAction(e) {
|
|
1179
1207
|
const button = e.target.closest(".plugin-card .card-action");
|
|
1180
1208
|
if (!button || !selected) return;
|
|
1181
1209
|
const card = button.closest(".plugin-card");
|
|
@@ -1192,7 +1220,8 @@ historyEl.addEventListener("click", (e) => {
|
|
|
1192
1220
|
err.classList.remove("hidden");
|
|
1193
1221
|
})
|
|
1194
1222
|
.finally(() => { button.disabled = false; });
|
|
1195
|
-
}
|
|
1223
|
+
}
|
|
1224
|
+
turnRoots().forEach((root) => root.addEventListener("click", onCardAction));
|
|
1196
1225
|
|
|
1197
1226
|
function removeRecap() {
|
|
1198
1227
|
historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
|
|
@@ -1280,12 +1309,16 @@ function startLiveTurnTiming(startedAt = new Date().toISOString()) {
|
|
|
1280
1309
|
liveTurnNote = "";
|
|
1281
1310
|
liveTurnTimingEl = document.createElement("div");
|
|
1282
1311
|
liveTurnTimingEl.className = "turn-timing live";
|
|
1283
|
-
|
|
1312
|
+
// The stage's status row while it holds the turn: the line moves into the
|
|
1313
|
+
// history with it.
|
|
1314
|
+
(turnDom.timingHost?.() || historyEl).appendChild(liveTurnTimingEl);
|
|
1284
1315
|
updateLiveTiming();
|
|
1285
1316
|
timingInterval = setInterval(updateLiveTiming, 1000);
|
|
1286
1317
|
}
|
|
1287
1318
|
|
|
1319
|
+
// The stage takes a running turn's rows (its extras); else the history.
|
|
1288
1320
|
function appendToHistory(el) {
|
|
1321
|
+
if (turnDom.place?.(el)) return;
|
|
1289
1322
|
appendAboveLiveTiming(historyEl, el, liveTurnTimingEl);
|
|
1290
1323
|
}
|
|
1291
1324
|
|
|
@@ -1438,13 +1471,16 @@ const streamHandlers = {
|
|
|
1438
1471
|
},
|
|
1439
1472
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
1440
1473
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
1474
|
+
// A finished turn still in the stage goes first: the start op looks
|
|
1475
|
+
// for a queued prompt among its rows.
|
|
1476
|
+
turnDom.handOff?.({ immediate: true });
|
|
1441
1477
|
markRecapStale();
|
|
1442
|
-
applyPromptOps(data);
|
|
1478
|
+
const promptBubble = applyPromptOps(data);
|
|
1443
1479
|
setTurnRunning(true);
|
|
1444
1480
|
setLiveStatus("running");
|
|
1445
1481
|
clearTimingInterval();
|
|
1446
1482
|
liveTurnTimingEl = null;
|
|
1447
|
-
turnDom.turnStarted(data);
|
|
1483
|
+
turnDom.turnStarted({ ...data, promptBubble });
|
|
1448
1484
|
startLiveTurnTiming(data.started_at);
|
|
1449
1485
|
}),
|
|
1450
1486
|
turn_completed: async (data) => {
|
|
@@ -1569,7 +1605,11 @@ const streamHandlers = {
|
|
|
1569
1605
|
resolveQuestionCard(data.id, { cancelled: true, reason: data.reason });
|
|
1570
1606
|
},
|
|
1571
1607
|
// One collected just as a turn started describes the chat before it.
|
|
1572
|
-
recap_ready: (data) => {
|
|
1608
|
+
recap_ready: (data) => {
|
|
1609
|
+
// After the turn it covers, never above it.
|
|
1610
|
+
turnDom.handOff?.({ immediate: true });
|
|
1611
|
+
if (!turnRunning) showRecap(data.recap);
|
|
1612
|
+
},
|
|
1573
1613
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
1574
1614
|
// In the turn view, a notice during a turn is a row of its current step.
|
|
1575
1615
|
hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
|
|
@@ -1898,9 +1938,12 @@ async function handleSendTurn() {
|
|
|
1898
1938
|
}
|
|
1899
1939
|
const hint = historyEl.querySelector(".hint");
|
|
1900
1940
|
if (hint) hint.remove();
|
|
1941
|
+
// Sending moves a finished turn out of the stage at once (else the echo
|
|
1942
|
+
// would land among its rows); into a running one it is a queued row there.
|
|
1943
|
+
turnDom.handOff?.({ immediate: true });
|
|
1901
1944
|
const sending = chips.slice();
|
|
1902
1945
|
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
|
|
1903
|
-
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1946
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1904
1947
|
if (!currentFirstPreview || currentFirstPreview==="—") {
|
|
1905
1948
|
currentFirstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
|
|
1906
1949
|
updateInfoBar();
|
|
@@ -2008,7 +2051,7 @@ function drawCommandBubble(div, { label, line, text, busy = false, failed = fals
|
|
|
2008
2051
|
|
|
2009
2052
|
function commandBubble(commandId) {
|
|
2010
2053
|
if (!commandId) return null;
|
|
2011
|
-
return
|
|
2054
|
+
return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
|
|
2012
2055
|
}
|
|
2013
2056
|
|
|
2014
2057
|
function addNoteBubble(note) {
|
|
@@ -2131,7 +2174,17 @@ $("#newBtn").addEventListener("click", () => {
|
|
|
2131
2174
|
// preselected while it is still offered (else the server's default). Hidden
|
|
2132
2175
|
// until the list is in, and in a session (its model is in the info bar). A
|
|
2133
2176
|
// host that failed is the picker's tooltip; a route that fails leaves the
|
|
2134
|
-
// picker hidden and the server's default in charge.
|
|
2177
|
+
// picker hidden and the server's default in charge. The search list itself
|
|
2178
|
+
// is model_picker.js'; a pick sends the focus to the message box.
|
|
2179
|
+
const modelPicker = createModelPicker({
|
|
2180
|
+
button: modelPickEl,
|
|
2181
|
+
panel: $("#modelPanel"),
|
|
2182
|
+
input: $("#modelSearch"),
|
|
2183
|
+
list: $("#modelList"),
|
|
2184
|
+
composer: $("#composer"),
|
|
2185
|
+
center: $("#center"),
|
|
2186
|
+
onPick: () => promptEl.focus(),
|
|
2187
|
+
});
|
|
2135
2188
|
async function loadModels() {
|
|
2136
2189
|
let payload;
|
|
2137
2190
|
try {
|
|
@@ -2139,26 +2192,16 @@ async function loadModels() {
|
|
|
2139
2192
|
} catch (_) {
|
|
2140
2193
|
return;
|
|
2141
2194
|
}
|
|
2142
|
-
const names = modelNames(payload);
|
|
2143
|
-
if (!names.length) return;
|
|
2144
2195
|
let stored = null;
|
|
2145
2196
|
try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
|
|
2146
|
-
|
|
2147
|
-
modelSelectEl.innerHTML = names
|
|
2148
|
-
.map((n) => `<option value="${escapeHtml(n)}"${n === chosen ? " selected" : ""}>${escapeHtml(n)}</option>`)
|
|
2149
|
-
.join("");
|
|
2197
|
+
if (!modelPicker.setModels(payload, stored)) return;
|
|
2150
2198
|
modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
|
|
2151
|
-
modelPickEl.dataset.value = chosen; // sizes the select to the chosen name (CSS)
|
|
2152
2199
|
modelPickEl.hidden = false;
|
|
2153
2200
|
}
|
|
2154
|
-
modelSelectEl.addEventListener("change", () => {
|
|
2155
|
-
modelPickEl.dataset.value = modelSelectEl.value;
|
|
2156
|
-
try { localStorage.setItem(MODEL_KEY, modelSelectEl.value); } catch (_) {}
|
|
2157
|
-
});
|
|
2158
2201
|
// The picker's choice for a create request; nothing (the server's default)
|
|
2159
2202
|
// while the list is not in.
|
|
2160
2203
|
function chosenModel() {
|
|
2161
|
-
return modelPickEl.hidden ? undefined :
|
|
2204
|
+
return modelPickEl.hidden ? undefined : modelPicker.chosen() || undefined;
|
|
2162
2205
|
}
|
|
2163
2206
|
actionBtn.addEventListener("click", () => {
|
|
2164
2207
|
if (!selected) handleCreate();
|
|
@@ -2250,7 +2293,8 @@ fitPrompt();
|
|
|
2250
2293
|
const dockEl = $("#dock");
|
|
2251
2294
|
const centerEl = $("#center");
|
|
2252
2295
|
new ResizeObserver(() => {
|
|
2253
|
-
|
|
2296
|
+
// The stage's height too: a reader at the end stays there.
|
|
2297
|
+
const follow = atHistoryEnd();
|
|
2254
2298
|
centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
|
|
2255
2299
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2256
2300
|
}).observe(dockEl);
|
|
@@ -2273,12 +2317,12 @@ composerEl.addEventListener("drop", (e) => {
|
|
|
2273
2317
|
e.preventDefault();
|
|
2274
2318
|
addImageFiles(files);
|
|
2275
2319
|
});
|
|
2276
|
-
|
|
2320
|
+
turnRoots().forEach((root) => root.addEventListener("click", (e) => {
|
|
2277
2321
|
const img = e.target.closest("img.thumb");
|
|
2278
2322
|
if (!img) return;
|
|
2279
2323
|
lightboxEl.querySelector("img").src = img.src;
|
|
2280
2324
|
lightboxEl.hidden = false;
|
|
2281
|
-
});
|
|
2325
|
+
}));
|
|
2282
2326
|
lightboxEl.addEventListener("click", () => { lightboxEl.hidden = true; });
|
|
2283
2327
|
document.addEventListener("keydown", (e) => {
|
|
2284
2328
|
if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
|
|
@@ -2511,7 +2555,7 @@ function updateAnnotate() {
|
|
|
2511
2555
|
const sel = window.getSelection();
|
|
2512
2556
|
if (!selected || currentOwner === "tui" || !sel || sel.isCollapsed || !sel.rangeCount) return hideAnnotate();
|
|
2513
2557
|
const range = sel.getRangeAt(0);
|
|
2514
|
-
if (!
|
|
2558
|
+
if (!turnRoots().some((root) => root.contains(range.startContainer))) return hideAnnotate();
|
|
2515
2559
|
const source = annotationSource(range.startContainer);
|
|
2516
2560
|
// A running turn's narration box shows complete sentences and its thinking
|
|
2517
2561
|
// is appended by delta; a selection in either is refused while the step is
|
|
@@ -2557,8 +2601,9 @@ function scheduleAnnotate() {
|
|
|
2557
2601
|
}
|
|
2558
2602
|
document.addEventListener("selectionchange", scheduleAnnotate);
|
|
2559
2603
|
historyEl.addEventListener("scroll", scheduleAnnotate);
|
|
2604
|
+
turnDom.scrollEl?.addEventListener("scroll", scheduleAnnotate, true);
|
|
2560
2605
|
window.addEventListener("resize", scheduleAnnotate);
|
|
2561
|
-
installCopy(
|
|
2606
|
+
turnRoots().forEach((root) => installCopy(root));
|
|
2562
2607
|
// Keep the selection when a button is pressed.
|
|
2563
2608
|
annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
|
|
2564
2609
|
annotateBar.addEventListener("click", (e) => {
|
|
@@ -2595,19 +2640,19 @@ function tabInFront() {
|
|
|
2595
2640
|
return document.visibilityState === "visible" && document.hasFocus();
|
|
2596
2641
|
}
|
|
2597
2642
|
|
|
2598
|
-
function notifySupported() {
|
|
2599
|
-
return typeof globalThis.Notification === "function";
|
|
2600
|
-
}
|
|
2601
|
-
|
|
2602
2643
|
function notifyWanted() {
|
|
2603
2644
|
try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
|
|
2604
2645
|
}
|
|
2605
2646
|
|
|
2606
|
-
// "on" | "off" | "denied" | "unsupported"
|
|
2647
|
+
// "on" | "off" | "denied" | "unsupported" (notify.js)
|
|
2607
2648
|
function notifyState() {
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2649
|
+
const hasNotification = typeof globalThis.Notification === "function";
|
|
2650
|
+
return bellState({
|
|
2651
|
+
hasNotification,
|
|
2652
|
+
secureContext: globalThis.isSecureContext !== false,
|
|
2653
|
+
permission: hasNotification ? Notification.permission : "default",
|
|
2654
|
+
wanted: notifyWanted(),
|
|
2655
|
+
});
|
|
2611
2656
|
}
|
|
2612
2657
|
|
|
2613
2658
|
const NOTIFY_TITLES = {
|
|
@@ -349,7 +349,11 @@ export function fillActivityRow(el, row, sessionId) {
|
|
|
349
349
|
const toolEl = el.querySelector(".activity-tool");
|
|
350
350
|
if (toolEl) toolEl.textContent = toolName(row);
|
|
351
351
|
const paramsEl = el.querySelector(".activity-params");
|
|
352
|
-
if (paramsEl)
|
|
352
|
+
if (paramsEl) {
|
|
353
|
+
// The title in place of the params; the full params line on hover.
|
|
354
|
+
paramsEl.textContent = row.title || row.params || "";
|
|
355
|
+
paramsEl.title = row.title && row.params ? row.params : "";
|
|
356
|
+
}
|
|
353
357
|
const outEl = el.querySelector(".activity-output");
|
|
354
358
|
if (outEl) {
|
|
355
359
|
if (row.status === "running") {
|