samagotchi 0.5.1 → 0.7.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 +77 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +24 -1
- data/docs/desktop.md +52 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +7 -2
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +23 -8
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +11 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -55,9 +55,18 @@ export function hookNoticeLabel(hook) {
|
|
|
55
55
|
return /^[a-z][a-z0-9-]*$/.test(name) ? name : "hook";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// The row a step gets when the loop asks again after an empty answer
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// The row a step gets when the loop asks again after an empty answer, or
|
|
59
|
+
// after a plugin cut the generation (stopped_by: the bundle).
|
|
60
|
+
export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
|
|
61
|
+
const what = stoppedBy ? `cut by ${stoppedBy}` : "empty answer";
|
|
62
|
+
return `↻ ${what}, asking again (${attempt}/${of})`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The text of a turn's row from a snapshot entry (CardStore): a hook's
|
|
66
|
+
// notice as "<label>: text", the loop's asking-again row as its line.
|
|
67
|
+
export function noticeLine(entry = {}) {
|
|
68
|
+
if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
|
|
69
|
+
return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
// The live turn's status while the provider is asked again after an error
|
|
@@ -211,6 +220,7 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
|
|
|
211
220
|
// parts in order), then the prompts queued behind it. app.js feeds these to
|
|
212
221
|
// its stream handlers, so a join renders exactly like watching live.
|
|
213
222
|
// `merged_input` is the one synthetic type: a prompt merged into the turn.
|
|
223
|
+
// A `notice` part carries its event as it came (TurnNotice).
|
|
214
224
|
export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
|
|
215
225
|
const events = [];
|
|
216
226
|
if (turn) {
|
|
@@ -256,9 +266,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
256
266
|
closeText();
|
|
257
267
|
events.push({ type: "reminder_injected", reminders: part.reminders || [] });
|
|
258
268
|
break;
|
|
259
|
-
|
|
269
|
+
// One of the turn's rows (a hook's notice, an empty-answer retry, a
|
|
270
|
+
// question and its answer): the event itself, which the live
|
|
271
|
+
// handler draws where it drew it live.
|
|
272
|
+
case "notice":
|
|
260
273
|
closeText();
|
|
261
|
-
events.push({
|
|
274
|
+
if (part.event?.type) events.push({ ...part.event });
|
|
262
275
|
break;
|
|
263
276
|
// A plugin's steer: a steer-only merge, as live.
|
|
264
277
|
case "steer":
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
// The turn view of a running turn (web.
|
|
2
|
-
// class="turn-work"> per turn holding one <details
|
|
3
|
-
// generation, the current one live (open, at the bottom),
|
|
4
|
-
// collapsed to one line each. At the turn's end the last
|
|
5
|
-
// narration moves out of the block as the answer bubble, so
|
|
6
|
-
// annotate and the turn output dedupe work
|
|
7
|
-
//
|
|
1
|
+
// The turn view of a running turn (web.view: turn, and the stage's block):
|
|
2
|
+
// one <details class="turn-work"> per turn holding one <details
|
|
3
|
+
// class="gen"> per generation, the current one live (open, at the bottom),
|
|
4
|
+
// the earlier ones collapsed to one line each. At the turn's end the last
|
|
5
|
+
// generation's narration moves out of the block as the answer bubble, so
|
|
6
|
+
// markdown, annotate and the turn output dedupe work on a plain bubble.
|
|
7
|
+
// The grouping rules live in turn_model.js.
|
|
8
8
|
//
|
|
9
9
|
// A block that holds one gen with no thinking and no tool rows hides its
|
|
10
10
|
// chrome (CSS :has), so a plain answer streams like today's bubble, and is
|
|
11
11
|
// removed at the end: the answer bubble takes its place.
|
|
12
12
|
import { toolName } from "./activity.js";
|
|
13
13
|
import { leavesBlock } from "./card.js";
|
|
14
|
-
import { activityStatusClass, activityStatusLabel, fillActivityRow } from "./chat_view.js";
|
|
15
14
|
import { copySourceAttr } from "./copy.js";
|
|
16
15
|
import { activityDiffHtml } from "./diff_view.js";
|
|
17
16
|
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
17
|
+
import { thumbsHtml } from "./images.js";
|
|
18
18
|
import { shouldFollowScroll } from "./scroll.js";
|
|
19
19
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
20
20
|
import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
@@ -23,6 +23,55 @@ import { createTicker } from "./thinking_ticker.js";
|
|
|
23
23
|
import * as model from "./turn_model.js";
|
|
24
24
|
import { createHold } from "./hold.js";
|
|
25
25
|
|
|
26
|
+
// A row's status (live, or a saved tool record's on reload): running,
|
|
27
|
+
// error, stopped (a task_wait the user's Stop ended), else ok ("done").
|
|
28
|
+
function activityStatusClass(status) {
|
|
29
|
+
if (status === "running") return "running";
|
|
30
|
+
if (status === "error") return "error";
|
|
31
|
+
if (status === "stopped") return "stopped";
|
|
32
|
+
return "ok";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function activityStatusLabel(status) {
|
|
36
|
+
if (status === "running") return "running";
|
|
37
|
+
if (status === "error") return "error";
|
|
38
|
+
if (status === "stopped") return "stopped";
|
|
39
|
+
return "done";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A live tool row filled from its activity row (status, title, output).
|
|
43
|
+
function fillActivityRow(el, row, sessionId) {
|
|
44
|
+
const statusEl = el.querySelector(".activity-status");
|
|
45
|
+
statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
|
|
46
|
+
statusEl.textContent = activityStatusLabel(row.status);
|
|
47
|
+
const toolEl = el.querySelector(".activity-tool");
|
|
48
|
+
if (toolEl) toolEl.textContent = toolName(row);
|
|
49
|
+
const paramsEl = el.querySelector(".activity-params");
|
|
50
|
+
if (paramsEl) {
|
|
51
|
+
// The title in place of the params; the full params line on hover.
|
|
52
|
+
paramsEl.textContent = row.title || row.params || "";
|
|
53
|
+
paramsEl.title = row.title && row.params ? row.params : "";
|
|
54
|
+
}
|
|
55
|
+
const outEl = el.querySelector(".activity-output");
|
|
56
|
+
if (outEl) {
|
|
57
|
+
if (row.status === "running") {
|
|
58
|
+
outEl.style.display = "none";
|
|
59
|
+
outEl.textContent = "";
|
|
60
|
+
outEl.title = "";
|
|
61
|
+
} else {
|
|
62
|
+
// dispatch prefixes output with "[tool]"; strip that noise for display.
|
|
63
|
+
const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
64
|
+
outEl.title = full;
|
|
65
|
+
outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
|
|
66
|
+
outEl.style.display = full ? "" : "none";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// The images a tool returned (a reloaded row has them too, reloadRowHtml).
|
|
70
|
+
if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
|
|
71
|
+
// An edit/write's change, collapsed: "diff +3 −1".
|
|
72
|
+
if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
|
|
73
|
+
}
|
|
74
|
+
|
|
26
75
|
// The container seams (the stage view composes this view, stage_view.js):
|
|
27
76
|
// blockHost(el) puts the turn's block in the page (default: the history);
|
|
28
77
|
// follow() keeps the live part in view (default: the history's
|
|
@@ -102,7 +151,7 @@ export function createTurnView({
|
|
|
102
151
|
const text = document.createElement("div");
|
|
103
152
|
text.className = "gen-text streaming";
|
|
104
153
|
// A step after the first shows "…" until its text comes (the first one
|
|
105
|
-
// only for a prompt
|
|
154
|
+
// only for a prompt).
|
|
106
155
|
if (turn.gens.length > 1) text.textContent = "…";
|
|
107
156
|
el.appendChild(text);
|
|
108
157
|
const entry = { el, summary, thinking: null, text, activity: null, rows: new Map(), toggled: false,
|
|
@@ -308,7 +357,7 @@ export function createTurnView({
|
|
|
308
357
|
}
|
|
309
358
|
entry.summary.textContent = model.genLabel(gen);
|
|
310
359
|
if (!entry.toggled) entry.el.open = false;
|
|
311
|
-
//
|
|
360
|
+
// Every generation's text goes into seenContent (the turn output dedupe).
|
|
312
361
|
const k = normalize(gen.text);
|
|
313
362
|
if (k) sawText(k);
|
|
314
363
|
}
|
|
@@ -449,8 +498,8 @@ export function createTurnView({
|
|
|
449
498
|
cards.forEach((el) => { if (!el.classList.contains("plugin-card")) el.classList.add("bubble"); });
|
|
450
499
|
if (leaving) entry.el.remove();
|
|
451
500
|
if (plain) {
|
|
452
|
-
// A plain answer: no block, the bubble where it was
|
|
453
|
-
//
|
|
501
|
+
// A plain answer: no block, the bubble where it was; its thinking, if
|
|
502
|
+
// any, as a collapsed thinking block above it.
|
|
454
503
|
const pieces = [];
|
|
455
504
|
if (gen.thinking) {
|
|
456
505
|
if (!entry.thinkingWrap) thinkingEl(gen);
|
|
@@ -485,7 +534,7 @@ export function createTurnView({
|
|
|
485
534
|
const last = model.currentGen(turn);
|
|
486
535
|
// A completed turn's answer is its last generation's text; a one-step
|
|
487
536
|
// turn without tools leaves the block in any case (canceled, it is the
|
|
488
|
-
// partial text bubble
|
|
537
|
+
// partial text bubble).
|
|
489
538
|
const promoting = last && (kind === "completed" || (turn.gens.length === 1 && last.tools.length === 0));
|
|
490
539
|
// The box's height, measured while it is still in the DOM and the step
|
|
491
540
|
// still open: the apply below swaps it for the full text and collapses
|
|
@@ -613,8 +662,7 @@ function defaultPlaceAnswer(appendToHistory) {
|
|
|
613
662
|
};
|
|
614
663
|
}
|
|
615
664
|
|
|
616
|
-
// A reloaded tool row, the live row's markup (
|
|
617
|
-
// fillActivityRow): status, tool, params, output (its "[tool]" tag dropped,
|
|
665
|
+
// A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, output (its "[tool]" tag dropped,
|
|
618
666
|
// 300 chars, the rest on hover), plus the call's duration from its record.
|
|
619
667
|
// Params, output and the call's images come from the saved messages'
|
|
620
668
|
// parts; without them the row has the record's name, status and duration
|
|
@@ -663,7 +711,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
663
711
|
const noBlock = !group.steps.length ||
|
|
664
712
|
(group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking);
|
|
665
713
|
if (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking) {
|
|
666
|
-
// A plain answer with thinking: the
|
|
714
|
+
// A plain answer with thinking: the collapsed thinking block, as live.
|
|
667
715
|
parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
|
|
668
716
|
} else if (group.steps.length) {
|
|
669
717
|
const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
|
|
@@ -684,6 +732,10 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
684
732
|
// their own after the prompt.
|
|
685
733
|
const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
|
|
686
734
|
parts.push(loose.map((i) => steerHtml(i, true)).join(""));
|
|
735
|
+
// The user's lines merged into the turn: the live "steered" bubbles,
|
|
736
|
+
// after its steps and before its answer.
|
|
737
|
+
parts.push((group.merged || []).map((i) =>
|
|
738
|
+
`<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}><div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>`).join(""));
|
|
687
739
|
// A turn with no answer (canceled, or running: no record yet) shows its
|
|
688
740
|
// timing after its prompt and steps, where the live view leaves it.
|
|
689
741
|
if (group.answer === null) parts.push(timingHtml(group));
|
|
@@ -59,8 +59,9 @@ module Samagotchi
|
|
|
59
59
|
# @param new_token [Boolean] replace the LAN access token first
|
|
60
60
|
# (--new-token): a running server takes the new one at once
|
|
61
61
|
# @return [Integer] the exit status
|
|
62
|
-
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
|
|
63
|
-
|
|
62
|
+
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
|
|
63
|
+
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
64
|
+
new_token: false)
|
|
64
65
|
rotate_token if new_token
|
|
65
66
|
setting = host_setting(host)
|
|
66
67
|
lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
|
|
@@ -108,7 +109,7 @@ module Samagotchi
|
|
|
108
109
|
# @param token_path [String] the LAN access token's file
|
|
109
110
|
# @return [Boolean] false when the port was taken (said so on stderr)
|
|
110
111
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
111
|
-
view: "
|
|
112
|
+
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
112
113
|
token_path: Token.path)
|
|
113
114
|
port = resolve_port(port)
|
|
114
115
|
host = resolve_host(host)
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -150,9 +150,11 @@ files:
|
|
|
150
150
|
- lib/samagotchi/bridge/ring_buffer.rb
|
|
151
151
|
- lib/samagotchi/bridge/sse_writer.rb
|
|
152
152
|
- lib/samagotchi/bridge/turn_accumulator.rb
|
|
153
|
+
- lib/samagotchi/bridge/turn_notice.rb
|
|
153
154
|
- lib/samagotchi/bridge_client.rb
|
|
154
155
|
- lib/samagotchi/bridge_client/event_stream.rb
|
|
155
156
|
- lib/samagotchi/bridge_client/sse_parser.rb
|
|
157
|
+
- lib/samagotchi/bundle_command.rb
|
|
156
158
|
- lib/samagotchi/bundle_needs.rb
|
|
157
159
|
- lib/samagotchi/bundles/btw/manifest.yml
|
|
158
160
|
- lib/samagotchi/bundles/btw/plugin.rb
|
|
@@ -195,12 +197,15 @@ files:
|
|
|
195
197
|
- lib/samagotchi/desktop/macos/Hotkey.swift
|
|
196
198
|
- lib/samagotchi/desktop/macos/Images.swift
|
|
197
199
|
- lib/samagotchi/desktop/macos/Info.plist.erb
|
|
200
|
+
- lib/samagotchi/desktop/macos/Kitty.swift
|
|
198
201
|
- lib/samagotchi/desktop/macos/Panel.swift
|
|
202
|
+
- lib/samagotchi/desktop/macos/ProcessRunner.swift
|
|
199
203
|
- lib/samagotchi/desktop_command.rb
|
|
200
204
|
- lib/samagotchi/edit_preview.rb
|
|
201
205
|
- lib/samagotchi/empty_answer_retry.rb
|
|
202
206
|
- lib/samagotchi/engine.rb
|
|
203
207
|
- lib/samagotchi/gem_update.rb
|
|
208
|
+
- lib/samagotchi/guardrail_wiring.rb
|
|
204
209
|
- lib/samagotchi/guardrails.rb
|
|
205
210
|
- lib/samagotchi/guardrails/approval.rb
|
|
206
211
|
- lib/samagotchi/guardrails/approvals.rb
|
|
@@ -216,6 +221,7 @@ files:
|
|
|
216
221
|
- lib/samagotchi/hooks/bundle_loader.rb
|
|
217
222
|
- lib/samagotchi/hooks/loader.rb
|
|
218
223
|
- lib/samagotchi/hooks/registry.rb
|
|
224
|
+
- lib/samagotchi/hooks/stream_watch.rb
|
|
219
225
|
- lib/samagotchi/host_registry.rb
|
|
220
226
|
- lib/samagotchi/idle_client.rb
|
|
221
227
|
- lib/samagotchi/idle_recap.rb
|
|
@@ -269,9 +275,12 @@ files:
|
|
|
269
275
|
- lib/samagotchi/plugin/sessions.rb
|
|
270
276
|
- lib/samagotchi/plugin/side_question.rb
|
|
271
277
|
- lib/samagotchi/plugin/tool_result.rb
|
|
278
|
+
- lib/samagotchi/plugin_tasks.rb
|
|
272
279
|
- lib/samagotchi/project_scope.rb
|
|
273
280
|
- lib/samagotchi/prompt.rb
|
|
274
281
|
- lib/samagotchi/prompt_literal_guard.rb
|
|
282
|
+
- lib/samagotchi/question_desk.rb
|
|
283
|
+
- lib/samagotchi/recap_setup.rb
|
|
275
284
|
- lib/samagotchi/recap_store.rb
|
|
276
285
|
- lib/samagotchi/reminder_store.rb
|
|
277
286
|
- lib/samagotchi/reply_wait.rb
|
|
@@ -286,6 +295,7 @@ files:
|
|
|
286
295
|
- lib/samagotchi/session_manager.rb
|
|
287
296
|
- lib/samagotchi/session_metrics.rb
|
|
288
297
|
- lib/samagotchi/session_observer.rb
|
|
298
|
+
- lib/samagotchi/sessions_command.rb
|
|
289
299
|
- lib/samagotchi/steer.rb
|
|
290
300
|
- lib/samagotchi/system_prompt.rb
|
|
291
301
|
- lib/samagotchi/terminal_ui.rb
|
|
@@ -360,7 +370,6 @@ files:
|
|
|
360
370
|
- lib/samagotchi/web/public/annotations.js
|
|
361
371
|
- lib/samagotchi/web/public/app.js
|
|
362
372
|
- lib/samagotchi/web/public/card.js
|
|
363
|
-
- lib/samagotchi/web/public/chat_view.js
|
|
364
373
|
- lib/samagotchi/web/public/chunk_router.js
|
|
365
374
|
- lib/samagotchi/web/public/command_complete.js
|
|
366
375
|
- lib/samagotchi/web/public/composer_size.js
|
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
// The chat view of a running turn: one streaming answer bubble per
|
|
2
|
-
// generation, one thinking block per turn (above the first bubble) and one
|
|
3
|
-
// activity panel per turn (at the first tool call). This module owns that
|
|
4
|
-
// per-turn DOM; app.js's stream handlers call it and keep the rest (prompt
|
|
5
|
-
// bubbles, cards, the timing line, the session state). turn_view.js offers
|
|
6
|
-
// the same interface with a different placement; app.js picks one at boot.
|
|
7
|
-
//
|
|
8
|
-
// deps: historyEl, appendToHistory(el) (above the live timing line),
|
|
9
|
-
// isNearBottom(), sawText(normalizedText) (the turn output dedupe),
|
|
10
|
-
// sessionId() (image thumbs of a tool row).
|
|
11
|
-
import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
|
|
12
|
-
import { copySourceAttr } from "./copy.js";
|
|
13
|
-
import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
14
|
-
import { activityDiffHtml } from "./diff_view.js";
|
|
15
|
-
import { thumbsHtml } from "./images.js";
|
|
16
|
-
import { shouldFollowScroll } from "./scroll.js";
|
|
17
|
-
import { tallyText } from "./tally.js";
|
|
18
|
-
import { cancelLineHtml, formatDuration, timedTurnIndexes, turnRecordAt, turnTimingText } from "./timing.js";
|
|
19
|
-
|
|
20
|
-
// A row's status (live, or a saved tool record's on reload): running,
|
|
21
|
-
// error, stopped (a task_wait the user's Stop ended), else ok ("done").
|
|
22
|
-
export function activityStatusClass(status) {
|
|
23
|
-
if (status === "running") return "running";
|
|
24
|
-
if (status === "error") return "error";
|
|
25
|
-
if (status === "stopped") return "stopped";
|
|
26
|
-
return "ok";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function activityStatusLabel(status) {
|
|
30
|
-
if (status === "running") return "running";
|
|
31
|
-
if (status === "error") return "error";
|
|
32
|
-
if (status === "stopped") return "stopped";
|
|
33
|
-
return "done";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function createChatView({ historyEl, appendToHistory, isNearBottom, sawText, sessionId }) {
|
|
37
|
-
let streamingBubble = null;
|
|
38
|
-
let streamingBuffer = "";
|
|
39
|
-
let rafPending = false;
|
|
40
|
-
let activityModel = null;
|
|
41
|
-
let activityEl = null;
|
|
42
|
-
let activityElBody = null;
|
|
43
|
-
const activityRowEls = new Map();
|
|
44
|
-
// One live "thinking" block per turn (Claude-like); streams :generation_chunk
|
|
45
|
-
// `thinking` deltas and auto-collapses on turn_completed.
|
|
46
|
-
let thinkingEl = null;
|
|
47
|
-
let thinkingBodyEl = null;
|
|
48
|
-
let thinkingBuffer = "";
|
|
49
|
-
|
|
50
|
-
function removeHint() {
|
|
51
|
-
const hint = historyEl.querySelector(".hint");
|
|
52
|
-
if (hint) hint.remove();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function ensureStreamingBubble() {
|
|
56
|
-
if (streamingBubble) return streamingBubble;
|
|
57
|
-
removeHint();
|
|
58
|
-
streamingBubble = document.createElement("div");
|
|
59
|
-
streamingBubble.className = "bubble output streaming";
|
|
60
|
-
streamingBubble.textContent = "";
|
|
61
|
-
appendToHistory(streamingBubble);
|
|
62
|
-
return streamingBubble;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function flush() {
|
|
66
|
-
rafPending = false;
|
|
67
|
-
// Runs every animation frame while streaming — must not yank a user who
|
|
68
|
-
// scrolled up to read; only follow when they were already near the bottom.
|
|
69
|
-
const follow = isNearBottom();
|
|
70
|
-
// An empty buffer keeps the "…" placeholder.
|
|
71
|
-
if (streamingBubble && streamingBuffer) {
|
|
72
|
-
streamingBubble.textContent = streamingBuffer;
|
|
73
|
-
}
|
|
74
|
-
if (thinkingBodyEl) {
|
|
75
|
-
// The capped thinking box follows its own tail the same way, unless the
|
|
76
|
-
// user scrolled up inside it.
|
|
77
|
-
const followThinking = shouldFollowScroll(thinkingBodyEl, 24);
|
|
78
|
-
thinkingBodyEl.textContent = thinkingBuffer;
|
|
79
|
-
if (followThinking) thinkingBodyEl.scrollTop = thinkingBodyEl.scrollHeight;
|
|
80
|
-
}
|
|
81
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function scheduleFlush() {
|
|
85
|
-
if (rafPending) return;
|
|
86
|
-
rafPending = true;
|
|
87
|
-
requestAnimationFrame(flush);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function appendToken(content) {
|
|
91
|
-
if (!content) return;
|
|
92
|
-
// Leading whitespace right after a closed think/tool_call block (or at the
|
|
93
|
-
// start of the stream) would otherwise render as an empty first line in
|
|
94
|
-
// the pre-wrap bubble. Trim it only at the start of a bubble so mid-bubble
|
|
95
|
-
// paragraph spacing is preserved.
|
|
96
|
-
if (streamingBuffer === "") content = content.replace(/^[ \t\r\n]+/, "");
|
|
97
|
-
if (!content) return;
|
|
98
|
-
streamingBuffer += content;
|
|
99
|
-
ensureStreamingBubble();
|
|
100
|
-
scheduleFlush();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ── Per-turn live thinking block ─────────────────────────────────────────
|
|
104
|
-
// Routed from :generation_chunk `thinking`. One <details> per turn; accumulates
|
|
105
|
-
// across the turn's generations and collapses on turn completion.
|
|
106
|
-
|
|
107
|
-
function ensureThinkingEl() {
|
|
108
|
-
if (thinkingEl) return thinkingEl;
|
|
109
|
-
removeHint();
|
|
110
|
-
const details = document.createElement("details");
|
|
111
|
-
details.className = "bubble thinking";
|
|
112
|
-
details.open = true;
|
|
113
|
-
const summary = document.createElement("summary");
|
|
114
|
-
summary.textContent = "thinking";
|
|
115
|
-
details.appendChild(summary);
|
|
116
|
-
const body = document.createElement("div");
|
|
117
|
-
body.className = "thinking-body";
|
|
118
|
-
details.appendChild(body);
|
|
119
|
-
thinkingEl = details;
|
|
120
|
-
thinkingBodyEl = body;
|
|
121
|
-
// Prefer Claude-like ordering: thinking above the streaming answer bubble.
|
|
122
|
-
if (streamingBubble && streamingBubble.parentNode === historyEl) {
|
|
123
|
-
historyEl.insertBefore(details, streamingBubble);
|
|
124
|
-
} else {
|
|
125
|
-
appendToHistory(details);
|
|
126
|
-
}
|
|
127
|
-
return details;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function appendThinking(content) {
|
|
131
|
-
// The box starts at the first visible character, not the "\n" after <think>.
|
|
132
|
-
const piece = leadTrimmed(thinkingBuffer, content);
|
|
133
|
-
if (!piece) return;
|
|
134
|
-
thinkingBuffer += piece;
|
|
135
|
-
ensureThinkingEl();
|
|
136
|
-
scheduleFlush();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function finalizeThinkingEl() {
|
|
140
|
-
if (thinkingBodyEl) thinkingBodyEl.textContent = thinkingBuffer.replace(/[ \t\r\n]+$/, "");
|
|
141
|
-
if (thinkingEl) thinkingEl.open = false;
|
|
142
|
-
thinkingBuffer = "";
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function resetThinkingState() {
|
|
146
|
-
thinkingEl = null;
|
|
147
|
-
thinkingBodyEl = null;
|
|
148
|
-
thinkingBuffer = "";
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function finalizeStreaming() {
|
|
152
|
-
if (streamingBubble) {
|
|
153
|
-
const k = normalize(streamingBuffer);
|
|
154
|
-
// The text lands in the bubble on the next animation frame; a generation
|
|
155
|
-
// can end before one runs (a burst of frames, a hidden tab), so write it
|
|
156
|
-
// here too or the bubble keeps its "…" placeholder.
|
|
157
|
-
if (k) {
|
|
158
|
-
streamingBubble.textContent = streamingBuffer;
|
|
159
|
-
sawText(k);
|
|
160
|
-
}
|
|
161
|
-
else streamingBubble.remove(); // drop text-less bubbles (thinking/tool-only generations, "…" placeholder)
|
|
162
|
-
streamingBubble.classList.remove("streaming");
|
|
163
|
-
streamingBubble = null;
|
|
164
|
-
}
|
|
165
|
-
streamingBuffer = "";
|
|
166
|
-
rafPending = false;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ── Per-turn tool/memory activity panel ──────────────────────────────────
|
|
170
|
-
// One collapsible <details> per turn; each :tool_call_started/completed upserts
|
|
171
|
-
// a row (keyed by iteration:call_index) that transitions running -> ok/error.
|
|
172
|
-
// Memory reads/writes flow through the same tool_call events, so they show here too.
|
|
173
|
-
|
|
174
|
-
function ensureActivityEl() {
|
|
175
|
-
if (activityEl) return activityEl;
|
|
176
|
-
removeHint();
|
|
177
|
-
const details = document.createElement("details");
|
|
178
|
-
details.className = "bubble activity";
|
|
179
|
-
details.open = true;
|
|
180
|
-
const summary = document.createElement("summary");
|
|
181
|
-
summary.textContent = "activity";
|
|
182
|
-
details.appendChild(summary);
|
|
183
|
-
const body = document.createElement("div");
|
|
184
|
-
body.className = "activity-body";
|
|
185
|
-
details.appendChild(body);
|
|
186
|
-
activityEl = details;
|
|
187
|
-
activityElBody = body;
|
|
188
|
-
appendToHistory(details);
|
|
189
|
-
return details;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function renderActivityRow(row) {
|
|
193
|
-
const el = document.createElement("div");
|
|
194
|
-
el.className = "activity-row";
|
|
195
|
-
el.dataset.key = row.key;
|
|
196
|
-
const statusEl = document.createElement("span");
|
|
197
|
-
statusEl.className = "activity-status";
|
|
198
|
-
const toolEl = document.createElement("span");
|
|
199
|
-
toolEl.className = "activity-tool";
|
|
200
|
-
toolEl.textContent = toolName(row);
|
|
201
|
-
const paramsEl = document.createElement("span");
|
|
202
|
-
paramsEl.className = "activity-params";
|
|
203
|
-
const outEl = document.createElement("div");
|
|
204
|
-
outEl.className = "activity-output";
|
|
205
|
-
outEl.style.display = "none";
|
|
206
|
-
el.appendChild(statusEl);
|
|
207
|
-
el.appendChild(toolEl);
|
|
208
|
-
el.appendChild(paramsEl);
|
|
209
|
-
el.appendChild(outEl);
|
|
210
|
-
activityRowEls.set(row.key, el);
|
|
211
|
-
const follow = isNearBottom();
|
|
212
|
-
(activityElBody || ensureActivityEl().querySelector(".activity-body")).appendChild(el);
|
|
213
|
-
updateActivityRowEl(row);
|
|
214
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function updateActivityRowEl(row) {
|
|
218
|
-
const el = activityRowEls.get(row.key);
|
|
219
|
-
if (!el) return;
|
|
220
|
-
fillActivityRow(el, row, sessionId());
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function updateActivitySummary() {
|
|
224
|
-
if (!activityEl) return;
|
|
225
|
-
const summary = activityEl.querySelector("summary");
|
|
226
|
-
if (!summary) return;
|
|
227
|
-
// From the 3rd call: "activity · 12 tool calls (2 failed) · execute ×7 · …" (the rows show the last call).
|
|
228
|
-
const tally = tallyText(activityModel?.rows, { last: false });
|
|
229
|
-
summary.textContent = tally ? `activity · ${tally}` : `activity (${activityCount(activityModel)})`;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function finalizeActivityEl() {
|
|
233
|
-
if (activityModel) finalizeActivity(activityModel);
|
|
234
|
-
if (activityEl) activityEl.open = false;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function resetActivityState() {
|
|
238
|
-
activityModel = null;
|
|
239
|
-
activityEl = null;
|
|
240
|
-
activityElBody = null;
|
|
241
|
-
activityRowEls.clear();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function toolEvent(data, add) {
|
|
245
|
-
const model = activityModel || (activityModel = newActivity());
|
|
246
|
-
ensureActivityEl();
|
|
247
|
-
const row = add(model, data);
|
|
248
|
-
if (!activityRowEls.has(row.key)) renderActivityRow(row);
|
|
249
|
-
else updateActivityRowEl(row);
|
|
250
|
-
updateActivitySummary();
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return {
|
|
254
|
-
turnStarted(data) {
|
|
255
|
-
resetActivityState();
|
|
256
|
-
resetThinkingState();
|
|
257
|
-
streamingBuffer = "";
|
|
258
|
-
ensureStreamingBubble();
|
|
259
|
-
if (data.prompt) streamingBubble.textContent = "…";
|
|
260
|
-
},
|
|
261
|
-
generationStarted() {
|
|
262
|
-
// A generation left open (a join mid-way) ends where the next begins.
|
|
263
|
-
finalizeStreaming();
|
|
264
|
-
ensureStreamingBubble();
|
|
265
|
-
},
|
|
266
|
-
chunk({ text, thinking }) {
|
|
267
|
-
if (text) appendToken(text);
|
|
268
|
-
if (thinking) appendThinking(thinking);
|
|
269
|
-
},
|
|
270
|
-
generationCompleted() {
|
|
271
|
-
finalizeStreaming();
|
|
272
|
-
},
|
|
273
|
-
toolStarted(data) {
|
|
274
|
-
toolEvent(data, (model, event) => addStarted(model, event).row);
|
|
275
|
-
},
|
|
276
|
-
toolCompleted(data) {
|
|
277
|
-
toolEvent(data, addCompleted);
|
|
278
|
-
},
|
|
279
|
-
// Everything a turn's end (completed, canceled, failed) closes.
|
|
280
|
-
turnEnded() {
|
|
281
|
-
finalizeStreaming();
|
|
282
|
-
finalizeActivityEl();
|
|
283
|
-
finalizeThinkingEl();
|
|
284
|
-
},
|
|
285
|
-
// A hook's notice stays the page's bubble in this view.
|
|
286
|
-
notice() {
|
|
287
|
-
return false;
|
|
288
|
-
},
|
|
289
|
-
// So is a plugin's steer.
|
|
290
|
-
steer() {
|
|
291
|
-
return false;
|
|
292
|
-
},
|
|
293
|
-
// So is a card.
|
|
294
|
-
card() {
|
|
295
|
-
return false;
|
|
296
|
-
},
|
|
297
|
-
flush,
|
|
298
|
-
// The element is rewritten every frame while the turn runs (annotate
|
|
299
|
-
// skips it).
|
|
300
|
-
containsLive(el) {
|
|
301
|
-
return !!thinkingEl?.contains(el);
|
|
302
|
-
},
|
|
303
|
-
reset() {
|
|
304
|
-
streamingBubble = null;
|
|
305
|
-
streamingBuffer = "";
|
|
306
|
-
rafPending = false;
|
|
307
|
-
resetActivityState();
|
|
308
|
-
resetThinkingState();
|
|
309
|
-
},
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// One tool row's status, name, params, output and image thumbs from its
|
|
314
|
-
// activity row (activity.js). Shared with the turn view.
|
|
315
|
-
// A reloaded history in the chat view (renderHistory's object path): one
|
|
316
|
-
// bubble per message; each turn's timing line under its last bubble, then
|
|
317
|
-
// its cancel line, where the live view leaves them.
|
|
318
|
-
// +thumbs(images)+ draws a prompt's image thumbs.
|
|
319
|
-
export function chatHistoryHtml(items, timing, { thumbs }) {
|
|
320
|
-
const timedTurns = timedTurnIndexes(items);
|
|
321
|
-
return items
|
|
322
|
-
.map((m, i) => {
|
|
323
|
-
if (m.role === "note") return `<div class="bubble note">${noteHtml(m)}</div>`;
|
|
324
|
-
if (m.role === "steer") return steerRowHtml({ source: m.source, text: m.content }, { bubble: true });
|
|
325
|
-
const turnIndex = timedTurns[i];
|
|
326
|
-
const cls = m.role === "user" ? "user" : "output";
|
|
327
|
-
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
328
|
-
const markdown = renderedMarkdown ? " markdown" : "";
|
|
329
|
-
const record = turnIndex === null ? null : turnRecordAt(timing, turnIndex);
|
|
330
|
-
const timingHtml = record && formatDuration(record.duration_ms)
|
|
331
|
-
? `<div class="turn-timing">${escapeHtml(turnTimingText(turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
|
|
332
|
-
: "";
|
|
333
|
-
// A canceled turn ends with its cancel line, as the live view left it.
|
|
334
|
-
const cancelHtml = record ? cancelLineHtml(record, escapeHtml) : "";
|
|
335
|
-
// A user bubble is a flex row (label, text, badge): its text sits in
|
|
336
|
-
// one block so quotes stack instead of lining up side by side.
|
|
337
|
-
const inner = m.role === "user" ? `<div class="user-message">${body}${thumbs(m.images)}</div>` : body;
|
|
338
|
-
// The timing line goes after the bubble, not in it: under the answer,
|
|
339
|
-
// or under the prompt of a turn canceled before any answer.
|
|
340
|
-
return `<div class="bubble ${cls}${markdown}"${copySourceAttr(m, renderedMarkdown)}>${inner}</div>${timingHtml}${cancelHtml}`;
|
|
341
|
-
})
|
|
342
|
-
.join("");
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export function fillActivityRow(el, row, sessionId) {
|
|
346
|
-
const statusEl = el.querySelector(".activity-status");
|
|
347
|
-
statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
|
|
348
|
-
statusEl.textContent = activityStatusLabel(row.status);
|
|
349
|
-
const toolEl = el.querySelector(".activity-tool");
|
|
350
|
-
if (toolEl) toolEl.textContent = toolName(row);
|
|
351
|
-
const paramsEl = el.querySelector(".activity-params");
|
|
352
|
-
if (paramsEl) {
|
|
353
|
-
// The title in place of the params; the full params line on hover.
|
|
354
|
-
paramsEl.textContent = row.title || row.params || "";
|
|
355
|
-
paramsEl.title = row.title && row.params ? row.params : "";
|
|
356
|
-
}
|
|
357
|
-
const outEl = el.querySelector(".activity-output");
|
|
358
|
-
if (outEl) {
|
|
359
|
-
if (row.status === "running") {
|
|
360
|
-
outEl.style.display = "none";
|
|
361
|
-
outEl.textContent = "";
|
|
362
|
-
outEl.title = "";
|
|
363
|
-
} else {
|
|
364
|
-
// dispatch prefixes output with "[tool]"; strip that noise for display.
|
|
365
|
-
const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
366
|
-
outEl.title = full;
|
|
367
|
-
outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
|
|
368
|
-
outEl.style.display = full ? "" : "none";
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
// The images a tool returned (the chat view's reloaded history has no tool
|
|
372
|
-
// rows; the turn view's has, turn_view.js reloadRowHtml).
|
|
373
|
-
if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
|
|
374
|
-
// An edit/write's change, collapsed: "diff +3 −1".
|
|
375
|
-
if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
|
|
376
|
-
}
|