samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- metadata +20 -1
|
@@ -66,9 +66,11 @@ export function splitSnapshotCards(cards) {
|
|
|
66
66
|
// goes in its reloaded turn's block: the step of its iteration (the
|
|
67
67
|
// iteration-th .gen, 0-based index here) and the key of the tool row it
|
|
68
68
|
// came before (it came after +calls+ of that step's calls started), or null
|
|
69
|
-
//
|
|
69
|
+
// for a notice not of a turn. One from before the first generation (a
|
|
70
|
+
// before_turn hook's) has no iteration: the first step, as live.
|
|
70
71
|
export function turnNoticePlace(notice) {
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
if (!notice?.in_turn) return null;
|
|
73
|
+
const iteration = notice.iteration == null ? 1 : Number(notice.iteration);
|
|
74
|
+
if (!Number.isInteger(iteration) || iteration < 1) return null;
|
|
73
75
|
return { step: iteration - 1, rowKey: `${iteration}:${(Number(notice.calls) || 0) + 1}` };
|
|
74
76
|
}
|
|
@@ -10,21 +10,25 @@
|
|
|
10
10
|
// sessionId() (image thumbs of a tool row).
|
|
11
11
|
import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
|
|
12
12
|
import { copySourceAttr } from "./copy.js";
|
|
13
|
-
import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize } from "./format.js";
|
|
13
|
+
import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
14
14
|
import { thumbsHtml } from "./images.js";
|
|
15
15
|
import { shouldFollowScroll } from "./scroll.js";
|
|
16
16
|
import { tallyText } from "./tally.js";
|
|
17
17
|
import { cancelLineHtml, formatDuration, timedTurnIndexes, turnRecordAt, turnTimingText } from "./timing.js";
|
|
18
18
|
|
|
19
|
+
// A row's status (live, or a saved tool record's on reload): running,
|
|
20
|
+
// error, stopped (a task_wait the user's Stop ended), else ok ("done").
|
|
19
21
|
export function activityStatusClass(status) {
|
|
20
22
|
if (status === "running") return "running";
|
|
21
23
|
if (status === "error") return "error";
|
|
24
|
+
if (status === "stopped") return "stopped";
|
|
22
25
|
return "ok";
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export function activityStatusLabel(status) {
|
|
26
29
|
if (status === "running") return "running";
|
|
27
30
|
if (status === "error") return "error";
|
|
31
|
+
if (status === "stopped") return "stopped";
|
|
28
32
|
return "done";
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -281,6 +285,10 @@ export function createChatView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
281
285
|
notice() {
|
|
282
286
|
return false;
|
|
283
287
|
},
|
|
288
|
+
// So is a plugin's steer.
|
|
289
|
+
steer() {
|
|
290
|
+
return false;
|
|
291
|
+
},
|
|
284
292
|
// So is a card.
|
|
285
293
|
card() {
|
|
286
294
|
return false;
|
|
@@ -312,6 +320,7 @@ export function chatHistoryHtml(items, timing, { thumbs }) {
|
|
|
312
320
|
return items
|
|
313
321
|
.map((m, i) => {
|
|
314
322
|
if (m.role === "note") return `<div class="bubble note">${noteHtml(m)}</div>`;
|
|
323
|
+
if (m.role === "steer") return steerRowHtml({ source: m.source, text: m.content }, { bubble: true });
|
|
315
324
|
const turnIndex = timedTurns[i];
|
|
316
325
|
const cls = m.role === "user" ? "user" : "output";
|
|
317
326
|
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Copy buttons: one on every prompt and answer bubble, one on every code
|
|
2
2
|
// block of a rendered answer. An answer copies its markdown source (the
|
|
3
|
-
// saved message's `
|
|
4
|
-
//
|
|
5
|
-
// its code.
|
|
3
|
+
// saved message's `display` when a hook presented one, else its `content`,
|
|
4
|
+
// kept on the bubble as data-copy-source when the page shows rendered
|
|
5
|
+
// html), a prompt the text as typed, a code block just its code.
|
|
6
6
|
//
|
|
7
7
|
// The buttons carry no text (an icon; "copied ✓" is CSS content), so a
|
|
8
8
|
// bubble's textContent, the turn output dedupe and an annotation's selected
|
|
@@ -57,12 +57,28 @@ export function codeText(pre) {
|
|
|
57
57
|
return visibleText(code, ".copy-btn").replace(/\n$/, "");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// What an answer copies: what a hook presented in its place (`display`,
|
|
61
|
+
// AnswerDisplay), else its markdown source.
|
|
62
|
+
export function copySource(message) {
|
|
63
|
+
return String(message?.display ?? message?.content ?? "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Whether +bubble+ shows the answer +message+ (the page's re-read at the
|
|
67
|
+
// end of a turn): its streamed text is the answer's, or it was rendered
|
|
68
|
+
// from it already (its kept source is the answer or its display: an
|
|
69
|
+
// answer_display re-read after the turn_completed one).
|
|
70
|
+
export function showsAnswer(bubble, message, normalize) {
|
|
71
|
+
const source = bubble.getAttribute?.(COPY_SOURCE_ATTR);
|
|
72
|
+
if (source !== null && source !== undefined && (source === message.content || source === message.display)) return true;
|
|
73
|
+
return normalize(bubble.textContent) === normalize(message.content);
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
// The attribute that keeps a message's source on its reloaded bubble:
|
|
61
77
|
// rendered html and a prompt (its `>` lines render as quotes) need it; a
|
|
62
78
|
// plain answer's text is its source.
|
|
63
79
|
export function copySourceAttr(message, renderedMarkdown) {
|
|
64
80
|
if (!renderedMarkdown && message?.role !== "user") return "";
|
|
65
|
-
const text =
|
|
81
|
+
const text = copySource(message)
|
|
66
82
|
.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
67
83
|
return ` ${COPY_SOURCE_ATTR}="${text}"`;
|
|
68
84
|
}
|
|
@@ -33,3 +33,18 @@ export function extractCtxPct(data, windowTokens = null) {
|
|
|
33
33
|
if (!window || window <= 0 || used === null) return null;
|
|
34
34
|
return (used / window) * 100;
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
// The saved context (the session's timing.context: used_tokens after the
|
|
38
|
+
// last counted turn, the window now): the meter's value on load, before a
|
|
39
|
+
// turn streams. null when either count is missing.
|
|
40
|
+
export function savedCtxPct(context) {
|
|
41
|
+
const used = num(context?.used_tokens);
|
|
42
|
+
const window = num(context?.window_tokens);
|
|
43
|
+
if (used === null || !window || window <= 0) return null;
|
|
44
|
+
return (used / window) * 100;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A session card's quiet ctx: "12%", or "" when the list row has none.
|
|
48
|
+
export function cardCtxText(pct) {
|
|
49
|
+
return num(pct) === null ? "" : `${Math.round(pct)}%`;
|
|
50
|
+
}
|
|
@@ -4,6 +4,7 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
|
|
|
4
4
|
"generation_completed",
|
|
5
5
|
"turn_started",
|
|
6
6
|
"turn_completed",
|
|
7
|
+
"answer_display",
|
|
7
8
|
"turn_canceled",
|
|
8
9
|
"turn_failed",
|
|
9
10
|
"turn_enqueued",
|
|
@@ -27,6 +28,7 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
|
|
|
27
28
|
"context_added",
|
|
28
29
|
"guardrail_warning",
|
|
29
30
|
"hook_notice",
|
|
31
|
+
"empty_answer_retry",
|
|
30
32
|
"card",
|
|
31
33
|
"plugin_init_started",
|
|
32
34
|
"plugin_init_finished",
|
|
@@ -65,10 +67,11 @@ export function createSession(prompt, { dir, model, ...opts } = {}) {
|
|
|
65
67
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
// A session with no first turn:
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// A session with no first turn: the page's first message is sent into it
|
|
71
|
+
// as a turn (with its images, once they are uploaded). preview: that
|
|
72
|
+
// message, which names the session until its turn is saved.
|
|
73
|
+
export function createIdleSession({ dir, model, preview, ...opts } = {}) {
|
|
74
|
+
const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
|
|
72
75
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -111,10 +114,11 @@ export function sendTurn(id, prompt, { clientId, images, ...opts } = {}) {
|
|
|
111
114
|
|
|
112
115
|
// A session command (/model, /models, !rollback, !cmd, /continue …) for the
|
|
113
116
|
// worker to run; its command_ran reaches every client.
|
|
114
|
-
|
|
117
|
+
// card: the line is a card's action (its echo stays out of the history).
|
|
118
|
+
export function sendCommand(id, line, { clientId, card = false, ...opts } = {}) {
|
|
115
119
|
return api(`/api/sessions/${id}/command`, {
|
|
116
120
|
method: "POST",
|
|
117
|
-
body: JSON.stringify(clientId ? {
|
|
121
|
+
body: JSON.stringify({ line, ...(clientId ? { client_id: clientId } : {}), ...(card ? { card: true } : {}) }),
|
|
118
122
|
...opts,
|
|
119
123
|
});
|
|
120
124
|
}
|
|
@@ -148,6 +152,17 @@ export function stopSession(id, opts = {}) {
|
|
|
148
152
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
// Hides the session (and its delegates) from the lists, kept for good; the
|
|
156
|
+
// server stops a live idle worker first and answers 409 while a turn runs.
|
|
157
|
+
export function archiveSession(id, opts = {}) {
|
|
158
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/archive`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Back to the lists, delegates too.
|
|
162
|
+
export function unarchiveSession(id, opts = {}) {
|
|
163
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/unarchive`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
// Deletes the session for good; the server stops a live worker first.
|
|
152
167
|
export function deleteSession(id, opts = {}) {
|
|
153
168
|
return api(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", ...opts });
|
|
@@ -23,6 +23,15 @@ export function noteHtml(note) {
|
|
|
23
23
|
return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// A plugin's steer (a nudge put into the running turn): one line that
|
|
27
|
+
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
28
|
+
// own between bubbles (the chat view), not a row of a step.
|
|
29
|
+
export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
|
|
30
|
+
const who = source ? `${source} nudged the model` : "nudged the model";
|
|
31
|
+
return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
|
|
32
|
+
`<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// A user message's body: runs of `>` lines become <blockquote>s, the rest
|
|
27
36
|
// stays escaped text. The bubble is pre-wrap, so the blank lines around a
|
|
28
37
|
// quote are dropped (the blockquote's margin spaces it instead).
|
|
@@ -115,6 +115,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
115
115
|
.activity-status.running{color:var(--warn)}
|
|
116
116
|
.activity-status.ok{color:var(--ok)}
|
|
117
117
|
.activity-status.error{color:var(--danger)}
|
|
118
|
+
.activity-status.stopped{color:var(--warn-text);opacity:.8}
|
|
118
119
|
.activity-tool{color:var(--accent);font-weight:600}
|
|
119
120
|
.activity-params{color:var(--text-muted);word-break:break-all}
|
|
120
121
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
@@ -258,6 +259,14 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
258
259
|
.gen .hook-notice{font-size:13px;color:var(--text-3);padding:5px 2px 5px 8px;border-top:1px solid var(--border);border-left:2px solid var(--text-faint);white-space:pre-wrap;word-break:break-word}
|
|
259
260
|
.gen .activity-body>.hook-notice:first-child{border-top:none}
|
|
260
261
|
.gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
|
|
262
|
+
/* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
|
|
263
|
+
.steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
|
|
264
|
+
.steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
|
|
265
|
+
.steer-row>summary::-webkit-details-marker{display:none}
|
|
266
|
+
.steer-row>summary::before{content:"↪ ";font-style:normal;color:var(--accent)}
|
|
267
|
+
.steer-text{white-space:pre-wrap;word-break:break-word;color:var(--text-muted);margin-top:4px}
|
|
268
|
+
.gen>.steer-row{padding:4px 2px 4px 8px;margin:2px 0 6px}
|
|
269
|
+
.bubble.steer-row{background:transparent;padding:6px 12px;border-left-width:3px}
|
|
261
270
|
/* A plugin's card (card.js): the question card's glass with a cool tint; its actions are commands the session runs. */
|
|
262
271
|
.bubble.plugin-card{border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 6%,var(--glass-bg));padding:10px 12px;transition:box-shadow .3s ease}
|
|
263
272
|
.bubble.plugin-card.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
|
|
@@ -278,8 +287,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
278
287
|
/* In a step: a row, lighter (no float), like the step's notices. */
|
|
279
288
|
.gen .bubble.plugin-card{margin:6px 0 4px;box-shadow:inset 0 1px 0 var(--glass-highlight);padding:8px 10px}
|
|
280
289
|
.hint{color:var(--text-muted);font-size:13px;padding:12px}
|
|
281
|
-
|
|
282
|
-
#
|
|
290
|
+
/* The selection bubble (app.js Annotate): Annotate + the presets (web.annotate_presets), small pills with a gap (they wrap on a narrow page). */
|
|
291
|
+
#annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
|
|
292
|
+
#annotateBar[hidden]{display:none}
|
|
293
|
+
#annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
|
|
294
|
+
#annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
283
295
|
/* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
|
|
284
296
|
.bubble.output,.bubble.user{position:relative}
|
|
285
297
|
.copy-btn{position:absolute;top:6px;right:6px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-muted);font-size:11px;line-height:1;white-space:nowrap;user-select:none;-webkit-user-select:none;opacity:0;transition:opacity .12s ease,color .12s ease}
|
|
@@ -376,6 +388,8 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
376
388
|
#infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
|
|
377
389
|
#infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
|
|
378
390
|
#infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
|
|
391
|
+
#infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
|
|
392
|
+
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
379
393
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
380
394
|
#infoBar #infoDeleteBtn:hover{color:#fff;background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
381
395
|
/* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable) and the first-message line, the strip card's title, goes. */
|
|
@@ -437,6 +451,16 @@ body.has-session #scope .scope-where{display:none}
|
|
|
437
451
|
}
|
|
438
452
|
/* The ▾ glyph draws tiny at the pill's 11 px: at 20 px it is about as tall
|
|
439
453
|
as the pill text's capitals (phone and desktop alike). */
|
|
454
|
+
/* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
|
|
455
|
+
#notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
|
|
456
|
+
#notifyBtn[hidden]{display:none}
|
|
457
|
+
#notifyBtn:hover{color:var(--text-2);border-color:var(--border)}
|
|
458
|
+
#notifyBtn svg{fill:none;stroke:currentColor;stroke-width:1.3;stroke-linejoin:round;stroke-linecap:round}
|
|
459
|
+
#notifyBtn .slash{display:none}
|
|
460
|
+
#notifyBtn[data-state="on"]{color:var(--accent)}
|
|
461
|
+
#notifyBtn[data-state="on"] .bell{fill:currentColor;fill-opacity:.22}
|
|
462
|
+
#notifyBtn[data-state="denied"]{color:var(--text-faint)}
|
|
463
|
+
#notifyBtn[data-state="denied"] .slash{display:inline}
|
|
440
464
|
#stripShow .pill-arrow{font-size:20px;line-height:0;vertical-align:-3px}
|
|
441
465
|
.card .folder{font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;color:var(--text-3)}
|
|
442
466
|
.card .parent{font-family:ui-monospace,monospace;white-space:nowrap;color:var(--text-3)}
|
|
@@ -465,6 +489,13 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
465
489
|
#allPath[hidden]{display:none}
|
|
466
490
|
#allPath code{font-family:ui-monospace,monospace;background:var(--bg-hover);padding:1px 4px;border-radius:4px}
|
|
467
491
|
#allHead .count{font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
492
|
+
#allHead .include-archived{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted);white-space:nowrap;cursor:pointer;user-select:none}
|
|
493
|
+
.card.archived{opacity:.6}
|
|
494
|
+
.card.archived:hover{opacity:.85}
|
|
495
|
+
.card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
|
|
496
|
+
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
|
|
497
|
+
#toast[hidden]{display:none}
|
|
498
|
+
#toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
|
|
468
499
|
#allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
|
|
469
500
|
.card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
|
|
470
501
|
.card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
|
|
@@ -484,6 +515,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
484
515
|
.card.active{background:var(--accent-bg);border-color:var(--accent-border)}
|
|
485
516
|
.card .preview{font-size:14px;line-height:1.4;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
486
517
|
.card .time{font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
518
|
+
.card .ctx{font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text-3)}
|
|
487
519
|
#topStrip .card .preview{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
488
520
|
#allList .card .preview{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;word-break:break-word;line-height:1.4}
|
|
489
521
|
.card .recap{font-size:12px;line-height:1.4;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:-2px 0 2px}
|
|
@@ -517,6 +549,7 @@ a{color:var(--accent)}
|
|
|
517
549
|
<button type="button" id="newBtn" class="brand" title="New chat"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new chat</span></span></button>
|
|
518
550
|
<div id="scope" hidden></div>
|
|
519
551
|
<button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
|
|
552
|
+
<button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
|
|
520
553
|
</div>
|
|
521
554
|
<section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
|
|
522
555
|
<section id="center">
|
|
@@ -548,6 +581,7 @@ a{color:var(--accent)}
|
|
|
548
581
|
</div>
|
|
549
582
|
<div id="infoBar">
|
|
550
583
|
<div class="info" id="infoText"></div>
|
|
584
|
+
<button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
|
|
551
585
|
<button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
|
|
552
586
|
<button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
|
|
553
587
|
</div>
|
|
@@ -557,12 +591,14 @@ a{color:var(--accent)}
|
|
|
557
591
|
<div id="allHead">
|
|
558
592
|
<button class="ghost" id="allBack" title="Back to the chat (Esc)">← Back</button>
|
|
559
593
|
<input id="filter" type="search" placeholder="Search… (preview / id / status)" autocomplete="off">
|
|
594
|
+
<label class="include-archived" title="Show archived sessions too"><input id="includeArchived" type="checkbox"> include archived</label>
|
|
560
595
|
<span class="count" id="count"></span>
|
|
561
596
|
</div>
|
|
562
597
|
<div id="allPath" hidden>Stored in <code></code></div>
|
|
563
598
|
<div id="allList"></div>
|
|
564
599
|
</section>
|
|
565
600
|
</main>
|
|
601
|
+
<div id="toast" role="status" aria-live="polite" hidden></div>
|
|
566
602
|
<script type="module" src="/assets/app.js"></script>
|
|
567
603
|
</body>
|
|
568
604
|
</html>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// When a session needs the user: a pure reducer over the hub's session
|
|
2
|
+
// summaries (GET /api/events). The page shows an OS notification and a
|
|
3
|
+
// title badge for what it returns, while the tab is not in front.
|
|
4
|
+
|
|
5
|
+
// A finished turn shorter than this was watched: no "done".
|
|
6
|
+
export const DONE_MIN_SECONDS = 10;
|
|
7
|
+
|
|
8
|
+
// What changed between two summaries of one session that needs the user.
|
|
9
|
+
// @param prev the summary held before (null for a session not seen yet)
|
|
10
|
+
// @param next the new summary
|
|
11
|
+
// @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
|
|
12
|
+
// key names this one event, the same in every tab (the Notification tag)
|
|
13
|
+
export function attentionFor(prev, next) {
|
|
14
|
+
if (!next || !next.id) return null;
|
|
15
|
+
const q = next.pending_question;
|
|
16
|
+
if (q && q.id && q.id !== prev?.pending_question?.id) {
|
|
17
|
+
return { reason: q.kind === "approval" ? "approval" : "question", sessionId: next.id, key: `${next.id}:${q.id}` };
|
|
18
|
+
}
|
|
19
|
+
// A running turn's card with actions (check-in's Nudge / Keep going /
|
|
20
|
+
// Stop) asks the user as a question does.
|
|
21
|
+
const card = next.pending_card;
|
|
22
|
+
if (card && card.id && card.id !== prev?.pending_card?.id) {
|
|
23
|
+
return { reason: "card", sessionId: next.id, key: cardKey(next.id, card.id) };
|
|
24
|
+
}
|
|
25
|
+
const turn = next.last_turn;
|
|
26
|
+
if (!turn || !turn.ended_at || turn.ended_at === prev?.last_turn?.ended_at) return null;
|
|
27
|
+
// A delegated child's turn is reported by its parent's.
|
|
28
|
+
if (next.parent_id) return null;
|
|
29
|
+
const key = `${next.id}:${turn.ended_at}`;
|
|
30
|
+
if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
|
|
31
|
+
if (turn.outcome === "completed" && Number(turn.seconds) >= DONE_MIN_SECONDS) {
|
|
32
|
+
return { reason: "done", sessionId: next.id, key };
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The tracker's state: the last summary of each session, and whether the
|
|
38
|
+
// first snapshot has been seen (it seeds, so old state never notifies).
|
|
39
|
+
export function initialAttentionState() {
|
|
40
|
+
return { seeded: false, byId: {} };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
44
|
+
|
|
45
|
+
// The keys of what the session no longer has open: its question (answered
|
|
46
|
+
// here or in another client), its card with actions (resolved, or the turn
|
|
47
|
+
// ended), or both when the session is gone.
|
|
48
|
+
// @param prev the summary held before (null for a session not seen yet)
|
|
49
|
+
// @param next the new summary (null when the session is gone)
|
|
50
|
+
// @return [String] keys, maybe none
|
|
51
|
+
export function closedKeys(prev, next) {
|
|
52
|
+
const keys = [];
|
|
53
|
+
const q = prev?.pending_question;
|
|
54
|
+
if (q && q.id && next?.pending_question?.id !== q.id) keys.push(`${prev.id}:${q.id}`);
|
|
55
|
+
const card = prev?.pending_card;
|
|
56
|
+
if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
|
|
57
|
+
return keys;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// @param state initialAttentionState() or a previous result's state
|
|
61
|
+
// @param type "snapshot" | "session" | "session_gone"
|
|
62
|
+
// @param data the frame's data
|
|
63
|
+
// @return {state, attentions, closed}; state is a new object, attentions a
|
|
64
|
+
// list, closed the keys of questions and cards no longer open (the badge
|
|
65
|
+
// drops them)
|
|
66
|
+
export function trackAttention(state, type, data = {}) {
|
|
67
|
+
const byId = { ...state.byId };
|
|
68
|
+
const attentions = [];
|
|
69
|
+
const closed = [];
|
|
70
|
+
const close = (prev, next) => closed.push(...closedKeys(prev, next));
|
|
71
|
+
const see = (s) => {
|
|
72
|
+
if (!s || !s.id) return;
|
|
73
|
+
const prev = byId[s.id] || null;
|
|
74
|
+
if (state.seeded) {
|
|
75
|
+
const a = attentionFor(prev, s);
|
|
76
|
+
if (a) attentions.push(a);
|
|
77
|
+
}
|
|
78
|
+
close(prev, s);
|
|
79
|
+
byId[s.id] = s;
|
|
80
|
+
};
|
|
81
|
+
if (type === "snapshot") {
|
|
82
|
+
(Array.isArray(data.sessions) ? data.sessions : []).forEach(see);
|
|
83
|
+
return { state: { seeded: true, byId }, attentions, closed };
|
|
84
|
+
}
|
|
85
|
+
if (type === "session") see(data.session);
|
|
86
|
+
else if (type === "session_gone") {
|
|
87
|
+
close(byId[data.id] || null, null);
|
|
88
|
+
delete byId[data.id];
|
|
89
|
+
}
|
|
90
|
+
return { state: { seeded: state.seeded, byId }, attentions, closed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The notification's words: a short title (the session) and the reason.
|
|
94
|
+
// No question or answer text (it would show on a lock screen).
|
|
95
|
+
export function attentionText(session, attention) {
|
|
96
|
+
const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
|
|
97
|
+
const title = name.length > 60 ? `${name.slice(0, 60)}…` : name;
|
|
98
|
+
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
99
|
+
const body = {
|
|
100
|
+
question: "needs an answer",
|
|
101
|
+
approval: "needs approval",
|
|
102
|
+
card: "needs you",
|
|
103
|
+
failed: "turn failed",
|
|
104
|
+
done: `done in ${seconds} s`
|
|
105
|
+
}[attention.reason] || "";
|
|
106
|
+
return { title, body };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Several tabs ──────────────────────────────────────────────────────────
|
|
110
|
+
// Every chi tab gets the same hub frames. A tab in front tells the others
|
|
111
|
+
// (BroadcastChannel "chi-notify") which attention keys it has in front, and
|
|
112
|
+
// a tab behind neither notifies nor badges those: it holds a new attention
|
|
113
|
+
// for +holdMs+ in case the front tab's word is on its way, and drops a key
|
|
114
|
+
// from its badge when the word comes later. Without BroadcastChannel every
|
|
115
|
+
// tab goes on alone, at once.
|
|
116
|
+
export const NOTIFY_CHANNEL = "chi-notify";
|
|
117
|
+
export const NOTIFY_HOLD_MS = 300;
|
|
118
|
+
// How long a key seen in front is remembered (its frame may come late).
|
|
119
|
+
const SEEN_TTL_MS = 10 * 60 * 1000;
|
|
120
|
+
|
|
121
|
+
// @param channel a BroadcastChannel (postMessage, onmessage), or null
|
|
122
|
+
// @param onSeen(keys) another tab has these keys in front (drop them from
|
|
123
|
+
// the badge)
|
|
124
|
+
// @return {seenInFront(keys), offer(attention, deliver), drop(keys)}
|
|
125
|
+
export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSeen = () => {},
|
|
126
|
+
schedule = setTimeout, cancel = clearTimeout, now = Date.now } = {}) {
|
|
127
|
+
const seen = new Map(); // key → when
|
|
128
|
+
const pending = new Map(); // key → timer
|
|
129
|
+
const mark = (keys) => {
|
|
130
|
+
const at = now();
|
|
131
|
+
for (const [key, when] of seen) if (at - when > SEEN_TTL_MS) seen.delete(key);
|
|
132
|
+
for (const key of keys) seen.set(key, at);
|
|
133
|
+
};
|
|
134
|
+
const drop = (keys) => {
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
if (!pending.has(key)) continue;
|
|
137
|
+
cancel(pending.get(key));
|
|
138
|
+
pending.delete(key);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
if (channel) {
|
|
142
|
+
channel.onmessage = (e) => {
|
|
143
|
+
const keys = e?.data?.type === "seen" && Array.isArray(e.data.keys) ? e.data.keys.map(String) : [];
|
|
144
|
+
if (keys.length === 0) return;
|
|
145
|
+
mark(keys);
|
|
146
|
+
drop(keys);
|
|
147
|
+
onSeen(keys);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
// This tab is in front with +keys+.
|
|
152
|
+
seenInFront(keys) {
|
|
153
|
+
if (!channel || keys.length === 0) return;
|
|
154
|
+
try { channel.postMessage({ type: "seen", keys }); } catch (_) {}
|
|
155
|
+
},
|
|
156
|
+
// A new attention in a tab behind: +deliver+ (notify, badge) unless
|
|
157
|
+
// another tab has it in front. @return whether it may still be delivered
|
|
158
|
+
offer(attention, deliver) {
|
|
159
|
+
const key = attention.key;
|
|
160
|
+
if (seen.has(key)) return false;
|
|
161
|
+
if (!channel) {
|
|
162
|
+
deliver();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
drop([key]);
|
|
166
|
+
pending.set(key, schedule(() => {
|
|
167
|
+
pending.delete(key);
|
|
168
|
+
if (!seen.has(key)) deliver();
|
|
169
|
+
}, holdMs));
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
// Closed before its hold was up (a question answered at once).
|
|
173
|
+
drop,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -26,7 +26,8 @@ export function approvalView(pq) {
|
|
|
26
26
|
return {
|
|
27
27
|
// A plugin tool's label ("chrome: screenshot"), as its row shows it.
|
|
28
28
|
tool: a.label || a.tool || "",
|
|
29
|
-
|
|
29
|
+
// A tool without a command or path (an MCP tool): its compact args.
|
|
30
|
+
what: command || paths.join("\n") || a.args || "",
|
|
30
31
|
isCommand: !!command,
|
|
31
32
|
where,
|
|
32
33
|
why: `${reason} (${who})`,
|
|
@@ -28,6 +28,13 @@ export function applySessionEvent(list, type, data = {}) {
|
|
|
28
28
|
return list;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The sessions a list shows: archived ones only when asked (the all view's
|
|
32
|
+
// "include archived"). The page keeps them in its list either way: a parent
|
|
33
|
+
// chip and the open session still need them.
|
|
34
|
+
export function listedSessions(list, includeArchived = false) {
|
|
35
|
+
return includeArchived ? list : list.filter((s) => !s.archived);
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
const updatedMs = (s) => {
|
|
32
39
|
const ms = Date.parse(s.updated_at || "");
|
|
33
40
|
return Number.isNaN(ms) ? 0 : ms;
|
|
@@ -92,9 +92,12 @@ export function turnGroups(items, timing) {
|
|
|
92
92
|
items.forEach((item, i) => {
|
|
93
93
|
if (item?.role === "note") {
|
|
94
94
|
groups.push({ kind: "note", i });
|
|
95
|
+
} else if (item?.role === "steer") {
|
|
96
|
+
if (turn) turn.steers.push(i);
|
|
97
|
+
else groups.push({ kind: "steer", i });
|
|
95
98
|
} else if (item?.role === "user") {
|
|
96
99
|
turnIndex += 1;
|
|
97
|
-
turn = { kind: "turn", turnIndex, user: i, texts: [] };
|
|
100
|
+
turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [] };
|
|
98
101
|
groups.push(turn);
|
|
99
102
|
} else if (item?.role === "assistant") {
|
|
100
103
|
if (turn) turn.texts.push(i);
|
|
@@ -103,22 +106,44 @@ export function turnGroups(items, timing) {
|
|
|
103
106
|
});
|
|
104
107
|
return groups.map((g) => {
|
|
105
108
|
if (g.kind !== "turn") return g;
|
|
106
|
-
|
|
107
|
-
const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
|
|
108
|
-
const stepsOnly = canceledSteps(g, record, tools);
|
|
109
|
-
if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
|
|
110
|
-
const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
|
|
111
|
-
const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
|
|
112
|
-
const steps = [];
|
|
113
|
-
for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
|
|
114
|
-
const iteration = k + 1;
|
|
115
|
-
steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
|
|
116
|
-
}
|
|
117
|
-
const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
|
|
118
|
-
return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
|
|
109
|
+
return withSteers(turnGroup(g, items, timing), g.steers, items);
|
|
119
110
|
});
|
|
120
111
|
}
|
|
121
112
|
|
|
113
|
+
// A turn's steers (a plugin's nudges) into its steps: each goes to the step
|
|
114
|
+
// that answered it (`step`: the server counts the model messages since the
|
|
115
|
+
// prompt, plus one). One whose step isn't there (the answer, which leaves
|
|
116
|
+
// the block) stays on the group (`steers`): a bubble before the answer, as
|
|
117
|
+
// live. A turn without steers keeps its shape.
|
|
118
|
+
function withSteers(group, steerIndexes, items) {
|
|
119
|
+
if (!steerIndexes.length) return group;
|
|
120
|
+
const steps = group.steps.map((step) => ({ ...step, steers: [] }));
|
|
121
|
+
const left = [];
|
|
122
|
+
for (const i of steerIndexes) {
|
|
123
|
+
const n = Number(items[i].step) || 1;
|
|
124
|
+
const step = steps.find((s) => s.iteration === n);
|
|
125
|
+
if (step) step.steers.push(i);
|
|
126
|
+
else left.push(i);
|
|
127
|
+
}
|
|
128
|
+
return { ...group, steps, steers: left };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function turnGroup(g, items, timing) {
|
|
132
|
+
const record = turnRecordAt(timing, g.turnIndex);
|
|
133
|
+
const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
|
|
134
|
+
const stepsOnly = canceledSteps(g, record, tools);
|
|
135
|
+
if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
|
|
136
|
+
const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
|
|
137
|
+
const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
|
|
138
|
+
const steps = [];
|
|
139
|
+
for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
|
|
140
|
+
const iteration = k + 1;
|
|
141
|
+
steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
|
|
142
|
+
}
|
|
143
|
+
const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
|
|
144
|
+
return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
// A canceled turn that ran more than one step or any call has no answer:
|
|
123
148
|
// its partial text stays in the block, as it does live (turn_view.js
|
|
124
149
|
// turnEnded promotes only a completed turn or a lone step with no tools).
|