samagotchi 0.92.0 → 0.94.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 +53 -1
- data/README.md +1 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +6 -0
- data/docs/memory.md +26 -1
- data/docs/plugins.md +37 -0
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +11 -2
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bootstrap_command.rb +1 -0
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
- data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +4 -14
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/memory_bundle/profile.rb +22 -4
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +47 -190
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +26 -0
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +137 -638
- data/lib/samagotchi/web/public/dock_reserve.js +103 -0
- data/lib/samagotchi/web/public/index.html +4 -4
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- data/lib/samagotchi/web/public/toast_place.js +1 -1
- metadata +14 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { easeScrollTo, FOLLOW_MS } from "./scroll.js";
|
|
2
|
+
import { FOLD_MS } from "./stage_view.js";
|
|
3
|
+
import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
|
|
4
|
+
import { toastBottom } from "./toast_place.js";
|
|
5
|
+
|
|
6
|
+
// What floats over the history (used by app.js): the composer card, and in
|
|
7
|
+
// the stage view the stage above it. Two measures follow it.
|
|
8
|
+
//
|
|
9
|
+
// The reserve: the card floats over the history, so the history's bottom
|
|
10
|
+
// padding (--dock-h on #center, index.html) is kept at the card's height,
|
|
11
|
+
// and a reader at the end stays pinned there, since the card grows (text,
|
|
12
|
+
// chips, Cancel, a wrapping footer) under a reader at the end. In the stage
|
|
13
|
+
// view the stage floats above the card, out of the dock's flow
|
|
14
|
+
// (stage_view.js): the reserve counts it too, with its STAGE_GAP, while it
|
|
15
|
+
// shows. A leaving stage (the hand-off's slide) no longer counts: the
|
|
16
|
+
// reserve eases down over the slide (#center.reserve-easing).
|
|
17
|
+
//
|
|
18
|
+
// The toast: it sits above the session's composer card (measured: it grows
|
|
19
|
+
// with a wrapping footer or a taller box), so it never covers the card's
|
|
20
|
+
// controls, and above the stage while it shows and a fresh link's open
|
|
21
|
+
// model panel (toast_place.js); with no card in sight (the start page, all
|
|
22
|
+
// sessions) the CSS's 24 px from the bottom (--toast-bottom unset).
|
|
23
|
+
//
|
|
24
|
+
// +stageEl+: the stage (stage view) or null (turn view). +atHistoryEnd()+:
|
|
25
|
+
// the reader is at the history's end (app.js, scroll.js's threshold).
|
|
26
|
+
// +reduce()+ / +ease+: prefers-reduced-motion and the eased follow
|
|
27
|
+
// (scroll.js), defaults the page's.
|
|
28
|
+
// The observers that call these are installed here: the dock's and the
|
|
29
|
+
// stage's size, the stage showing, hiding or leaving, the window's size and
|
|
30
|
+
// the model panel's. app.js calls placeToast() when the page enters or
|
|
31
|
+
// leaves a session and when a toast shows or goes (toasts.js onChange).
|
|
32
|
+
export function createDockReserve({
|
|
33
|
+
dockEl, centerEl, stageEl, historyEl, toastEl, modelPanelEl, atHistoryEnd,
|
|
34
|
+
reduce = reducedMotion, ease = easeScrollTo,
|
|
35
|
+
}) {
|
|
36
|
+
const doc = dockEl.ownerDocument;
|
|
37
|
+
const win = doc.defaultView;
|
|
38
|
+
|
|
39
|
+
function dockReserve() {
|
|
40
|
+
const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
|
|
41
|
+
return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
|
|
42
|
+
}
|
|
43
|
+
const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
|
|
44
|
+
|
|
45
|
+
// A reader at the end stays there when the reserve changes: at once for the
|
|
46
|
+
// composer card, eased for the stage (+fromStage+: it appeared, grew or
|
|
47
|
+
// leaves), so the history glides rather than jumps. An eased follow running
|
|
48
|
+
// counts as at the end.
|
|
49
|
+
function updateReserve({ fromStage = false } = {}) {
|
|
50
|
+
const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
|
|
51
|
+
const leaving = !!stageEl?.classList.contains("leaving");
|
|
52
|
+
centerEl.classList.toggle("reserve-easing", leaving);
|
|
53
|
+
const reserve = `${dockReserve()}px`;
|
|
54
|
+
const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
|
|
55
|
+
if (changed) centerEl.style.setProperty("--dock-h", reserve);
|
|
56
|
+
if (changed && follow) {
|
|
57
|
+
if (fromStage) ease(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reduce() });
|
|
58
|
+
else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
|
|
59
|
+
}
|
|
60
|
+
placeToast();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function placeToast() {
|
|
64
|
+
const r = dockEl.getBoundingClientRect();
|
|
65
|
+
const shownTop = (el) => {
|
|
66
|
+
const box = el && !el.hidden ? el.getBoundingClientRect() : null;
|
|
67
|
+
return box && box.height > 0 ? box.top : null;
|
|
68
|
+
};
|
|
69
|
+
const px = toastBottom({
|
|
70
|
+
inSession: doc.body.classList.contains("has-session") && r.height > 0,
|
|
71
|
+
viewportHeight: win.innerHeight,
|
|
72
|
+
dockTop: r.top,
|
|
73
|
+
tops: [shownTop(stageEl), shownTop(modelPanelEl)],
|
|
74
|
+
toastHeight: toastEl.offsetHeight,
|
|
75
|
+
});
|
|
76
|
+
const bottom = px === null ? "" : `${px}px`;
|
|
77
|
+
const root = doc.documentElement.style;
|
|
78
|
+
if (root.getPropertyValue("--toast-bottom") !== bottom) {
|
|
79
|
+
if (bottom) root.setProperty("--toast-bottom", bottom);
|
|
80
|
+
else root.removeProperty("--toast-bottom");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dockObserver = new win.ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
|
|
85
|
+
dockObserver.observe(dockEl);
|
|
86
|
+
if (stageEl) {
|
|
87
|
+
dockObserver.observe(stageEl);
|
|
88
|
+
// Only showing, hiding and leaving change the reserve (not the stage's
|
|
89
|
+
// other classes: waiting, in-use…).
|
|
90
|
+
let stageShown = "";
|
|
91
|
+
new win.MutationObserver(() => {
|
|
92
|
+
const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
|
|
93
|
+
if (shown === stageShown) return;
|
|
94
|
+
stageShown = shown;
|
|
95
|
+
updateReserve({ fromStage: true });
|
|
96
|
+
}).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
|
|
97
|
+
}
|
|
98
|
+
win.addEventListener("resize", () => placeToast());
|
|
99
|
+
// The model panel opening, closing or changing height (its list filters).
|
|
100
|
+
new win.ResizeObserver(() => placeToast()).observe(modelPanelEl);
|
|
101
|
+
|
|
102
|
+
return { updateReserve, placeToast };
|
|
103
|
+
}
|
|
@@ -547,7 +547,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
547
547
|
@media (hover:none){.copy-btn{opacity:.55}}
|
|
548
548
|
/* The dock is the composer card: the text field, a toolbar row, the info bar as its footer. */
|
|
549
549
|
#dock{display:flex;flex-direction:column;flex-shrink:0;margin:0 12px 12px;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
|
|
550
|
-
/* In a session the card floats over the history, so the glass blurs it; the history's bottom padding follows the card (--dock-h, set from
|
|
550
|
+
/* In a session the card floats over the history, so the glass blurs it; the history's bottom padding follows the card (--dock-h, set from dock_reserve.js). */
|
|
551
551
|
/* The session panel lets the page's glow through (the history's own layers go clear), so the card and its footer read as glass over it, not as a flat box on black. */
|
|
552
552
|
body.has-session #center{background:color-mix(in srgb,var(--bg) 35%,transparent);border-color:var(--glass-border);position:relative}
|
|
553
553
|
body.has-session #stage,body.has-session #history{background:transparent}
|
|
@@ -646,7 +646,7 @@ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
|
|
|
646
646
|
body.has-session #composer textarea{min-height:0}
|
|
647
647
|
body.has-session #composeHint{display:none}
|
|
648
648
|
body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
649
|
-
/* The model picker (the start page, and a fresh chain link:
|
|
649
|
+
/* The model picker (the start page, and a fresh chain link: start_page.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
|
|
650
650
|
#composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
|
|
651
651
|
#composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
|
|
652
652
|
#composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
|
|
@@ -719,7 +719,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
719
719
|
.turn-stage{display:flex;flex-direction:column;min-height:0;max-height:var(--stage-max,60vh);border-left:3px solid var(--accent);border-radius:14px;overflow:hidden}
|
|
720
720
|
/* Out of the dock's flow: it floats 10 px above the composer card, so its rise, growth and leaving never move #dock (a
|
|
721
721
|
bottom-pinned box changing height is a layout shift, animated or not). Painted before .dock-card (no z-index): the command
|
|
722
|
-
list (z-index 20 inside .dock-card) opens over its lower edge. The history's bottom reserve counts it (
|
|
722
|
+
list (z-index 20 inside .dock-card) opens over its lower edge. The history's bottom reserve counts it (dock_reserve.js, --dock-h). */
|
|
723
723
|
body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-bottom:10px}
|
|
724
724
|
.turn-stage[hidden]{display:none}
|
|
725
725
|
.turn-stage.ended{border-left-color:var(--border-strong)}
|
|
@@ -1183,7 +1183,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
1183
1183
|
.card.archived{opacity:.6}
|
|
1184
1184
|
.card.archived:hover{opacity:.85}
|
|
1185
1185
|
.card .archived-badge,.family-row .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
|
|
1186
|
-
/* bottom: above the session's composer card when one shows (--toast-bottom,
|
|
1186
|
+
/* bottom: above the session's composer card when one shows (--toast-bottom, dock_reserve.js placeToast). */
|
|
1187
1187
|
#toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
|
|
1188
1188
|
#toast[hidden]{display:none}
|
|
1189
1189
|
#toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { routeComposerLine, routeSessionLine } from "./composer.js";
|
|
2
|
+
import { chipsAfterSend, restoredChips, turnRefs, turnText, uploadChips } from "./images.js";
|
|
3
|
+
import { cantText, firstPreview } from "./format.js";
|
|
4
|
+
import { setBubbleDelivery } from "./bubbles.js";
|
|
5
|
+
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
6
|
+
|
|
7
|
+
// The composer's send (used by app.js): a line into the open session, or
|
|
8
|
+
// on the start page a new session first (an idle one, then the line as for
|
|
9
|
+
// any later one, so a failed first turn's prompt comes back into the
|
|
10
|
+
// composer too). The route (composer.js), the echo, the images' upload, the
|
|
11
|
+
// /turn and its ack, a fast failure's restore (restore.js) and the error's
|
|
12
|
+
// words.
|
|
13
|
+
//
|
|
14
|
+
// Hooks: +session()+ the open session's id or null; +state()+ its fields
|
|
15
|
+
// (session_state.js: owner, commands, firstPreview); +running()+ a turn
|
|
16
|
+
// runs; +clientId+; +sentIds+ / +earlyRestores+ (restore.js' bookkeeping);
|
|
17
|
+
// +api+: {createIdleSession, uploadImage, sendTurn} (data.js); +dir+: the
|
|
18
|
+
// page's scope folder (new sessions start there); +startPage+
|
|
19
|
+
// (start_page.js: pendingPick(), chosenModel(), newChoices(),
|
|
20
|
+
// clearChoices()); +takeDelivery()+ the delivery line's armed choice;
|
|
21
|
+
// +composer+: {el (the text box), button (Send/Start), chips(),
|
|
22
|
+
// setChips(list), fit(), clear(), openForVisit(), focus()}; +historyEl+,
|
|
23
|
+
// +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
|
|
24
|
+
// +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
|
|
25
|
+
// +showNotice(text)+, +updateInfoBar()+, +updateComposerMode()+,
|
|
26
|
+
// +followWokenWorker()+, +refreshHistory()+, +leaveGoneSession(id, why)+,
|
|
27
|
+
// +runCommand(command)+ (composer.js send), +toast(text, action, options)+;
|
|
28
|
+
// +alert+: the browser's.
|
|
29
|
+
export function createSendTurn({
|
|
30
|
+
session, state, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
|
|
31
|
+
historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
|
|
32
|
+
addCreatedSession, select, showNotice, updateInfoBar, updateComposerMode, followWokenWorker, refreshHistory,
|
|
33
|
+
leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
|
|
34
|
+
}) {
|
|
35
|
+
const promptEl = composer.el;
|
|
36
|
+
const actionBtn = composer.button;
|
|
37
|
+
|
|
38
|
+
// +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
|
|
39
|
+
// a delivery-line link: cut or queue); else the armed one; a typed /cut or
|
|
40
|
+
// /queue wins over both.
|
|
41
|
+
async function send({ delivery: chosen = null } = {}) {
|
|
42
|
+
if (state().owner === "tui") return;
|
|
43
|
+
// A fresh link's pick first: its /model is queued in the worker once
|
|
44
|
+
// acked, and the worker runs queued commands before a turn's input.
|
|
45
|
+
const pick = startPage.pendingPick();
|
|
46
|
+
if (pick) await pick;
|
|
47
|
+
let prompt = turnText(promptEl.value, composer.chips());
|
|
48
|
+
if (!prompt) return;
|
|
49
|
+
const armed = takeDelivery();
|
|
50
|
+
// An image-only send's text is its placeholders: not for ↑.
|
|
51
|
+
const typed = promptEl.value.trim() !== "";
|
|
52
|
+
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
53
|
+
const line = prompt;
|
|
54
|
+
// Where the line goes (composer.js): the page's own reply, a session to
|
|
55
|
+
// make first (the start page), a command, or a turn.
|
|
56
|
+
let route = routeComposerLine(prompt, {
|
|
57
|
+
session: !!session(), running: running(), commands: state().commands, images: composer.chips().length,
|
|
58
|
+
});
|
|
59
|
+
if (route.create) {
|
|
60
|
+
actionBtn.disabled = true;
|
|
61
|
+
try {
|
|
62
|
+
const s = await api.createIdleSession({ dir, model: startPage.chosenModel(), preview: route.send,
|
|
63
|
+
...startPage.newChoices() });
|
|
64
|
+
// The choice was for this chat only.
|
|
65
|
+
startPage.clearChoices();
|
|
66
|
+
addCreatedSession(s);
|
|
67
|
+
await select(s.id);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
alert(e.message);
|
|
70
|
+
return;
|
|
71
|
+
} finally {
|
|
72
|
+
actionBtn.disabled = false;
|
|
73
|
+
}
|
|
74
|
+
// In the new session, by its command list (select read it).
|
|
75
|
+
route = routeSessionLine(route.send, { commands: state().commands, images: composer.chips().length, delivery: route.delivery });
|
|
76
|
+
}
|
|
77
|
+
if (route.reply) {
|
|
78
|
+
// The start page's reply is a notice: no session is made for it (2.29).
|
|
79
|
+
if (session()) addCommandBubble({ label: null, line: route.line, text: route.reply });
|
|
80
|
+
else showNotice(route.reply);
|
|
81
|
+
composer.clear();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (route.command) return runCommand(route.command);
|
|
85
|
+
prompt = route.send;
|
|
86
|
+
const isRunning = running() && !!session();
|
|
87
|
+
const delivery = route.delivery || (isRunning ? chosen || armed : null);
|
|
88
|
+
const hint = historyEl.querySelector(".hint");
|
|
89
|
+
if (hint) hint.remove();
|
|
90
|
+
// Sending moves a finished turn out of the stage at once (else the echo
|
|
91
|
+
// would land among its rows); into a running one it is a queued row there.
|
|
92
|
+
turnDom.handOff?.({ immediate: true });
|
|
93
|
+
const sending = composer.chips().slice();
|
|
94
|
+
// Into a running turn the echo says what it waits for (its delivery); a
|
|
95
|
+
// line with images always waits for the turn's end (the worker queues it).
|
|
96
|
+
const shownDelivery = isRunning ? (sending.length ? "queue" : delivery || "next_step") : null;
|
|
97
|
+
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
|
|
98
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
99
|
+
if (!state().firstPreview || state().firstPreview === "—") {
|
|
100
|
+
state().firstPreview = firstPreview(prompt);
|
|
101
|
+
updateInfoBar();
|
|
102
|
+
}
|
|
103
|
+
actionBtn.disabled = true;
|
|
104
|
+
// The session the line goes to: the page may switch to another while an
|
|
105
|
+
// image uploads or the turn is sent, and what follows stays with this one.
|
|
106
|
+
const sentTo = session();
|
|
107
|
+
try {
|
|
108
|
+
await uploadChips(sending, sentTo, { upload: api.uploadImage });
|
|
109
|
+
const ack = await api.sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
|
|
110
|
+
if (ack?.command_id) {
|
|
111
|
+
// The worker knew it as a command (its list was newer than ours):
|
|
112
|
+
// its command_ran shows it, not a prompt bubble.
|
|
113
|
+
echo.remove();
|
|
114
|
+
composer.clear();
|
|
115
|
+
if (sentTo === session()) followWokenWorker();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
119
|
+
// A cut the worker didn't take (no thinking-only step: cut off) goes in
|
|
120
|
+
// at the next step; a queue whose turn ended meanwhile is the next turn.
|
|
121
|
+
if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
|
|
122
|
+
setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
|
|
123
|
+
}
|
|
124
|
+
refreshHistory();
|
|
125
|
+
// A turn that failed fast was restored before this ack: its prompt
|
|
126
|
+
// (and images) go back into the composer, without replacing text typed
|
|
127
|
+
// since (4.13). The composer still holds the text just sent: it goes.
|
|
128
|
+
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
129
|
+
// A restored line must be seen: the composer opens for this visit.
|
|
130
|
+
if (restored?.refill && sentTo === session()) composer.openForVisit();
|
|
131
|
+
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
132
|
+
composer.fit();
|
|
133
|
+
// Only the sent chips go: images added since stay (they go up at their own send).
|
|
134
|
+
const kept = chipsAfterSend(composer.chips(), sending);
|
|
135
|
+
composer.setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
|
|
136
|
+
// Its turn_enqueued usually tagged the echo already; the ack covers a
|
|
137
|
+
// worker that wasn't live (no turn_enqueued then).
|
|
138
|
+
if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
|
|
139
|
+
echo.dataset.enqueuedId = ack.enqueued_id;
|
|
140
|
+
}
|
|
141
|
+
// A live stream carries this turn already; restarting it would drop the
|
|
142
|
+
// events in between. Without one, POST /turn just woke a worker: its
|
|
143
|
+
// events start after the seq the session was rendered at (0 for a new
|
|
144
|
+
// worker process). A switch since then opened the other session's own.
|
|
145
|
+
if (sentTo === session()) followWokenWorker();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
echo.remove();
|
|
148
|
+
// The turn never reached the worker's queue: an early restore waiting
|
|
149
|
+
// for its ack is never taken back (4.13), so drop it here. The prompt
|
|
150
|
+
// is still in the composer, as the echo was removed.
|
|
151
|
+
dropEarlyRestores(earlyRestores);
|
|
152
|
+
// The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
|
|
153
|
+
// image refused (bad_image, too_large, bad_images) says why below.
|
|
154
|
+
if (e.code === "owned_by_tui") {
|
|
155
|
+
if (sentTo !== session()) return toast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
|
|
156
|
+
state().owner = "tui";
|
|
157
|
+
updateComposerMode();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// A stale tab sent into a session deleted meanwhile: the start page
|
|
161
|
+
// says so, and the line stays in the composer.
|
|
162
|
+
if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
|
|
163
|
+
// A /queue to a worker that can't queue (it would steer): its words, the text kept.
|
|
164
|
+
if (e.code === "delivery_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
|
|
165
|
+
toast(cantText("send", e), null, { sticky: true });
|
|
166
|
+
} finally {
|
|
167
|
+
actionBtn.disabled = state().owner === "tui";
|
|
168
|
+
composer.focus();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { send };
|
|
173
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
|
|
2
|
+
import { cantText, deleteConfirmText, goneSessionNotice } from "./format.js";
|
|
3
|
+
import { sessionHash } from "./route.js";
|
|
4
|
+
import { inTree } from "./sessions_list.js";
|
|
5
|
+
|
|
6
|
+
// What the page does to a whole session (used by app.js): Continue, Archive
|
|
7
|
+
// and Unarchive, Delete and Stop, from the info bar's buttons, a card's or a
|
|
8
|
+
// batch; and the start page when the open session is gone.
|
|
9
|
+
//
|
|
10
|
+
// Hooks: +selected()+ the open session's id or null; +state()+ its fields
|
|
11
|
+
// (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
|
|
12
|
+
// +api+: {continueSession, archiveSession, unarchiveSession, deleteSession,
|
|
13
|
+
// stopSession} (data.js); +eventsLive()+ the hub's stream is open (else
|
|
14
|
+
// +refresh()+ fetches the list); +lists+ (lists_view.js: render);
|
|
15
|
+
// +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
|
|
16
|
+
// +toast(text, action, options)+, +addCreatedSession(created)+,
|
|
17
|
+
// +fillOpener(created)+ (the new link's composer), +closeLiveStream()+ (the live
|
|
18
|
+
// stream's close, the stream left as it is) and +workerGone(id)+ the page's.
|
|
19
|
+
// +alert+ / +confirm+: the browser's.
|
|
20
|
+
export function createSessionActions({
|
|
21
|
+
selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
|
|
22
|
+
select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, closeLiveStream, workerGone,
|
|
23
|
+
alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
|
|
24
|
+
}) {
|
|
25
|
+
// The session this tab is stopping: its stream may close before the stop
|
|
26
|
+
// request returns (workerGone reads it).
|
|
27
|
+
let stoppingId = null;
|
|
28
|
+
|
|
29
|
+
// Delete a session (the info bar's button, an all-sessions card's ✕) after
|
|
30
|
+
// a confirm; the list drops it without a reload. The server stops a live
|
|
31
|
+
// worker first and refuses a session a terminal chi has open.
|
|
32
|
+
async function confirmDelete(id) {
|
|
33
|
+
const sess = sessions().find((s) => s.id === id) || { id };
|
|
34
|
+
if (sess.owner === "tui") {
|
|
35
|
+
alert(`Session ${id.slice(0, 8)} is open in a terminal chi; close it there first.`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!confirm(deleteConfirmText(sess))) return;
|
|
39
|
+
try {
|
|
40
|
+
await api.deleteSession(id);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
alert(`Could not delete session ${id.slice(0, 8)}: ${e.message}`);
|
|
43
|
+
if (!eventsLive()) refresh();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setSessions(sessions().filter((s) => s.id !== id));
|
|
47
|
+
if (selected() === id) clearSelection();
|
|
48
|
+
lists.render();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The open session no longer exists: back to the start page, saying why.
|
|
52
|
+
function leaveGoneSession(id, why) {
|
|
53
|
+
if (selected() !== id) return;
|
|
54
|
+
clearSelection();
|
|
55
|
+
showNotice(goneSessionNotice(id, why));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The info bar's Stop: the open session's worker, after a confirm.
|
|
59
|
+
async function stopSelected() {
|
|
60
|
+
if (!selected()) return;
|
|
61
|
+
if (!confirm(`Stop session ${selected()}?`)) return;
|
|
62
|
+
const id = selected();
|
|
63
|
+
stoppingId = id;
|
|
64
|
+
try {
|
|
65
|
+
await api.stopSession(id);
|
|
66
|
+
if (id === selected()) stoppedWorker(id);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// A refusal (a terminal holds it) or a worker that won't stop: said, as
|
|
69
|
+
// a failed restart is.
|
|
70
|
+
toast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
|
|
71
|
+
} finally {
|
|
72
|
+
stoppingId = null;
|
|
73
|
+
}
|
|
74
|
+
// The list hears of the stop from the hub (the stop route rescans it).
|
|
75
|
+
if (!eventsLive()) refresh();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Continue (session chains) ─────────────────────────────────────────────
|
|
79
|
+
// Continue → starts the next link of the open session's chain (the server
|
|
80
|
+
// waits up to 30 s for its recap, archives it, starts the new one with the
|
|
81
|
+
// recap as a note) and opens it with the opener in the composer, not sent
|
|
82
|
+
// (fillOpener: the previous link's own first message when it continues one).
|
|
83
|
+
// A link continued already opens that next link; its open delegates move
|
|
84
|
+
// to the new link (the toast counts them); a refusal for open delegates
|
|
85
|
+
// that can't move names them, each a link.
|
|
86
|
+
// One continue per session at a time (the server locks it too); another
|
|
87
|
+
// session's continue may run beside it.
|
|
88
|
+
const continuingIds = new Set();
|
|
89
|
+
|
|
90
|
+
async function continueSelected() {
|
|
91
|
+
if (!selected() || continuingIds.has(selected())) return;
|
|
92
|
+
const id = selected();
|
|
93
|
+
const short = id.slice(0, 8);
|
|
94
|
+
continuingIds.add(id);
|
|
95
|
+
updateInfoBar();
|
|
96
|
+
try {
|
|
97
|
+
const created = await api.continueSession(id);
|
|
98
|
+
markArchived([id], true);
|
|
99
|
+
addCreatedSession(created);
|
|
100
|
+
const next = created.id;
|
|
101
|
+
// A model fallback's warning stays until closed.
|
|
102
|
+
const sticky = !!created.model_warning;
|
|
103
|
+
if (selected() !== id) {
|
|
104
|
+
toast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await select(next);
|
|
108
|
+
if (selected() === next) fillOpener(created);
|
|
109
|
+
toast(continuedText(id, created), null, { sticky });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const refusal = continueRefusal(e, sessions());
|
|
112
|
+
if (refusal.kind === "open") {
|
|
113
|
+
if (selected() === id) await select(refusal.id);
|
|
114
|
+
toast(`${short} is continued already: ${selected() === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
|
|
115
|
+
} else if (refusal.kind === "children") {
|
|
116
|
+
toast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
|
|
117
|
+
} else {
|
|
118
|
+
toast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
continuingIds.delete(id);
|
|
122
|
+
updateInfoBar();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Archive ───────────────────────────────────────────────────────────────
|
|
127
|
+
// Archive hides a session (and its delegates) from the strip and the list,
|
|
128
|
+
// kept for good; "include archived" in all sessions finds it. No confirm:
|
|
129
|
+
// the toast's Undo takes it back. Archiving the open session stays on it
|
|
130
|
+
// (the button flips to unarchive); its worker was stopped by the server.
|
|
131
|
+
|
|
132
|
+
function selectedArchived() {
|
|
133
|
+
return !!sessions().find((s) => s.id === selected())?.archived;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The ids in the server's answer flip here at once; the hub's events
|
|
137
|
+
// follow with the same.
|
|
138
|
+
function markArchived(ids, archived) {
|
|
139
|
+
const set = new Set(ids);
|
|
140
|
+
setSessions(sessions().map((s) => (set.has(s.id) ? { ...s, archived } : s)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The open session's worker was stopped from here (the stop button, an
|
|
144
|
+
// archive): don't wait for its stream to notice.
|
|
145
|
+
function stoppedWorker(id) {
|
|
146
|
+
closeLiveStream();
|
|
147
|
+
state().status = "stopped";
|
|
148
|
+
workerGone(id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// One archive / unarchive request, the core the info bar, a card's button
|
|
152
|
+
// and a batch share. The ids in the answer flip in the list at once. When
|
|
153
|
+
// the open session's worker was stopped (it, or a delegate of the archived
|
|
154
|
+
// one), its stream ending is a stop, not a lost worker.
|
|
155
|
+
// @return {ok: true, result} | {ok: false, error}
|
|
156
|
+
async function archiveOne(id, archive) {
|
|
157
|
+
// A live worker in the tree is stopped by the archive: the open
|
|
158
|
+
// session's stream ending then is a stop.
|
|
159
|
+
const open = selected();
|
|
160
|
+
const mine = archive && open && inTree(sessions(), id, open) ? open : null;
|
|
161
|
+
if (mine) stoppingId = mine;
|
|
162
|
+
try {
|
|
163
|
+
const result = archive ? await api.archiveSession(id) : await api.unarchiveSession(id);
|
|
164
|
+
markArchived(archive ? result.archived : result.unarchived, archive);
|
|
165
|
+
if (mine && mine === selected() && (result.stopped || []).includes(mine)) stoppedWorker(mine);
|
|
166
|
+
return { ok: true, result };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { ok: false, error };
|
|
169
|
+
} finally {
|
|
170
|
+
// Another call may have taken it meanwhile: clear only our own.
|
|
171
|
+
if (mine && stoppingId === mine) stoppingId = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function setArchived(id, archive, { undo = true } = {}) {
|
|
176
|
+
const short = id.slice(0, 8);
|
|
177
|
+
const answer = await archiveOne(id, archive);
|
|
178
|
+
if (!answer.ok) {
|
|
179
|
+
alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
lists.render();
|
|
183
|
+
updateInfoBar();
|
|
184
|
+
if (!eventsLive()) refresh();
|
|
185
|
+
const what = archive ? "Archived" : "Unarchived";
|
|
186
|
+
toast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The info bar's Archive / Unarchive: the open session's, by its state.
|
|
190
|
+
function toggleArchiveSelected() {
|
|
191
|
+
if (!selected()) return;
|
|
192
|
+
if (selectedArchived()) setArchived(selected(), false);
|
|
193
|
+
else setArchived(selected(), true);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
confirmDelete, leaveGoneSession, stopSelected, continueSelected, archiveOne, setArchived, toggleArchiveSelected,
|
|
198
|
+
// The sessions whose Continue is under way (the info bar's button).
|
|
199
|
+
continuing: continuingIds,
|
|
200
|
+
// The session this tab is stopping, or null.
|
|
201
|
+
stoppingId: () => stoppingId,
|
|
202
|
+
};
|
|
203
|
+
}
|