samagotchi 0.83.0 → 0.85.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 +31 -1
- data/bin/chi +27 -2
- data/docs/architecture.md +10 -1
- data/docs/cli.md +17 -1
- data/docs/configuration.md +2 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +3 -2
- data/docs/plugins.md +31 -5
- data/docs/sessions.md +15 -2
- data/docs/sub-agent.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +54 -1
- data/lib/samagotchi/bridge_client.rb +12 -0
- data/lib/samagotchi/bundle_session_settings.rb +212 -0
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +12 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +5 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +2 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +36 -2
- data/lib/samagotchi/reply_wait.rb +8 -1
- data/lib/samagotchi/send_command.rb +24 -7
- data/lib/samagotchi/session.rb +16 -1
- data/lib/samagotchi/session_commands.rb +7 -4
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/session_setup.rb +14 -6
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
- data/lib/samagotchi/terminal_ui.rb +10 -2
- data/lib/samagotchi/testing/bundle.rb +31 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +41 -4
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- metadata +3 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -166,6 +166,7 @@ module Samagotchi
|
|
|
166
166
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
167
167
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
168
168
|
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
|
|
169
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn],
|
|
169
170
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
170
171
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
171
172
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
@@ -649,6 +650,15 @@ module Samagotchi
|
|
|
649
650
|
thinking, error = request_thinking(body["thinking"])
|
|
650
651
|
return error if error
|
|
651
652
|
|
|
653
|
+
# bundle_settings: the session's own bundle settings (chi --set:
|
|
654
|
+
# {bundle => {key => scalar}}), checked against the installed
|
|
655
|
+
# bundles the same way.
|
|
656
|
+
begin
|
|
657
|
+
bundle_settings = BundleSessionSettings.from_request(body["bundle_settings"])
|
|
658
|
+
rescue BundleSessionSettings::Error => e
|
|
659
|
+
return error_response(400, "invalid_bundle_settings", e.message)
|
|
660
|
+
end
|
|
661
|
+
|
|
652
662
|
folder = dir ? { working_directory: dir } : {}
|
|
653
663
|
# model: the model the session starts on (the start page's picker,
|
|
654
664
|
# spelled as GET /api/models lists it); blank means the default.
|
|
@@ -659,7 +669,7 @@ module Samagotchi
|
|
|
659
669
|
# until that turn is saved.
|
|
660
670
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
661
671
|
folder[:title] = preview unless preview.empty?
|
|
662
|
-
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking)
|
|
672
|
+
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, bundle_settings: bundle_settings)
|
|
663
673
|
folder[:setup] = setup unless setup.empty?
|
|
664
674
|
begin
|
|
665
675
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
@@ -679,7 +689,7 @@ module Samagotchi
|
|
|
679
689
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
680
690
|
# link of that session's chain (SessionManager.continue_session), in its
|
|
681
691
|
# folder and with its own setup (SessionSetup: llm_context, thinking
|
|
682
|
-
# and reasoning_history levels), so dir, llm_context and
|
|
692
|
+
# and reasoning_history levels, bundle settings), so dir, llm_context, thinking and bundle_settings are
|
|
683
693
|
# refused beside it (a create takes no reasoning_history). It runs on
|
|
684
694
|
# model (or model_name) when given, else on the previous link's: one
|
|
685
695
|
# whose host is gone falls back to the default, and the 201's
|
|
@@ -694,9 +704,9 @@ module Samagotchi
|
|
|
694
704
|
unless given.is_a?(String) && !given.strip.empty?
|
|
695
705
|
return error_response(400, "invalid_continues", "continues is a session id")
|
|
696
706
|
end
|
|
697
|
-
if (fields = %w[dir llm_context thinking].select { |key| body.key?(key) }).any?
|
|
707
|
+
if (fields = %w[dir llm_context thinking bundle_settings].select { |key| body.key?(key) }).any?
|
|
698
708
|
return error_response(400, "invalid_continues",
|
|
699
|
-
"continues takes the previous session's folder, llm_context and
|
|
709
|
+
"continues takes the previous session's folder, llm_context, thinking and bundle_settings; " \
|
|
700
710
|
"leave out #{fields.join(", ")}")
|
|
701
711
|
end
|
|
702
712
|
|
|
@@ -1330,6 +1340,33 @@ module Samagotchi
|
|
|
1330
1340
|
error_response(404, "not_found", e.message)
|
|
1331
1341
|
end
|
|
1332
1342
|
|
|
1343
|
+
# POST /api/sessions/:id/turn/:enqueued_id/dequeue {client_id?}: the ×
|
|
1344
|
+
# on a queued bubble takes the message back before it runs
|
|
1345
|
+
# (Bridge#handle_dequeue_turn). Straight to the live worker, never
|
|
1346
|
+
# through deliver_turn: a removal must not wake one, and a stopped
|
|
1347
|
+
# worker's queued file is the next worker's (503 not_live). 200
|
|
1348
|
+
# {status, enqueued_id, prompt}; 409 already_started / not_queued and
|
|
1349
|
+
# 404 unknown_enqueued_id pass through; the rest as every relay.
|
|
1350
|
+
def handle_dequeue_turn(req, id, enqueued_id)
|
|
1351
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
1352
|
+
body = request_body(req)
|
|
1353
|
+
parsed = body.nil? || body.strip.empty? ? nil : parse_json(body)
|
|
1354
|
+
client_id = (parsed["client_id"] if parsed.is_a?(Hash))
|
|
1355
|
+
|
|
1356
|
+
request = ->(client) { client.dequeue(enqueued_id: enqueued_id, client_id: client_id) }
|
|
1357
|
+
relay(id, bridge_client(id), request, what: "the message was not removed", cant: "remove queued messages") do |reply|
|
|
1358
|
+
body = reply.json || {}
|
|
1359
|
+
case reply.status
|
|
1360
|
+
when 200 then json_response(200, { status: body["status"], enqueued_id: enqueued_id, prompt: body["prompt"].to_s, session_id: id })
|
|
1361
|
+
when 409 then json_response(409, { error: body["error"] || "already_started", detail: body["detail"], enqueued_id: enqueued_id, session_id: id }.compact)
|
|
1362
|
+
when 404
|
|
1363
|
+
json_response(404, { error: "unknown_enqueued_id", enqueued_id: enqueued_id, session_id: id }) if body["error"] == "unknown_enqueued_id"
|
|
1364
|
+
end
|
|
1365
|
+
end
|
|
1366
|
+
rescue ArgumentError => e
|
|
1367
|
+
error_response(404, "not_found", e.message)
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1333
1370
|
def handle_stop(_req, id)
|
|
1334
1371
|
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
1335
1372
|
# after it just means an immediate resume finds it (rare).
|
|
@@ -92,6 +92,7 @@ import { currentGen } from "./turn_model.js";
|
|
|
92
92
|
import { toolName } from "./activity.js";
|
|
93
93
|
import { toastBottom } from "./toast_place.js";
|
|
94
94
|
import { createToasts } from "./toasts.js";
|
|
95
|
+
import { createUnqueue } from "./unqueue.js";
|
|
95
96
|
|
|
96
97
|
const $ = (s) => document.querySelector(s);
|
|
97
98
|
const topStripEl = $("#topStrip");
|
|
@@ -1801,6 +1802,22 @@ composerEl.addEventListener("drop", (e) => {
|
|
|
1801
1802
|
e.preventDefault();
|
|
1802
1803
|
addImageFiles(files);
|
|
1803
1804
|
});
|
|
1805
|
+
// The × on a message queued for after the turn takes it back (unqueue.js).
|
|
1806
|
+
const unqueue = createUnqueue({
|
|
1807
|
+
sessionId: () => selected,
|
|
1808
|
+
clientId,
|
|
1809
|
+
toast: (text) => showToast(text),
|
|
1810
|
+
// Edit: the removed text back into an empty composer (never over a draft).
|
|
1811
|
+
putBack: (text) => {
|
|
1812
|
+
if (promptEl.value.trim() || chips.length) return false;
|
|
1813
|
+
openForVisit();
|
|
1814
|
+
promptEl.value = text;
|
|
1815
|
+
fitPrompt();
|
|
1816
|
+
promptEl.focus();
|
|
1817
|
+
return true;
|
|
1818
|
+
},
|
|
1819
|
+
});
|
|
1820
|
+
turnRoots().forEach((root) => root.addEventListener("click", (e) => { unqueue.onClick(e); }));
|
|
1804
1821
|
turnRoots().forEach((root) => root.addEventListener("click", (e) => {
|
|
1805
1822
|
const img = e.target.closest("img.thumb");
|
|
1806
1823
|
if (!img) return;
|
|
@@ -13,6 +13,13 @@ import { linkedLine } from "./notice_links.js";
|
|
|
13
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
14
14
|
import { drawCommandBubble } from "./commands.js";
|
|
15
15
|
|
|
16
|
+
export function removeQueuedBubble(bubble) {
|
|
17
|
+
if (!bubble) return;
|
|
18
|
+
const slot = bubble.parentElement?.classList.contains("ts-queued") ? bubble.parentElement : null;
|
|
19
|
+
bubble.remove();
|
|
20
|
+
if (slot && slot.childElementCount === 0) slot.remove();
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
// A prompt that started a turn: not a line steered into a running one.
|
|
17
24
|
export const TURN_PROMPT = ".bubble.user:not(.steered)";
|
|
18
25
|
|
|
@@ -205,6 +212,8 @@ export function createBubbles({
|
|
|
205
212
|
setBubbleState(bubble, null);
|
|
206
213
|
started = bubble;
|
|
207
214
|
}
|
|
215
|
+
} else if (op.op === "remove") {
|
|
216
|
+
removeQueuedBubble(bubbleById(op.enqueuedId));
|
|
208
217
|
} else if (op.op === "steer") {
|
|
209
218
|
const bubble = bubbleById(op.enqueuedId);
|
|
210
219
|
if (bubble) setBubbleState(bubble, "steered");
|
|
@@ -110,6 +110,7 @@ const WEB_LOCAL_REPLIES = {
|
|
|
110
110
|
"/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
|
|
111
111
|
"/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
|
|
112
112
|
"/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
|
|
113
|
+
"/unqueue": "/unqueue: a terminal's command; here the × on a message queued for after the turn removes it",
|
|
113
114
|
"/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
|
|
114
115
|
};
|
|
115
116
|
|
|
@@ -169,6 +169,17 @@ export function stopTask(id, taskId, opts = {}) {
|
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// Takes back a message queued for after the running turn (the × on its
|
|
173
|
+
// bubble): 200 {status, enqueued_id, prompt}; 409 already_started /
|
|
174
|
+
// not_queued, 404 unknown_enqueued_id, 503 no live worker.
|
|
175
|
+
export function dequeueTurn(id, enqueuedId, { clientId = null } = {}, opts = {}) {
|
|
176
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/turn/${encodeURIComponent(enqueuedId)}/dequeue`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: JSON.stringify(clientId ? { client_id: clientId } : {}),
|
|
179
|
+
...opts,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
// Hands the session to a new worker on the newest chi installed: 200
|
|
173
184
|
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
174
185
|
export function restartSession(id, opts = {}) {
|
|
@@ -120,6 +120,12 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
120
120
|
.bubble.user.queued .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
|
|
121
121
|
.bubble.user.steered .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
|
|
122
122
|
.bubble.user.failed .state-badge::before{background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 40%,transparent)}
|
|
123
|
+
/* The × on a message queued for after the turn (unqueue.js): only once the bubble has its enqueued_id; a 32px tap target around a small mark */
|
|
124
|
+
.bubble.user .state-badge button.unqueue{position:relative;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;margin:0 -3px 0 0;padding:0;border:0;border-radius:50%;background:transparent;color:var(--text-muted);font-size:14px;line-height:1;cursor:pointer}
|
|
125
|
+
.bubble.user .state-badge button.unqueue::before{content:"\00d7"}
|
|
126
|
+
.bubble.user .state-badge button.unqueue::after{content:"";position:absolute;inset:-8px}
|
|
127
|
+
.bubble.user .state-badge button.unqueue:hover:not(:disabled){background:var(--danger-bg-dim);color:var(--danger-text);filter:none}
|
|
128
|
+
.bubble.user:not([data-enqueued-id]) .state-badge button.unqueue{display:none}
|
|
123
129
|
.bubble.user .origin-label{font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}
|
|
124
130
|
.bubble.user .user-message{flex:1;min-width:0}
|
|
125
131
|
.bubble.user blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-3);margin:2px 0 6px}
|
|
@@ -847,6 +853,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
847
853
|
.turn-stage .turn-work.outline>.gen.folded{display:block}
|
|
848
854
|
.turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
|
|
849
855
|
@media(max-width:600px){
|
|
856
|
+
.bubble.user.queued[data-delivery="queue"]{flex-wrap:wrap}
|
|
857
|
+
.bubble.user.queued[data-delivery="queue"] .origin-label,.bubble.user.queued[data-delivery="queue"] .state-badge{order:0}
|
|
858
|
+
.bubble.user.queued[data-delivery="queue"] .user-message{order:1;flex-basis:100%}
|
|
850
859
|
.ts-status{padding-left:10px}
|
|
851
860
|
.ts-scroll{padding:8px 10px 10px}
|
|
852
861
|
.ts-foot{padding:0 10px 10px}
|
|
@@ -410,6 +410,10 @@ export function createStageView({
|
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
function ensureQueuedSlot() {
|
|
414
|
+
if (!queuedSlot.isConnected) stage.appendChild(queuedSlot);
|
|
415
|
+
}
|
|
416
|
+
|
|
413
417
|
function emptySlots() {
|
|
414
418
|
for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
|
|
415
419
|
slotsView.reset();
|
|
@@ -535,6 +539,7 @@ export function createStageView({
|
|
|
535
539
|
// The lines still queued behind this turn went into the history with
|
|
536
540
|
// the turn before (the hand-off at this turn_started, or an earlier
|
|
537
541
|
// one): back below this turn's card, as a reload places them.
|
|
542
|
+
ensureQueuedSlot();
|
|
538
543
|
queuedSlot.append(...waiting.filter((el) => !el.isConnected), ...historyEl.querySelectorAll(QUEUED_CHILDREN));
|
|
539
544
|
view.turnStarted(data || {});
|
|
540
545
|
show();
|
|
@@ -616,6 +621,7 @@ export function createStageView({
|
|
|
616
621
|
const where = placeFor({ live: holding, handingOff: false, kind: placeKind(el) });
|
|
617
622
|
if (where === "history") return false;
|
|
618
623
|
if (where === "queued") {
|
|
624
|
+
ensureQueuedSlot();
|
|
619
625
|
queuedSlot.appendChild(el);
|
|
620
626
|
scheduleRefresh();
|
|
621
627
|
return true;
|
|
@@ -116,6 +116,9 @@ export function createStreamHandlers(deps) {
|
|
|
116
116
|
showSteers(data.steers);
|
|
117
117
|
},
|
|
118
118
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
119
|
+
// A queued message taken back before it ran (the × here or in another
|
|
120
|
+
// tab, /unqueue in a terminal): its bubble goes, in every tab.
|
|
121
|
+
turn_dequeued: (data) => applyPromptOps(data),
|
|
119
122
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
120
123
|
if (stream.takeGuess()) {
|
|
121
124
|
// A replay of a turn the render drew: re-read and stream from there.
|
|
@@ -247,6 +247,8 @@ export function initWaitLine({ tasks } = {}) {
|
|
|
247
247
|
// tag — this tab's own local echo gets its enqueued_id
|
|
248
248
|
// start — the bubble's turn started (drops the "queued" badge)
|
|
249
249
|
// steer — the bubble was merged into the running turn
|
|
250
|
+
// remove — the queued message was taken back before it ran (turn_dequeued,
|
|
251
|
+
// whoever removed it): its bubble goes
|
|
250
252
|
// report — the merge brought a delegate child's report (origin
|
|
251
253
|
// child:<id8>, no bubble anywhere): its text gets a labelled one
|
|
252
254
|
// `known(enqueuedId)` says whether a bubble carries that id already;
|
|
@@ -276,6 +278,8 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
|
|
|
276
278
|
}
|
|
277
279
|
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
|
|
278
280
|
}
|
|
281
|
+
case "turn_dequeued":
|
|
282
|
+
return event.enqueued_id ? [{ op: "remove", enqueuedId: event.enqueued_id }] : [];
|
|
279
283
|
case "input_merged": {
|
|
280
284
|
const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
|
|
281
285
|
known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
|
|
@@ -184,9 +184,17 @@ export function badgeText(state, delivery = null) {
|
|
|
184
184
|
return (state === "queued" && QUEUED_BADGES[delivery]) || BADGES[state];
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// A message queued for after the turn (delivery queue) can be taken back
|
|
188
|
+
// while it waits: the badge carries its ×, shown once the bubble has its
|
|
189
|
+
// enqueued_id (style.css; an echo before its ack has none) and handled by
|
|
190
|
+
// unqueue.js. Not a next-step line: a step boundary may merge it any moment.
|
|
187
191
|
export function stateBadgeTemplate(state, delivery = null) {
|
|
188
192
|
const text = badgeText(state, delivery);
|
|
189
|
-
|
|
193
|
+
if (!text) return nothing;
|
|
194
|
+
const remove = state === "queued" && delivery === "queue"
|
|
195
|
+
? html`<button type="button" class="unqueue" title="Remove from queue" aria-label="Remove from queue"></button>`
|
|
196
|
+
: nothing;
|
|
197
|
+
return html`<span class="state-badge">${text}${remove}</span>`;
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
// A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The × on a message queued for after the running turn (its badge,
|
|
2
|
+
// turn_html.js stateBadgeTemplate): POST …/turn/:enqueued_id/dequeue
|
|
3
|
+
// (Bridge#handle_dequeue_turn) takes it back before it runs. The bubble
|
|
4
|
+
// goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
|
|
5
|
+
// here at the 200 already. Its text comes back (edit): into an empty
|
|
6
|
+
// composer, to change and send again; over a draft only in a toast (the
|
|
7
|
+
// draft stays). Its images don't come back. A refusal keeps it and says why
|
|
8
|
+
// in a toast. No confirm: nothing is lost. Pure but for the
|
|
9
|
+
// injected calls, so `node --test` covers it; app.js binds onClick to the
|
|
10
|
+
// turn roots.
|
|
11
|
+
import { dequeueTurn as postDequeue } from "./data.js";
|
|
12
|
+
import { removeQueuedBubble } from "./bubbles.js";
|
|
13
|
+
|
|
14
|
+
// Why a removal didn't happen, in words.
|
|
15
|
+
export function unqueueFailureText(error) {
|
|
16
|
+
if (error?.code === "already_started") return "Not removed: it already started";
|
|
17
|
+
if (error?.code === "not_queued") return "Not removed: it goes in at the running turn's next step";
|
|
18
|
+
if (error?.status === 404 && error?.code === "unknown_enqueued_id") return "Not removed: it is already gone";
|
|
19
|
+
return `Not removed: ${error?.message || "the worker did not answer"}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The removed text in a toast, clipped.
|
|
23
|
+
const SHOWN = 200;
|
|
24
|
+
function clipped(text) {
|
|
25
|
+
const line = String(text || "").replace(/\s+/g, " ").trim();
|
|
26
|
+
return line.length > SHOWN ? `${line.slice(0, SHOWN)}\u2026` : line;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// +sessionId+ is read at each click (the open session); +clientId+ is this
|
|
30
|
+
// tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
|
|
31
|
+
// composer when it is empty and says whether it did.
|
|
32
|
+
export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false }) {
|
|
33
|
+
// A click on a queued bubble's ×; any other click is left alone.
|
|
34
|
+
async function onClick(e) {
|
|
35
|
+
const button = e.target?.closest?.("button.unqueue");
|
|
36
|
+
if (!button || button.disabled) return null;
|
|
37
|
+
const bubble = button.closest(".bubble.user");
|
|
38
|
+
const enqueuedId = bubble?.dataset.enqueuedId;
|
|
39
|
+
const id = sessionId();
|
|
40
|
+
if (!enqueuedId || !id) return null;
|
|
41
|
+
e.stopPropagation?.();
|
|
42
|
+
button.disabled = true;
|
|
43
|
+
try {
|
|
44
|
+
const result = await dequeue(id, enqueuedId, { clientId });
|
|
45
|
+
removeQueuedBubble(bubble);
|
|
46
|
+
const text = String(result?.prompt || "");
|
|
47
|
+
if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
|
|
48
|
+
return result;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (button.isConnected) button.disabled = false;
|
|
51
|
+
toast(unqueueFailureText(error));
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { onClick };
|
|
56
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.85.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- lib/samagotchi/broadcast_command.rb
|
|
181
181
|
- lib/samagotchi/bundle_command.rb
|
|
182
182
|
- lib/samagotchi/bundle_needs.rb
|
|
183
|
+
- lib/samagotchi/bundle_session_settings.rb
|
|
183
184
|
- lib/samagotchi/bundles/agents-md/manifest.yml
|
|
184
185
|
- lib/samagotchi/bundles/agents-md/plugin.rb
|
|
185
186
|
- lib/samagotchi/bundles/btw/manifest.yml
|
|
@@ -690,6 +691,7 @@ files:
|
|
|
690
691
|
- lib/samagotchi/web/public/turn_html.js
|
|
691
692
|
- lib/samagotchi/web/public/turn_model.js
|
|
692
693
|
- lib/samagotchi/web/public/turn_view.js
|
|
694
|
+
- lib/samagotchi/web/public/unqueue.js
|
|
693
695
|
- lib/samagotchi/web/public/update_notice.js
|
|
694
696
|
- lib/samagotchi/web/public/vendor/directive-helpers.js
|
|
695
697
|
- lib/samagotchi/web/public/vendor/directive.js
|