samagotchi 0.8.1 → 0.10.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 +89 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +9 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/memory.md +5 -3
- data/docs/plugins.md +47 -17
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/identity.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/targets.rb +7 -1
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/log_subscriber.rb +5 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +25 -30
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +29 -15
- data/lib/samagotchi/session_metrics.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +8 -0
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +2 -13
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +13 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Pure routing for one :generation_chunk event into the two render lanes.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Every :generation_chunk the server sends carries additive `text` (visible
|
|
4
|
+
// prose, thinking + tool_call blocks removed) and `thinking` (thinking-only)
|
|
5
|
+
// fields beside the raw `content`: the native loop splits its stream per
|
|
6
|
+
// generation for every profile (Gemma's thought channel included), the chat
|
|
7
|
+
// loop gets reasoning apart from the answer. We use those fields and ignore raw
|
|
8
|
+
// `content`; a chunk without them (an older server) falls back to raw
|
|
9
|
+
// `content` as text.
|
|
9
10
|
//
|
|
10
11
|
// Presence is checked with `typeof === "string"` (not truthiness) so an empty
|
|
11
12
|
// `text` (e.g. a chunk that is entirely a tool_call body) still selects the new
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ── / autocomplete ─────────────────────────────────────────────────────────
|
|
2
|
+
// While the composer holds one "/word", a small glass list above it offers
|
|
3
|
+
// the session's commands (its worker's, plugins' too): arrows move, ⏎/Tab
|
|
4
|
+
// pick, Esc closes, a tap picks. A closed list takes no keys.
|
|
5
|
+
//
|
|
6
|
+
// The matching is command_complete.js'; this is the list's DOM.
|
|
7
|
+
|
|
8
|
+
import { escapeHtml } from "./format.js";
|
|
9
|
+
import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
|
|
10
|
+
|
|
11
|
+
// @param list the list element (#commandList)
|
|
12
|
+
// @param prompt the composer's textarea
|
|
13
|
+
// @param commands () => the open session's commands ([] when it takes none)
|
|
14
|
+
// @param afterPick () runs after a pick changed the composer's text (fit it)
|
|
15
|
+
// @return {update(), close(), handleKey(e)}; handleKey returns whether the
|
|
16
|
+
// open list took the key
|
|
17
|
+
export function createCommandList({ list, prompt, commands, afterPick = () => {} }) {
|
|
18
|
+
let shown = [];
|
|
19
|
+
let index = 0;
|
|
20
|
+
|
|
21
|
+
const isOpen = () => !list.hidden && shown.length > 0;
|
|
22
|
+
|
|
23
|
+
function update() {
|
|
24
|
+
const next = commandMatches(commands(), prompt.value);
|
|
25
|
+
if (!next.length) return close();
|
|
26
|
+
const keep = shown[index]?.name;
|
|
27
|
+
shown = next;
|
|
28
|
+
index = Math.max(0, next.findIndex((c) => c.name === keep));
|
|
29
|
+
render();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function render() {
|
|
33
|
+
list.innerHTML = shown.map((c, i) => {
|
|
34
|
+
const row = commandRow(c);
|
|
35
|
+
const note = row.note ? `<span class="command-note">${escapeHtml(row.note)}</span>` : "";
|
|
36
|
+
return `<div class="command-option${i === index ? " active" : ""}" role="option" id="command-option-${i}" ` +
|
|
37
|
+
`aria-selected="${i === index}" data-index="${i}"><span class="command-name">${escapeHtml(row.name)}</span>` +
|
|
38
|
+
`<span class="command-desc">${escapeHtml(row.description)}</span>${note}</div>`;
|
|
39
|
+
}).join("");
|
|
40
|
+
list.hidden = false;
|
|
41
|
+
prompt.setAttribute("aria-expanded", "true");
|
|
42
|
+
prompt.setAttribute("aria-activedescendant", `command-option-${index}`);
|
|
43
|
+
list.querySelector(".command-option.active")?.scrollIntoView({ block: "nearest" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function close() {
|
|
47
|
+
shown = [];
|
|
48
|
+
index = 0;
|
|
49
|
+
list.hidden = true;
|
|
50
|
+
list.innerHTML = "";
|
|
51
|
+
prompt.setAttribute("aria-expanded", "false");
|
|
52
|
+
prompt.removeAttribute("aria-activedescendant");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// @return whether the pick took the key (false: ⏎ on a full name sends it)
|
|
56
|
+
function accept(at, { enter = false } = {}) {
|
|
57
|
+
const command = shown[at];
|
|
58
|
+
if (!command) return false;
|
|
59
|
+
const pick = pickCommand(command.name, prompt.value, { enter });
|
|
60
|
+
close();
|
|
61
|
+
if (pick.send) return false;
|
|
62
|
+
prompt.value = pick.text;
|
|
63
|
+
prompt.setSelectionRange(pick.text.length, pick.text.length);
|
|
64
|
+
afterPick();
|
|
65
|
+
prompt.focus();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// @return whether the open list took the key
|
|
70
|
+
function handleKey(e) {
|
|
71
|
+
if (!isOpen() || e.isComposing) return false;
|
|
72
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
73
|
+
index = moveSelection(index, shown.length, e.key === "ArrowDown" ? 1 : -1);
|
|
74
|
+
render();
|
|
75
|
+
} else if (e.key === "Tab" && !e.shiftKey) {
|
|
76
|
+
accept(index);
|
|
77
|
+
} else if (e.key === "Enter" && !e.shiftKey) {
|
|
78
|
+
if (!accept(index, { enter: true })) return false;
|
|
79
|
+
} else if (e.key === "Escape") {
|
|
80
|
+
close();
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
} else {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
prompt.addEventListener("input", update);
|
|
90
|
+
prompt.addEventListener("blur", close);
|
|
91
|
+
// Keep the focus in the composer: the pick happens on click.
|
|
92
|
+
list.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
93
|
+
list.addEventListener("click", (e) => {
|
|
94
|
+
const option = e.target.closest(".command-option");
|
|
95
|
+
if (option) accept(Number(option.dataset.index));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return { update, close, handleKey };
|
|
99
|
+
}
|
|
@@ -1,41 +1,3 @@
|
|
|
1
|
-
const DEFAULT_EVENT_TYPES = Object.freeze([
|
|
2
|
-
"generation_chunk",
|
|
3
|
-
"generation_started",
|
|
4
|
-
"generation_completed",
|
|
5
|
-
"turn_started",
|
|
6
|
-
"turn_completed",
|
|
7
|
-
"answer_display",
|
|
8
|
-
"turn_canceled",
|
|
9
|
-
"turn_failed",
|
|
10
|
-
"turn_enqueued",
|
|
11
|
-
"input_merged",
|
|
12
|
-
"tool_call_completed",
|
|
13
|
-
"tool_call_started",
|
|
14
|
-
"context_status",
|
|
15
|
-
"used_memories_updated",
|
|
16
|
-
"pending_input_merged",
|
|
17
|
-
"reset",
|
|
18
|
-
"question_requested",
|
|
19
|
-
"question_answered",
|
|
20
|
-
"question_cancelled",
|
|
21
|
-
"recap_ready",
|
|
22
|
-
"prompt_restored",
|
|
23
|
-
"command_queued",
|
|
24
|
-
"command_ran",
|
|
25
|
-
"continue_offered",
|
|
26
|
-
"continue_resolved",
|
|
27
|
-
"reminder_injected",
|
|
28
|
-
"context_added",
|
|
29
|
-
"guardrail_warning",
|
|
30
|
-
"hook_notice",
|
|
31
|
-
"empty_answer_retry",
|
|
32
|
-
"generation_retrying",
|
|
33
|
-
"plugin_init_wait",
|
|
34
|
-
"card",
|
|
35
|
-
"plugin_init_started",
|
|
36
|
-
"plugin_init_finished",
|
|
37
|
-
]);
|
|
38
|
-
|
|
39
1
|
export function api(path, opts = {}) {
|
|
40
2
|
const { fetchImpl = globalThis.fetch } = opts;
|
|
41
3
|
return fetchImpl(path, {
|
|
@@ -170,29 +132,36 @@ export function deleteSession(id, opts = {}) {
|
|
|
170
132
|
return api(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", ...opts });
|
|
171
133
|
}
|
|
172
134
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const es = new EventSourceImpl(`/api/sessions/${id}/stream?from_seq=${cursor}`);
|
|
181
|
-
let eventReceived = false;
|
|
182
|
-
for (const type of DEFAULT_EVENT_TYPES) {
|
|
183
|
-
const handler = handlers[type];
|
|
184
|
-
if (!handler) continue;
|
|
135
|
+
// One EventSource listener per handler, named by the handler's key (the
|
|
136
|
+
// event type), so a new event type needs only its handler. Each event's
|
|
137
|
+
// JSON data goes to its handler (badJson(raw) when it doesn't parse);
|
|
138
|
+
// onEvent runs first, for every event.
|
|
139
|
+
function listenJson(es, handlers, onEvent, badJson) {
|
|
140
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
141
|
+
if (typeof handler !== "function") continue;
|
|
185
142
|
es.addEventListener(type, (raw) => {
|
|
186
|
-
|
|
143
|
+
onEvent();
|
|
187
144
|
let data = {};
|
|
188
145
|
try {
|
|
189
146
|
data = raw.data ? JSON.parse(raw.data) : {};
|
|
190
147
|
} catch (_) {
|
|
191
|
-
data =
|
|
148
|
+
data = badJson(raw.data);
|
|
192
149
|
}
|
|
193
150
|
handler(data, raw);
|
|
194
151
|
});
|
|
195
152
|
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// fromSeq: the snapshot's event id (`<seq>-<epoch>`: another worker answers it
|
|
156
|
+
// with a reset) or a plain event_seq.
|
|
157
|
+
export function openStream(id, fromSeq, handlers = {}, opts = {}) {
|
|
158
|
+
const { EventSourceImpl = globalThis.EventSource, onStreamError, onStreamClosed } = opts;
|
|
159
|
+
let cursor = "0";
|
|
160
|
+
if (Number.isInteger(fromSeq)) cursor = String(fromSeq);
|
|
161
|
+
else if (typeof fromSeq === "string" && fromSeq !== "") cursor = encodeURIComponent(fromSeq);
|
|
162
|
+
const es = new EventSourceImpl(`/api/sessions/${id}/stream?from_seq=${cursor}`);
|
|
163
|
+
let eventReceived = false;
|
|
164
|
+
listenJson(es, handlers, () => { eventReceived = true; }, (raw) => ({ text: String(raw) }));
|
|
196
165
|
let live = true;
|
|
197
166
|
es.onerror = () => {
|
|
198
167
|
if (!eventReceived && live && typeof onStreamError === "function") {
|
|
@@ -238,20 +207,7 @@ export function openEvents(handlers = {}, opts = {}) {
|
|
|
238
207
|
const url = dir ? `/api/events?dir=${encodeURIComponent(dir)}` : "/api/events";
|
|
239
208
|
const es = new EventSourceImpl(url);
|
|
240
209
|
let frameReceived = false;
|
|
241
|
-
|
|
242
|
-
const handler = handlers[type];
|
|
243
|
-
if (!handler) continue;
|
|
244
|
-
es.addEventListener(type, (raw) => {
|
|
245
|
-
frameReceived = true;
|
|
246
|
-
let data = {};
|
|
247
|
-
try {
|
|
248
|
-
data = raw.data ? JSON.parse(raw.data) : {};
|
|
249
|
-
} catch (_) {
|
|
250
|
-
data = {};
|
|
251
|
-
}
|
|
252
|
-
handler(data, raw);
|
|
253
|
-
});
|
|
254
|
-
}
|
|
210
|
+
listenJson(es, handlers, () => { frameReceived = true; }, () => ({}));
|
|
255
211
|
let live = true;
|
|
256
212
|
es.onerror = () => {
|
|
257
213
|
// readyState 2 (CLOSED): the browser won't retry on its own.
|
|
@@ -75,29 +75,6 @@ export function previewOf(lastPrompt) {
|
|
|
75
75
|
return normalized;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function firstMessageOf(session, messages) {
|
|
79
|
-
// Prefer first user message content, else last_prompt / first_preview from server
|
|
80
|
-
if (Array.isArray(messages) && messages.length) {
|
|
81
|
-
const firstUser = messages.find((m) => m && (m.role === "user" || m.role === "assistant") && m.role === "user");
|
|
82
|
-
if (firstUser && firstUser.content) {
|
|
83
|
-
const n = normalize(firstUser.content);
|
|
84
|
-
if (n) return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (session) {
|
|
88
|
-
if (session.first_preview) {
|
|
89
|
-
const n = normalize(session.first_preview);
|
|
90
|
-
if (n) return n;
|
|
91
|
-
}
|
|
92
|
-
if (session.last_prompt) return previewOf(session.last_prompt);
|
|
93
|
-
// fallback to previewOf with first_preview logic length 80
|
|
94
|
-
const raw = session.preview || session.id || "";
|
|
95
|
-
const n = normalize(raw);
|
|
96
|
-
if (n) return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
|
|
97
|
-
}
|
|
98
|
-
return "\u2014";
|
|
99
|
-
}
|
|
100
|
-
|
|
101
78
|
export function previewForCard(session) {
|
|
102
79
|
// Unified card preview: first message (80) else previewOf(last_prompt)
|
|
103
80
|
const raw = session.first_preview || session.last_prompt || "";
|
|
@@ -24,7 +24,6 @@ button{background:var(--primary);border:0;color:#fff;padding:8px 12px;border-rad
|
|
|
24
24
|
button:hover:not(:disabled){filter:brightness(1.12)}
|
|
25
25
|
button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
26
26
|
button.ghost{background:var(--bg-raised);color:var(--text-2)}
|
|
27
|
-
button.danger{background:var(--danger-bg)}
|
|
28
27
|
button:disabled{opacity:.5;cursor:not-allowed}
|
|
29
28
|
main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:auto;background:transparent;min-height:0}
|
|
30
29
|
#topStrip{display:grid;grid-template-columns:repeat(var(--strip-cols,3),minmax(0,440px)) 150px;gap:12px;min-height:88px;align-content:start;transition:opacity 280ms ease, transform 280ms ease}
|
|
@@ -661,7 +660,6 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
661
660
|
.card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
|
|
662
661
|
.card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
|
|
663
662
|
.card.skeleton .line.long{width:78%}
|
|
664
|
-
.card.skeleton .line.long2{width:62%}
|
|
665
663
|
.card.skeleton .line.tiny{width:30%;height:8px}
|
|
666
664
|
@keyframes skeleton-pulse{0%,100%{opacity:1}50%{opacity:.55}}
|
|
667
665
|
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
|
@@ -2,13 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
export const MODEL_KEY = "chi_model";
|
|
4
4
|
|
|
5
|
-
// The names to offer, in the server's order: the default host's models
|
|
6
|
-
// bare, the other hosts' as host:model. [] for a payload with none.
|
|
7
|
-
export function modelNames(payload) {
|
|
8
|
-
const list = Array.isArray(payload?.models) ? payload.models : [];
|
|
9
|
-
return list.map((m) => String(m?.name || "")).filter(Boolean);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
5
|
// The name to preselect: the remembered one when it is still offered (any
|
|
13
6
|
// case), else the server's default, else the first offered, else "".
|
|
14
7
|
export function pickModel(names, defaultName, stored) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Notifications (notify.js has the rules; this is the page's side): when a
|
|
2
|
+
// session needs the user (a question, an approval, a failed turn, a long
|
|
3
|
+
// turn done) and this tab is not in front, an OS notification (the bell
|
|
4
|
+
// turns them on) and a count in the title (always). One tag per event, so
|
|
5
|
+
// several tabs show one, and none while another chi tab is in front with it
|
|
6
|
+
// (notify.js, Several tabs).
|
|
7
|
+
//
|
|
8
|
+
// The browser parts come in as deps (globals by default) so the specs can
|
|
9
|
+
// drive it without a DOM.
|
|
10
|
+
|
|
11
|
+
import { sessionHash } from "./route.js";
|
|
12
|
+
import {
|
|
13
|
+
NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention,
|
|
14
|
+
} from "./notify.js";
|
|
15
|
+
|
|
16
|
+
export const NOTIFY_KEY = "chi_notify";
|
|
17
|
+
|
|
18
|
+
const NOTIFY_TITLES = {
|
|
19
|
+
on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
|
|
20
|
+
off: "Notify me when a session needs me while this tab is in the background",
|
|
21
|
+
denied: "Notifications are blocked for this site in the browser's settings",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function defaultChannel() {
|
|
25
|
+
try {
|
|
26
|
+
if (typeof globalThis.BroadcastChannel === "function") return new BroadcastChannel(NOTIFY_CHANNEL);
|
|
27
|
+
} catch (_) {}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// @param button the bell (#notifyBtn)
|
|
32
|
+
// @param doc, win the page's document and window (title, visibility, focus,
|
|
33
|
+
// listeners)
|
|
34
|
+
// @param NotificationApi the Notification constructor (undefined: none)
|
|
35
|
+
// @param storage () => localStorage (it can throw)
|
|
36
|
+
// @param channel the BroadcastChannel to the other chi tabs, or null
|
|
37
|
+
// @param gateOptions passed to createNotifyGate (specs: schedule, now)
|
|
38
|
+
// @return {noticeSessions(type, data), setBaseTitle(title), clearBadge()}
|
|
39
|
+
export function createNotifications({
|
|
40
|
+
button,
|
|
41
|
+
doc = globalThis.document,
|
|
42
|
+
win = globalThis.window,
|
|
43
|
+
NotificationApi = globalThis.Notification,
|
|
44
|
+
storage = () => globalThis.localStorage,
|
|
45
|
+
channel = defaultChannel(),
|
|
46
|
+
gateOptions = {},
|
|
47
|
+
}) {
|
|
48
|
+
let attention = initialAttentionState();
|
|
49
|
+
const badgeKeys = new Set();
|
|
50
|
+
let baseTitle = "Chi";
|
|
51
|
+
|
|
52
|
+
function setTitle() {
|
|
53
|
+
doc.title = badgeKeys.size ? `(${badgeKeys.size}) ${baseTitle}` : baseTitle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The user is looking at this tab: visible and focused (a window behind
|
|
57
|
+
// the terminal is visible but not focused).
|
|
58
|
+
function tabInFront() {
|
|
59
|
+
return doc.visibilityState === "visible" && doc.hasFocus();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function notifyWanted() {
|
|
63
|
+
try { return storage().getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// "on" | "off" | "denied" | "unsupported" (notify.js)
|
|
67
|
+
function notifyState() {
|
|
68
|
+
const hasNotification = typeof NotificationApi === "function";
|
|
69
|
+
return bellState({
|
|
70
|
+
hasNotification,
|
|
71
|
+
secureContext: win.isSecureContext !== false,
|
|
72
|
+
permission: hasNotification ? NotificationApi.permission : "default",
|
|
73
|
+
wanted: notifyWanted(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderNotifyBtn() {
|
|
78
|
+
const state = notifyState();
|
|
79
|
+
button.hidden = state === "unsupported";
|
|
80
|
+
button.dataset.state = state;
|
|
81
|
+
button.title = NOTIFY_TITLES[state] || "";
|
|
82
|
+
button.setAttribute("aria-pressed", state === "on" ? "true" : "false");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function toggleNotify() {
|
|
86
|
+
const state = notifyState();
|
|
87
|
+
if (state === "unsupported" || state === "denied") return;
|
|
88
|
+
let wanted = state !== "on";
|
|
89
|
+
if (wanted && NotificationApi.permission !== "granted") {
|
|
90
|
+
wanted = (await NotificationApi.requestPermission()) === "granted";
|
|
91
|
+
}
|
|
92
|
+
try { storage().setItem(NOTIFY_KEY, wanted ? "1" : "0"); } catch (_) {}
|
|
93
|
+
renderNotifyBtn();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function showNotification(session, a) {
|
|
97
|
+
if (notifyState() !== "on") return;
|
|
98
|
+
const { title, body } = attentionText(session, a);
|
|
99
|
+
try {
|
|
100
|
+
const n = new NotificationApi(title, { body, tag: a.key });
|
|
101
|
+
n.onclick = () => {
|
|
102
|
+
win.focus();
|
|
103
|
+
win.location.hash = sessionHash(a.sessionId);
|
|
104
|
+
n.close();
|
|
105
|
+
};
|
|
106
|
+
} catch (_) {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Another chi tab in front has these (notify.js, Several tabs).
|
|
110
|
+
function seenElsewhere(keys) {
|
|
111
|
+
const size = badgeKeys.size;
|
|
112
|
+
for (const key of keys) badgeKeys.delete(key);
|
|
113
|
+
if (badgeKeys.size !== size) setTitle();
|
|
114
|
+
}
|
|
115
|
+
const notifyGate = createNotifyGate({ ...gateOptions, channel, onSeen: seenElsewhere });
|
|
116
|
+
|
|
117
|
+
// Every hub frame goes through here before the list takes it.
|
|
118
|
+
function noticeSessions(type, data) {
|
|
119
|
+
const next = trackAttention(attention, type, data);
|
|
120
|
+
attention = next.state;
|
|
121
|
+
const size = badgeKeys.size;
|
|
122
|
+
for (const key of next.closed) badgeKeys.delete(key);
|
|
123
|
+
notifyGate.drop(next.closed);
|
|
124
|
+
if (next.attentions.length > 0 && tabInFront()) {
|
|
125
|
+
notifyGate.seenInFront(next.attentions.map((a) => a.key));
|
|
126
|
+
} else {
|
|
127
|
+
for (const a of next.attentions) {
|
|
128
|
+
notifyGate.offer(a, () => {
|
|
129
|
+
if (tabInFront()) return;
|
|
130
|
+
badgeKeys.add(a.key);
|
|
131
|
+
setTitle();
|
|
132
|
+
showNotification(attention.byId[a.sessionId], a);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (badgeKeys.size !== size) setTitle();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The user is here: the badge goes, and the other tabs drop what it had.
|
|
140
|
+
function clearBadge() {
|
|
141
|
+
if (badgeKeys.size === 0) return;
|
|
142
|
+
notifyGate.seenInFront([...badgeKeys]);
|
|
143
|
+
badgeKeys.clear();
|
|
144
|
+
setTitle();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The user is back: the page turned visible (Safari makes it visible
|
|
148
|
+
// before it has focus, and a tab switch inside a focused window sends no
|
|
149
|
+
// focus event), the window got focus, or they touched the page.
|
|
150
|
+
function backInFront() {
|
|
151
|
+
if (doc.visibilityState === "visible") clearBadge();
|
|
152
|
+
}
|
|
153
|
+
doc.addEventListener("visibilitychange", backInFront);
|
|
154
|
+
win.addEventListener("focus", backInFront);
|
|
155
|
+
doc.addEventListener("pointerdown", clearBadge, true);
|
|
156
|
+
doc.addEventListener("keydown", clearBadge, true);
|
|
157
|
+
button.addEventListener("click", toggleNotify);
|
|
158
|
+
renderNotifyBtn();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
noticeSessions,
|
|
162
|
+
// The title without the badge ("Chi · <project>").
|
|
163
|
+
setBaseTitle(title) {
|
|
164
|
+
baseTitle = title;
|
|
165
|
+
setTitle();
|
|
166
|
+
},
|
|
167
|
+
clearBadge,
|
|
168
|
+
};
|
|
169
|
+
}
|