samagotchi 0.77.0 → 0.79.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 +40 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +14 -8
- data/docs/internals/anthropic.md +80 -7
- data/docs/internals/engine.md +3 -2
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +2 -2
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +2 -2
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +74 -17
- data/lib/samagotchi/llm/anthropic_wire.rb +29 -1
- data/lib/samagotchi/llm/chat_loop.rb +61 -5
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/question_desk.rb +10 -3
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +2 -3
- data/lib/samagotchi/session_manager.rb +0 -1
- data/lib/samagotchi/session_metrics/totals.rb +4 -3
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +61 -53
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/index.html +27 -3
- data/lib/samagotchi/web/public/model_picker.js +4 -5
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/toasts.js +5 -4
- data/lib/samagotchi/worker.rb +1 -0
- metadata +5 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { GRIP_OPEN_PX, gripFloor, gripFolds } from "./composer_size.js";
|
|
2
|
+
import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
|
|
3
|
+
import { readStored, removeStored, writeStored } from "./storage.js";
|
|
4
|
+
|
|
5
|
+
// The composer's grip and fold (used by app.js): the grip's drag, tap and keys, the
|
|
6
|
+
// fold's state per visit and its page-wide listeners. app.js keeps the box's
|
|
7
|
+
// height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
|
|
8
|
+
// fitPrompt reads; the floor is saved under GRIP_KEY.
|
|
9
|
+
export const GRIP_KEY = "chi_composer_floor";
|
|
10
|
+
|
|
11
|
+
// +session+: the open session's id, or null (the start page). +draft+,
|
|
12
|
+
// +waiting+, +turnRunning+, +allViewOpen+, +lightboxOpen+: what the page
|
|
13
|
+
// shows now. +imageFiles+ / +addImageFiles+: a drop's images and the chips.
|
|
14
|
+
export function createComposerGrip({
|
|
15
|
+
composeGrip, promptEl, infoCancelBtn, infoBarEl,
|
|
16
|
+
session, fitPrompt, promptMinPx, floor, setFloor,
|
|
17
|
+
draft, waiting, turnRunning, allViewOpen, lightboxOpen,
|
|
18
|
+
imageFiles, addImageFiles,
|
|
19
|
+
doc = globalThis.document, win = globalThis.window,
|
|
20
|
+
}) {
|
|
21
|
+
// The composer's fold (composer_fold.js): the grip, which is the handle
|
|
22
|
+
// while folded, and the info bar's Stop turn while folded (the composer's
|
|
23
|
+
// #cancelBtn is folded away then). +gripWillFold+: the drag in progress is
|
|
24
|
+
// past the fold threshold.
|
|
25
|
+
const gripStartTitle = composeGrip.title;
|
|
26
|
+
let gripWillFold = false;
|
|
27
|
+
// This visit's state (a visit is one select() call): opened by the page
|
|
28
|
+
// (a focus, a restored line), archived (decided once, from the visit's
|
|
29
|
+
// first getSession answer).
|
|
30
|
+
const newFoldVisit = () => ({ open: false, archived: false, decided: false });
|
|
31
|
+
let foldVisit = newFoldVisit();
|
|
32
|
+
|
|
33
|
+
// A drag down past the box folds the composer on release (a session only: the
|
|
34
|
+
// start page's grip only resizes); from folded, a pull up opens it and the box
|
|
35
|
+
// follows the pointer in the same gesture, a tap opens it and focuses the box.
|
|
36
|
+
// No click listener: a click still fires after pointerdown's preventDefault
|
|
37
|
+
// and would toggle twice.
|
|
38
|
+
composeGrip.addEventListener("pointerdown", (e) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
composeGrip.setPointerCapture(e.pointerId);
|
|
41
|
+
composeGrip.classList.add("dragging");
|
|
42
|
+
const tapY = e.clientY;
|
|
43
|
+
let startY = tapY;
|
|
44
|
+
let startH = promptEl.offsetHeight;
|
|
45
|
+
let opened = false;
|
|
46
|
+
const move = (ev) => {
|
|
47
|
+
if (session() && isComposerFolded()) {
|
|
48
|
+
if (startY - ev.clientY < GRIP_OPEN_PX) return;
|
|
49
|
+
setComposerFolded(false);
|
|
50
|
+
opened = true;
|
|
51
|
+
// Re-based after the unfold (its fitPrompt sized the box: the minimum
|
|
52
|
+
// when empty, the text's height with a draft).
|
|
53
|
+
startH = promptEl.offsetHeight;
|
|
54
|
+
startY = ev.clientY;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const requested = startH + (startY - ev.clientY);
|
|
58
|
+
setFloor(gripFloor(requested, win.innerHeight, promptMinPx()));
|
|
59
|
+
fitPrompt();
|
|
60
|
+
const willFold = !!session() && gripFolds({ requestedPx: requested, shownPx: promptEl.offsetHeight });
|
|
61
|
+
if (willFold !== gripWillFold) {
|
|
62
|
+
gripWillFold = willFold;
|
|
63
|
+
composeGrip.classList.toggle("will-fold", willFold);
|
|
64
|
+
drawFoldControls();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const end = () => {
|
|
68
|
+
composeGrip.classList.remove("dragging");
|
|
69
|
+
composeGrip.removeEventListener("pointermove", move);
|
|
70
|
+
composeGrip.removeEventListener("pointerup", up);
|
|
71
|
+
composeGrip.removeEventListener("pointercancel", cancel);
|
|
72
|
+
const folds = gripWillFold;
|
|
73
|
+
gripWillFold = false;
|
|
74
|
+
composeGrip.classList.remove("will-fold");
|
|
75
|
+
return folds;
|
|
76
|
+
};
|
|
77
|
+
const up = (ev) => {
|
|
78
|
+
const folds = end();
|
|
79
|
+
if (folds) {
|
|
80
|
+
// The next opening starts at the minimum (or the text's height).
|
|
81
|
+
setFloor(null);
|
|
82
|
+
removeStored(GRIP_KEY);
|
|
83
|
+
setComposerFolded(true);
|
|
84
|
+
} else if (session() && isComposerFolded()) {
|
|
85
|
+
if (Math.abs(ev.clientY - tapY) < GRIP_OPEN_PX) {
|
|
86
|
+
setComposerFolded(false);
|
|
87
|
+
promptEl.focus();
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
writeStored(GRIP_KEY, String(floor() || ""));
|
|
91
|
+
// A pull that opened the box leaves the focus in it, as a tap does.
|
|
92
|
+
if (opened) promptEl.focus();
|
|
93
|
+
}
|
|
94
|
+
drawFoldControls();
|
|
95
|
+
};
|
|
96
|
+
const cancel = () => {
|
|
97
|
+
end();
|
|
98
|
+
drawFoldControls();
|
|
99
|
+
};
|
|
100
|
+
composeGrip.addEventListener("pointermove", move);
|
|
101
|
+
composeGrip.addEventListener("pointerup", up);
|
|
102
|
+
composeGrip.addEventListener("pointercancel", cancel);
|
|
103
|
+
});
|
|
104
|
+
composeGrip.addEventListener("dblclick", () => {
|
|
105
|
+
if (session() && isComposerFolded()) return;
|
|
106
|
+
setFloor(null);
|
|
107
|
+
removeStored(GRIP_KEY);
|
|
108
|
+
fitPrompt();
|
|
109
|
+
});
|
|
110
|
+
// Enter or Space on the focused handle folds, or opens and focuses the box
|
|
111
|
+
// (a session only; preventDefault: Space would scroll the page).
|
|
112
|
+
composeGrip.addEventListener("keydown", (e) => {
|
|
113
|
+
if (!session() || (e.key !== "Enter" && e.key !== " ")) return;
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
if (isComposerFolded()) {
|
|
116
|
+
setComposerFolded(false);
|
|
117
|
+
promptEl.focus();
|
|
118
|
+
} else {
|
|
119
|
+
setComposerFolded(true);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Composer fold (composer_fold.js) ───────────────────────────────────────
|
|
124
|
+
// Dragging the composer's grip down folds the session's composer away; the
|
|
125
|
+
// grip, now the handle on the info bar's top edge, pulled up or clicked, or
|
|
126
|
+
// typing on the page, brings it back. The user's own choice is saved per
|
|
127
|
+
// browser; an archived session opens folded for its visit, and expanding
|
|
128
|
+
// there saves nothing. The page's own focus or a restored line opens it
|
|
129
|
+
// for the visit only. The start page never folds.
|
|
130
|
+
function composerFoldSaved() {
|
|
131
|
+
return readStored(COMPOSER_FOLD_KEY) === "1";
|
|
132
|
+
}
|
|
133
|
+
function isComposerFolded() {
|
|
134
|
+
return !!session() && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open });
|
|
135
|
+
}
|
|
136
|
+
function applyComposerFolded() {
|
|
137
|
+
const folded = isComposerFolded();
|
|
138
|
+
const was = doc.body.classList.contains("composer-folded");
|
|
139
|
+
doc.body.classList.toggle("composer-folded", folded);
|
|
140
|
+
// Its height was measured while hidden.
|
|
141
|
+
if (was && !folded) fitPrompt();
|
|
142
|
+
drawFoldControls();
|
|
143
|
+
}
|
|
144
|
+
// The user's own fold or unfold (the grip, typing): saved, unless this
|
|
145
|
+
// is an archived visit (both are for the visit then).
|
|
146
|
+
function setComposerFolded(folded) {
|
|
147
|
+
if (foldVisit.archived) {
|
|
148
|
+
foldVisit.open = !folded;
|
|
149
|
+
} else {
|
|
150
|
+
writeStored(COMPOSER_FOLD_KEY, folded ? "1" : "0");
|
|
151
|
+
foldVisit.open = false;
|
|
152
|
+
}
|
|
153
|
+
applyComposerFolded();
|
|
154
|
+
}
|
|
155
|
+
// The page opens it for this visit: nothing saved.
|
|
156
|
+
function openForVisit() {
|
|
157
|
+
if (!isComposerFolded()) return;
|
|
158
|
+
foldVisit.open = true;
|
|
159
|
+
applyComposerFolded();
|
|
160
|
+
}
|
|
161
|
+
// Every focus of the message box: in a session it opens a folded composer
|
|
162
|
+
// for the visit first (+open+ false: only when it shows already).
|
|
163
|
+
function focusPrompt({ open = true } = {}) {
|
|
164
|
+
if (session() && isComposerFolded()) {
|
|
165
|
+
if (!open) return;
|
|
166
|
+
openForVisit();
|
|
167
|
+
}
|
|
168
|
+
promptEl.focus();
|
|
169
|
+
}
|
|
170
|
+
// The grip as the fold's handle (its title, marks, aria-expanded) and Stop turn
|
|
171
|
+
// while folded. The start page's grip only resizes: no role, no fold state.
|
|
172
|
+
function drawFoldControls() {
|
|
173
|
+
const folded = isComposerFolded();
|
|
174
|
+
infoCancelBtn.style.display = folded && turnRunning() ? "" : "none";
|
|
175
|
+
if (!session()) {
|
|
176
|
+
composeGrip.removeAttribute("role");
|
|
177
|
+
composeGrip.removeAttribute("aria-expanded");
|
|
178
|
+
composeGrip.title = gripStartTitle;
|
|
179
|
+
for (const c of ["folded", "draft", "waiting"]) composeGrip.classList.remove(c);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const h = foldHandle({ folded, draft: draft(), waiting: folded && waiting() });
|
|
183
|
+
composeGrip.title = gripWillFold ? "Release to hide the message box" : h.title;
|
|
184
|
+
composeGrip.setAttribute("role", "button");
|
|
185
|
+
composeGrip.setAttribute("aria-expanded", folded ? "false" : "true");
|
|
186
|
+
composeGrip.classList.toggle("folded", h.folded);
|
|
187
|
+
composeGrip.classList.toggle("draft", h.draft);
|
|
188
|
+
composeGrip.classList.toggle("waiting", h.waiting);
|
|
189
|
+
}
|
|
190
|
+
promptEl.addEventListener("input", () => drawFoldControls());
|
|
191
|
+
// Typing on the page opens a folded composer: the key lands in the box
|
|
192
|
+
// (the class goes before the focus, so the browser types it there).
|
|
193
|
+
doc.addEventListener("keydown", (e) => {
|
|
194
|
+
if (!isComposerFolded()) return;
|
|
195
|
+
if (!opensOnKey(e, { active: doc.activeElement, allView: allViewOpen(), lightbox: lightboxOpen() })) return;
|
|
196
|
+
setComposerFolded(false);
|
|
197
|
+
promptEl.focus();
|
|
198
|
+
});
|
|
199
|
+
// A file dropped on the folded info bar: open (for the visit), then add it.
|
|
200
|
+
infoBarEl.addEventListener("dragover", (e) => {
|
|
201
|
+
if (!isComposerFolded() || ![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
});
|
|
204
|
+
infoBarEl.addEventListener("drop", (e) => {
|
|
205
|
+
if (!isComposerFolded()) return;
|
|
206
|
+
const files = imageFiles(e.dataTransfer);
|
|
207
|
+
if (!files.length) return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
openForVisit();
|
|
210
|
+
addImageFiles(files);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
isComposerFolded,
|
|
215
|
+
openForVisit,
|
|
216
|
+
focusPrompt,
|
|
217
|
+
drawFoldControls,
|
|
218
|
+
// A new visit (select(), the start page): nothing opened, not decided.
|
|
219
|
+
newVisit() {
|
|
220
|
+
foldVisit = newFoldVisit();
|
|
221
|
+
applyComposerFolded();
|
|
222
|
+
},
|
|
223
|
+
// The visit's first getSession answer: an archived session opens folded.
|
|
224
|
+
decideVisit(archived) {
|
|
225
|
+
if (foldVisit.decided) return;
|
|
226
|
+
foldVisit.decided = true;
|
|
227
|
+
foldVisit.archived = !!archived;
|
|
228
|
+
applyComposerFolded();
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -4,6 +4,8 @@ export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the st
|
|
|
4
4
|
export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
|
|
5
5
|
const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
|
|
6
6
|
const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
|
|
7
|
+
export const GRIP_FOLD_PX = 32; // a drag down this far past what the box shows folds the composer (on release)
|
|
8
|
+
export const GRIP_OPEN_PX = 8; // a drag up this far from the folded handle opens it; less is a tap
|
|
7
9
|
|
|
8
10
|
// contentPx: the text's height (scrollHeight plus borders); floorPx: the
|
|
9
11
|
// grip's height, or null; minPx: the empty box's height.
|
|
@@ -18,3 +20,11 @@ export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PR
|
|
|
18
20
|
export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
|
|
19
21
|
return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
|
|
20
22
|
}
|
|
23
|
+
|
|
24
|
+
// Whether a grip drag asks to fold the composer: it wants the box at least
|
|
25
|
+
// GRIP_FOLD_PX less than the box actually shows (+shownPx+, after the box
|
|
26
|
+
// fitted itself: with text in it the content sets the height, so measuring
|
|
27
|
+
// against the floor would fold a tall box on a short pull).
|
|
28
|
+
export function gripFolds({ requestedPx, shownPx }) {
|
|
29
|
+
return shownPx - requestedPx >= GRIP_FOLD_PX;
|
|
30
|
+
}
|
|
@@ -590,10 +590,25 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
|
|
|
590
590
|
#lightbox[hidden]{display:none}
|
|
591
591
|
#lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
|
|
592
592
|
#composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
|
|
593
|
-
#composeGrip{position:
|
|
593
|
+
#composeGrip{position:relative;height:10px;margin:-5px 0;cursor:ns-resize;z-index:2;touch-action:none}
|
|
594
594
|
#composeGrip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
|
|
595
|
-
#
|
|
595
|
+
#composeGrip:hover::after,#composeGrip:has(+ #composer:hover)::after,#composeGrip.dragging::after{opacity:1}
|
|
596
596
|
#composeGrip:hover::after,#composeGrip.dragging::after{background:var(--accent)}
|
|
597
|
+
/* Folded (composer_grip.js, composer_fold.js): the grip is the handle on the info bar's top edge, drawn always, wider so it reads as a control;
|
|
598
|
+
draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
|
|
599
|
+
body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
|
|
600
|
+
#composeGrip.draft::after{background:var(--accent)}
|
|
601
|
+
#composeGrip.waiting::after{background:var(--warn)}
|
|
602
|
+
#composeGrip.will-fold::after{opacity:1;background:var(--accent)}
|
|
603
|
+
#composeGrip:focus-visible{outline:none}
|
|
604
|
+
#composeGrip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
|
|
605
|
+
/* A phone has no hover: the pill is always there and the hit zone is taller, ~96 px wide and centred (full width would cover the
|
|
606
|
+
info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
|
|
607
|
+
border and padding, so it never covers a button of the bar. */
|
|
608
|
+
@media(max-width:600px){
|
|
609
|
+
#composeGrip{width:96px;height:26px;margin:-19px auto -7px}
|
|
610
|
+
#composeGrip::after{top:17px;opacity:1}
|
|
611
|
+
}
|
|
597
612
|
#composer textarea::placeholder{color:var(--text-faint)}
|
|
598
613
|
/* Key hint: its line is always reserved, so focusing never shifts the layout. */
|
|
599
614
|
#composeHint{order:1;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);padding-left:2px;opacity:0;transition:opacity .12s ease;user-select:none}
|
|
@@ -851,6 +866,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
851
866
|
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
852
867
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
853
868
|
#infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
869
|
+
/* The composer folded away (composer_grip.js: the grip dragged down, Enter/Space on it): the info bar is the page's bottom edge.
|
|
870
|
+
Stop turn comes into the bar while folded and a turn runs. */
|
|
871
|
+
body.has-session.composer-folded #composer,body.has-session.composer-folded #initRow{display:none}
|
|
872
|
+
body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
873
|
+
#infoBar #infoCancelBtn{background:var(--bg-raised);color:var(--danger-text);border:1px solid color-mix(in srgb,var(--danger) 55%,transparent)}
|
|
874
|
+
#infoBar #infoCancelBtn:hover:not(:disabled){color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
854
875
|
/* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
|
|
855
876
|
#infoBar .meta .llmctx-chip,#infoBar .meta .think-chip,#infoBar .meta .rh-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
|
|
856
877
|
#infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover,#infoBar .meta .rh-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
@@ -932,6 +953,8 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
932
953
|
#infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
|
|
933
954
|
#infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
|
|
934
955
|
#infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
|
|
956
|
+
#infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
|
|
957
|
+
#infoBar #infoCancelBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5'/%3E%3C/svg%3E")}
|
|
935
958
|
#infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
|
|
936
959
|
#infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
|
|
937
960
|
}
|
|
@@ -1208,13 +1231,13 @@ a{color:var(--accent)}
|
|
|
1208
1231
|
<div id="lightbox" hidden><img alt=""></div>
|
|
1209
1232
|
<div id="dock">
|
|
1210
1233
|
<div id="initRow" role="status" aria-live="polite" hidden></div>
|
|
1234
|
+
<div id="composeGrip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1211
1235
|
<div id="composer" style="position:relative">
|
|
1212
1236
|
<div id="commandList" role="listbox" aria-label="Commands" hidden></div>
|
|
1213
1237
|
<div id="modelPanel" hidden>
|
|
1214
1238
|
<label class="model-search"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14" stroke-linecap="round"/></svg><input id="modelSearch" type="text" placeholder="Search models" autocomplete="off" spellcheck="false" role="combobox" aria-label="Search models" aria-autocomplete="list" aria-controls="modelList" aria-expanded="false"></label>
|
|
1215
1239
|
<div id="modelList" role="listbox" aria-label="Models"></div>
|
|
1216
1240
|
</div>
|
|
1217
|
-
<div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1218
1241
|
<div id="deliveryLine" aria-live="off" hidden></div>
|
|
1219
1242
|
<div class="compose-col">
|
|
1220
1243
|
<div id="chips" hidden></div>
|
|
@@ -1234,6 +1257,7 @@ a{color:var(--accent)}
|
|
|
1234
1257
|
<div id="infoBar">
|
|
1235
1258
|
<div class="info" id="infoText"></div>
|
|
1236
1259
|
<div id="contextChips" aria-label="Attached context" hidden></div>
|
|
1260
|
+
<button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
1237
1261
|
<button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
|
|
1238
1262
|
<button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
|
|
1239
1263
|
<button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
|
|
11
11
|
|
|
12
12
|
import { moveSelection } from "./command_complete.js";
|
|
13
|
+
import { readStored, writeStored } from "./storage.js";
|
|
13
14
|
import {
|
|
14
15
|
MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
|
|
15
16
|
} from "./model_pick.js";
|
|
@@ -39,7 +40,7 @@ function headTemplate(label, note = "") {
|
|
|
39
40
|
|
|
40
41
|
function readRecent() {
|
|
41
42
|
try {
|
|
42
|
-
const list = JSON.parse(
|
|
43
|
+
const list = JSON.parse(readStored(RECENT_KEY) || "[]");
|
|
43
44
|
return Array.isArray(list) ? list : [];
|
|
44
45
|
} catch (_) {
|
|
45
46
|
return [];
|
|
@@ -163,10 +164,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
163
164
|
if (!row) return;
|
|
164
165
|
setChosen(row.name);
|
|
165
166
|
if (remember()) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
localStorage.setItem(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
|
|
169
|
-
} catch (_) {}
|
|
167
|
+
writeStored(MODEL_KEY, row.name);
|
|
168
|
+
writeStored(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
|
|
170
169
|
}
|
|
171
170
|
close();
|
|
172
171
|
onPick?.(row.name);
|
|
@@ -22,6 +22,7 @@ import { createStageFrame, reducedMotion } from "./stage_frame.js";
|
|
|
22
22
|
import { createInUseWatch } from "./stage_watch.js";
|
|
23
23
|
import { formatDuration } from "./timing.js";
|
|
24
24
|
import { easeScrollTo } from "./scroll.js";
|
|
25
|
+
import { readStored, writeStored } from "./storage.js";
|
|
25
26
|
|
|
26
27
|
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
27
28
|
// The turn-end read (markdown, answer_display) settles within this, or the
|
|
@@ -41,7 +42,7 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
|
|
|
41
42
|
const ASKS = ".plugin-card[data-in-turn] .card-action";
|
|
42
43
|
|
|
43
44
|
function readCollapsed() {
|
|
44
|
-
|
|
45
|
+
return readStored(COLLAPSE_KEY) === "1";
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// +historyNearBottom+: the history's own follow check (the hand-off scrolls
|
|
@@ -289,7 +290,7 @@ export function createStageView({
|
|
|
289
290
|
function setCollapsed(value) {
|
|
290
291
|
collapsed = value;
|
|
291
292
|
revealed = false;
|
|
292
|
-
|
|
293
|
+
writeStored(COLLAPSE_KEY, value ? "1" : "0");
|
|
293
294
|
applyCollapsed();
|
|
294
295
|
}
|
|
295
296
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The page's per-browser choices in localStorage, guarded: storage can be
|
|
2
|
+
// missing or throw (a private window, blocked site data, a full quota), and
|
|
3
|
+
// a choice that can't be read or written is just not remembered. Reads
|
|
4
|
+
// globalThis.localStorage at each call, never at import (the specs install
|
|
5
|
+
// their own).
|
|
6
|
+
|
|
7
|
+
// The stored string, or +fallback+ when there is none or it can't be read.
|
|
8
|
+
export function readStored(key, fallback = null) {
|
|
9
|
+
try {
|
|
10
|
+
const value = globalThis.localStorage.getItem(key);
|
|
11
|
+
return value === null ? fallback : value;
|
|
12
|
+
} catch (_) {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeStored(key, value) {
|
|
18
|
+
try { globalThis.localStorage.setItem(key, value); } catch (_) {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function removeStored(key) {
|
|
22
|
+
try { globalThis.localStorage.removeItem(key); } catch (_) {}
|
|
23
|
+
}
|
|
@@ -37,7 +37,7 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function show(text, action = null, { sticky = false } = {}) {
|
|
40
|
-
const candidate = { el: doc.createElement("div"), timer: null, sticky, key: null };
|
|
40
|
+
const candidate = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
|
|
41
41
|
candidate.el.className = "toast-item";
|
|
42
42
|
draw(candidate, text, action, sticky);
|
|
43
43
|
candidate.key = candidate.el.querySelector(".toast-message").textContent;
|
|
@@ -46,12 +46,13 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
46
46
|
if (duplicate) {
|
|
47
47
|
if (duplicate.timer !== null) clearTimeout(duplicate.timer);
|
|
48
48
|
duplicate.timer = null;
|
|
49
|
-
duplicate.sticky = sticky;
|
|
49
|
+
duplicate.sticky = sticky || duplicate.sticky;
|
|
50
|
+
duplicate.action = action || duplicate.action;
|
|
50
51
|
duplicate.key = candidate.key;
|
|
51
|
-
draw(duplicate, text, action, sticky);
|
|
52
|
+
draw(duplicate, text, duplicate.action, duplicate.sticky);
|
|
52
53
|
entries.push(...entries.splice(entries.indexOf(duplicate), 1));
|
|
53
54
|
el.appendChild(duplicate.el);
|
|
54
|
-
if (!sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
|
|
55
|
+
if (!duplicate.sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
|
|
55
56
|
sync();
|
|
56
57
|
return;
|
|
57
58
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
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.79.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -337,6 +337,7 @@ files:
|
|
|
337
337
|
- lib/samagotchi/llm/adapter_common.rb
|
|
338
338
|
- lib/samagotchi/llm/adapters.rb
|
|
339
339
|
- lib/samagotchi/llm/anthropic_assembly.rb
|
|
340
|
+
- lib/samagotchi/llm/anthropic_binding_recovery.rb
|
|
340
341
|
- lib/samagotchi/llm/anthropic_messages.rb
|
|
341
342
|
- lib/samagotchi/llm/anthropic_wire.rb
|
|
342
343
|
- lib/samagotchi/llm/api_key.rb
|
|
@@ -594,6 +595,8 @@ files:
|
|
|
594
595
|
- lib/samagotchi/web/public/command_view.js
|
|
595
596
|
- lib/samagotchi/web/public/commands.js
|
|
596
597
|
- lib/samagotchi/web/public/composer.js
|
|
598
|
+
- lib/samagotchi/web/public/composer_fold.js
|
|
599
|
+
- lib/samagotchi/web/public/composer_grip.js
|
|
597
600
|
- lib/samagotchi/web/public/composer_size.js
|
|
598
601
|
- lib/samagotchi/web/public/context_chips.js
|
|
599
602
|
- lib/samagotchi/web/public/copy.js
|
|
@@ -646,6 +649,7 @@ files:
|
|
|
646
649
|
- lib/samagotchi/web/public/stage_slots.js
|
|
647
650
|
- lib/samagotchi/web/public/stage_view.js
|
|
648
651
|
- lib/samagotchi/web/public/stage_watch.js
|
|
652
|
+
- lib/samagotchi/web/public/storage.js
|
|
649
653
|
- lib/samagotchi/web/public/stream_handlers.js
|
|
650
654
|
- lib/samagotchi/web/public/strip.js
|
|
651
655
|
- lib/samagotchi/web/public/tally.js
|