samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// The turn view of a running turn (web.turn_view): one <details
|
|
2
|
+
// class="turn-work"> per turn holding one <details class="gen"> per
|
|
3
|
+
// generation, the current one live (open, at the bottom), the earlier ones
|
|
4
|
+
// collapsed to one line each. At the turn's end the last generation's
|
|
5
|
+
// narration moves out of the block as the answer bubble, so markdown,
|
|
6
|
+
// annotate and the turn output dedupe work as in the chat view. The same
|
|
7
|
+
// interface as chat_view.js; the grouping rules live in turn_model.js.
|
|
8
|
+
//
|
|
9
|
+
// A block that holds one gen with no thinking and no tool rows hides its
|
|
10
|
+
// chrome (CSS :has), so a plain answer streams like today's bubble, and is
|
|
11
|
+
// removed at the end: the answer bubble takes its place.
|
|
12
|
+
import { toolName } from "./activity.js";
|
|
13
|
+
import { leavesBlock } from "./card.js";
|
|
14
|
+
import { activityStatusClass, activityStatusLabel, fillActivityRow } from "./chat_view.js";
|
|
15
|
+
import { copySourceAttr } from "./copy.js";
|
|
16
|
+
import { escapeHtml, messageBodyHtml, noteHtml, normalize } from "./format.js";
|
|
17
|
+
import { shouldFollowScroll } from "./scroll.js";
|
|
18
|
+
import { hookNoticeLabel } from "./turn_events.js";
|
|
19
|
+
import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
20
|
+
import { createSentenceFeed } from "./sentences.js";
|
|
21
|
+
import { createTicker } from "./thinking_ticker.js";
|
|
22
|
+
import * as model from "./turn_model.js";
|
|
23
|
+
import { createHold } from "./hold.js";
|
|
24
|
+
|
|
25
|
+
export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawText, sessionId }) {
|
|
26
|
+
let turn = null;
|
|
27
|
+
let blockEl = null;
|
|
28
|
+
let blockSummaryEl = null;
|
|
29
|
+
let blockToggled = false;
|
|
30
|
+
// gen → { el, summary, thinking, text, activity, rows: Map(key → el), toggled }
|
|
31
|
+
const genEls = new Map();
|
|
32
|
+
const dirty = new Set();
|
|
33
|
+
let rafPending = false;
|
|
34
|
+
// The promoted answer bubble while it is held at the box's height (the
|
|
35
|
+
// pop, D6).
|
|
36
|
+
const hold = createHold({ isNearBottom, follow: () => { historyEl.scrollTop = historyEl.scrollHeight; } });
|
|
37
|
+
|
|
38
|
+
function removeHint() {
|
|
39
|
+
const hint = historyEl.querySelector(".hint");
|
|
40
|
+
if (hint) hint.remove();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A hand toggle: the code's auto-collapse then leaves it as the user left it.
|
|
44
|
+
function rememberToggle(summary, mark) {
|
|
45
|
+
summary.addEventListener("click", mark);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createBlock() {
|
|
49
|
+
removeHint();
|
|
50
|
+
blockEl = document.createElement("details");
|
|
51
|
+
blockEl.className = "turn-work";
|
|
52
|
+
blockEl.open = true;
|
|
53
|
+
blockToggled = false;
|
|
54
|
+
blockSummaryEl = document.createElement("summary");
|
|
55
|
+
blockEl.appendChild(blockSummaryEl);
|
|
56
|
+
rememberToggle(blockSummaryEl, () => { blockToggled = true; });
|
|
57
|
+
updateBlockSummary();
|
|
58
|
+
appendToHistory(blockEl);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function updateBlockSummary() {
|
|
62
|
+
if (blockSummaryEl && turn) blockSummaryEl.textContent = model.blockSummary(turn, { running: !turn.ended });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createGenEl(gen) {
|
|
66
|
+
const el = document.createElement("details");
|
|
67
|
+
el.className = "gen live";
|
|
68
|
+
el.open = true;
|
|
69
|
+
const summary = document.createElement("summary");
|
|
70
|
+
summary.textContent = `step ${turn.gens.length}`;
|
|
71
|
+
el.appendChild(summary);
|
|
72
|
+
const text = document.createElement("div");
|
|
73
|
+
text.className = "gen-text streaming";
|
|
74
|
+
// A step after the first shows "…" until its text comes (the first one
|
|
75
|
+
// only for a prompt, as the chat view's bubble).
|
|
76
|
+
if (turn.gens.length > 1) text.textContent = "…";
|
|
77
|
+
el.appendChild(text);
|
|
78
|
+
const entry = { el, summary, thinking: null, text, activity: null, rows: new Map(), toggled: false,
|
|
79
|
+
thinkingWrap: null, thinkingLine: null, ticker: null, tickerTimer: null, thinkingWritten: 0, thinkingToggled: false,
|
|
80
|
+
feed: createSentenceFeed(), box: null };
|
|
81
|
+
rememberToggle(summary, () => { entry.toggled = true; });
|
|
82
|
+
genEls.set(gen, entry);
|
|
83
|
+
const follow = isNearBottom();
|
|
84
|
+
blockEl.appendChild(el);
|
|
85
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The step's thinking as its own collapsed <details class="thinking"> (the
|
|
90
|
+
// ticker lives in its summary) before the narration; entry.thinking stays
|
|
91
|
+
// the body, entry.thinkingWrap the <details>, entry.thinkingLine its summary.
|
|
92
|
+
function thinkingEl(gen) {
|
|
93
|
+
const entry = genEls.get(gen);
|
|
94
|
+
if (!entry.thinking) {
|
|
95
|
+
entry.thinkingWrap = document.createElement("details");
|
|
96
|
+
entry.thinkingWrap.className = "thinking";
|
|
97
|
+
entry.thinkingLine = document.createElement("summary");
|
|
98
|
+
entry.thinkingLine.textContent = "thinking";
|
|
99
|
+
entry.thinkingWrap.appendChild(entry.thinkingLine);
|
|
100
|
+
entry.thinking = document.createElement("div");
|
|
101
|
+
entry.thinking.className = "thinking-body";
|
|
102
|
+
entry.thinkingWrap.appendChild(entry.thinking);
|
|
103
|
+
// A real click on the summary is a hand toggle of *this* step: its wrap
|
|
104
|
+
// then stays as the user left it at the step's end. A peek is per step
|
|
105
|
+
// (the page is for glancing): later steps start closed. The toggle
|
|
106
|
+
// event (queued after <details>.open flips) only scrolls the body to
|
|
107
|
+
// its tail on open.
|
|
108
|
+
rememberToggle(entry.thinkingLine, () => { entry.thinkingToggled = true; });
|
|
109
|
+
entry.thinkingWrap.addEventListener("toggle", () => {
|
|
110
|
+
if (entry.thinkingWrap.open) entry.thinking.scrollTop = entry.thinking.scrollHeight;
|
|
111
|
+
});
|
|
112
|
+
entry.ticker = createTicker({ now: () => Date.now(), dwellMs: 1500 });
|
|
113
|
+
entry.el.insertBefore(entry.thinkingWrap, entry.text);
|
|
114
|
+
}
|
|
115
|
+
return entry.thinking;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The ticker's newest sentence into the summary: "thinking · <line>", the
|
|
119
|
+
// prefix a <span class="thinking-tag">, the sentence a text node.
|
|
120
|
+
function setThinkingLine(entry, sentence) {
|
|
121
|
+
entry.thinkingLine.textContent = "";
|
|
122
|
+
const tag = document.createElement("span");
|
|
123
|
+
tag.className = "thinking-tag";
|
|
124
|
+
tag.textContent = "thinking · ";
|
|
125
|
+
entry.thinkingLine.appendChild(tag);
|
|
126
|
+
entry.thinkingLine.appendChild(document.createTextNode(sentence));
|
|
127
|
+
// A subtle fade on change; the class is removed at close (reload parity:
|
|
128
|
+
// the re-rendered summary is plain "thinking").
|
|
129
|
+
entry.thinkingLine.classList.remove("thinking-fade");
|
|
130
|
+
void entry.thinkingLine.offsetWidth;
|
|
131
|
+
entry.thinkingLine.classList.add("thinking-fade");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Feed the step's ticker from its accumulated thinking; the caller's timer
|
|
135
|
+
// (entry.tickerTimer) shows a pending line when its dwell elapses.
|
|
136
|
+
function feedTicker(gen) {
|
|
137
|
+
const entry = genEls.get(gen);
|
|
138
|
+
const { line, changed, dueIn } = entry.ticker.feed(gen.thinking);
|
|
139
|
+
if (changed) setThinkingLine(entry, line);
|
|
140
|
+
else if (dueIn > 0 && !entry.tickerTimer) {
|
|
141
|
+
entry.tickerTimer = setTimeout(() => {
|
|
142
|
+
entry.tickerTimer = null;
|
|
143
|
+
const r = entry.ticker.tick();
|
|
144
|
+
if (r.changed) setThinkingLine(entry, r.line);
|
|
145
|
+
}, dueIn);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The live step's narration box (D1–D4 of the narration-box plan): the
|
|
150
|
+
// complete sentences so far, one per line, the newest last, inside
|
|
151
|
+
// entry.text (so the plain-answer chrome and containsLive see one element).
|
|
152
|
+
// Created on the first sentence, in place of the "…" placeholder.
|
|
153
|
+
function narrationBox(entry) {
|
|
154
|
+
if (!entry.box) {
|
|
155
|
+
entry.text.textContent = "";
|
|
156
|
+
entry.box = document.createElement("div");
|
|
157
|
+
entry.box.className = "narration";
|
|
158
|
+
entry.text.appendChild(entry.box);
|
|
159
|
+
}
|
|
160
|
+
return entry.box;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Append complete sentences to the box, which follows its own tail unless
|
|
164
|
+
// the user scrolled up inside it to read (decided before the append: a
|
|
165
|
+
// burst lands several lines at once).
|
|
166
|
+
function appendSentences(entry, sentences) {
|
|
167
|
+
if (!sentences.length) return;
|
|
168
|
+
const box = narrationBox(entry);
|
|
169
|
+
const stick = shouldFollowScroll(box, 24);
|
|
170
|
+
for (const sentence of sentences) {
|
|
171
|
+
const line = document.createElement("div");
|
|
172
|
+
line.className = "sentence";
|
|
173
|
+
line.textContent = sentence;
|
|
174
|
+
box.appendChild(line);
|
|
175
|
+
}
|
|
176
|
+
if (stick) box.scrollTop = box.scrollHeight;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function activityEl(gen) {
|
|
180
|
+
const entry = genEls.get(gen);
|
|
181
|
+
if (!entry.activity) {
|
|
182
|
+
entry.activity = document.createElement("div");
|
|
183
|
+
entry.activity.className = "activity-body";
|
|
184
|
+
entry.el.appendChild(entry.activity);
|
|
185
|
+
}
|
|
186
|
+
return entry.activity;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The gen's text and thinking into its elements. The thinking is appended by
|
|
190
|
+
// delta (a selection survives; a full rewrite would drop it) and rewritten
|
|
191
|
+
// only when it shrank (the close's rtrim); the ticker advances from it while
|
|
192
|
+
// the step is live. The live narration goes into the box by complete
|
|
193
|
+
// sentences; a closed gen's text (the close itself, a late chunk via genFor)
|
|
194
|
+
// is written in full. The trailing whitespace is trimmed by the model at
|
|
195
|
+
// close.
|
|
196
|
+
function writeGen(gen, { follow = false } = {}) {
|
|
197
|
+
const entry = genEls.get(gen);
|
|
198
|
+
if (!entry) return;
|
|
199
|
+
if (gen.thinking) {
|
|
200
|
+
const body = thinkingEl(gen);
|
|
201
|
+
const written = entry.thinkingWritten;
|
|
202
|
+
if (gen.thinking.length >= written) {
|
|
203
|
+
if (gen.thinking.length > written) {
|
|
204
|
+
body.appendChild(document.createTextNode(gen.thinking.slice(written)));
|
|
205
|
+
entry.thinkingWritten = gen.thinking.length;
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
body.textContent = gen.thinking;
|
|
209
|
+
entry.thinkingWritten = gen.thinking.length;
|
|
210
|
+
}
|
|
211
|
+
if (follow && shouldFollowScroll(body, 24)) body.scrollTop = body.scrollHeight;
|
|
212
|
+
// Late chunks reach closed gens (genFor); their ticker stays still.
|
|
213
|
+
if (!gen.closed) feedTicker(gen);
|
|
214
|
+
}
|
|
215
|
+
// An empty text keeps the "…" placeholder.
|
|
216
|
+
if (!gen.text) return;
|
|
217
|
+
if (gen.closed) entry.text.textContent = gen.text;
|
|
218
|
+
else appendSentences(entry, entry.feed.feed(gen.text));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function flush() {
|
|
222
|
+
rafPending = false;
|
|
223
|
+
// Runs every animation frame while streaming — must not yank a user who
|
|
224
|
+
// scrolled up to read; only follow when they were already near the bottom.
|
|
225
|
+
const follow = isNearBottom();
|
|
226
|
+
for (const gen of dirty) writeGen(gen, { follow: true });
|
|
227
|
+
dirty.clear();
|
|
228
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function scheduleFlush(gen) {
|
|
232
|
+
dirty.add(gen);
|
|
233
|
+
if (rafPending) return;
|
|
234
|
+
rafPending = true;
|
|
235
|
+
requestAnimationFrame(flush);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A step left with its thinking only would read "thinking" twice (its own
|
|
239
|
+
// summary and its wrap's): the wrap goes and the body sits directly under
|
|
240
|
+
// the step's summary, as turnHistoryHtml draws it.
|
|
241
|
+
function unwrapThinking(entry) {
|
|
242
|
+
if (!entry.thinkingWrap) return;
|
|
243
|
+
if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
|
|
244
|
+
entry.ticker.close();
|
|
245
|
+
entry.thinkingWrap.replaceWith(entry.thinking);
|
|
246
|
+
entry.thinkingWrap = null;
|
|
247
|
+
entry.thinkingLine = null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A closed gen: its final text, collapsed to its label (unless the user
|
|
251
|
+
// opened it by hand), no longer live.
|
|
252
|
+
function closeGenEl(gen) {
|
|
253
|
+
const entry = genEls.get(gen);
|
|
254
|
+
if (!entry) return;
|
|
255
|
+
dirty.delete(gen);
|
|
256
|
+
writeGen(gen);
|
|
257
|
+
// The box gone: the step's full text (rtrimmed by the model at close),
|
|
258
|
+
// today's closed-step DOM and the reloaded one.
|
|
259
|
+
entry.text.textContent = gen.text;
|
|
260
|
+
entry.box = null;
|
|
261
|
+
entry.text.classList.remove("streaming");
|
|
262
|
+
entry.el.classList.remove("live");
|
|
263
|
+
if (entry.thinkingWrap) {
|
|
264
|
+
// Stop the ticker, revert its summary to the plain "thinking" (reload
|
|
265
|
+
// parity: no span, no fade class) and close the wrap unless the user
|
|
266
|
+
// opened it by hand.
|
|
267
|
+
if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
|
|
268
|
+
entry.ticker.close();
|
|
269
|
+
entry.thinkingLine.textContent = "thinking";
|
|
270
|
+
entry.thinkingLine.classList.remove("thinking-fade");
|
|
271
|
+
if (!entry.thinkingToggled) entry.thinkingWrap.open = false;
|
|
272
|
+
// A thinking-only middle step (a single-gen turn is promote's plain
|
|
273
|
+
// answer, which reuses the wrap).
|
|
274
|
+
if (!gen.text && !gen.tools.length && turn.gens.length > 1) unwrapThinking(entry);
|
|
275
|
+
}
|
|
276
|
+
entry.summary.textContent = model.genLabel(gen);
|
|
277
|
+
if (!entry.toggled) entry.el.open = false;
|
|
278
|
+
// The chat view puts every generation's text in seenContent.
|
|
279
|
+
const k = normalize(gen.text);
|
|
280
|
+
if (k) sawText(k);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function apply(r) {
|
|
284
|
+
if (!r) return;
|
|
285
|
+
if (r.closed) closeGenEl(r.closed);
|
|
286
|
+
if (r.opened) createGenEl(r.gen);
|
|
287
|
+
if (r.closed || r.opened) updateBlockSummary();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function toolEvent(r) {
|
|
291
|
+
apply(r);
|
|
292
|
+
const entry = genEls.get(r.gen);
|
|
293
|
+
// A step that wrote no narration before its calls drops the "…".
|
|
294
|
+
if (!r.gen.text) entry.text.textContent = "";
|
|
295
|
+
const body = activityEl(r.gen);
|
|
296
|
+
let el = entry.rows.get(r.row.key);
|
|
297
|
+
if (!el) {
|
|
298
|
+
el = document.createElement("div");
|
|
299
|
+
el.className = "activity-row";
|
|
300
|
+
el.dataset.key = r.row.key;
|
|
301
|
+
for (const [tag, cls] of [["span", "activity-status"], ["span", "activity-tool"], ["span", "activity-params"], ["div", "activity-output"]]) {
|
|
302
|
+
const part = document.createElement(tag);
|
|
303
|
+
part.className = cls;
|
|
304
|
+
el.appendChild(part);
|
|
305
|
+
}
|
|
306
|
+
el.querySelector(".activity-output").style.display = "none";
|
|
307
|
+
entry.rows.set(r.row.key, el);
|
|
308
|
+
const follow = isNearBottom();
|
|
309
|
+
body.appendChild(el);
|
|
310
|
+
fillActivityRow(el, r.row, sessionId());
|
|
311
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
312
|
+
} else {
|
|
313
|
+
fillActivityRow(el, r.row, sessionId());
|
|
314
|
+
}
|
|
315
|
+
updateBlockSummary();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
319
|
+
// of the call it judged; false with no running turn (app.js shows the
|
|
320
|
+
// bubble then).
|
|
321
|
+
function notice(data) {
|
|
322
|
+
const r = turn && model.notice(turn, data);
|
|
323
|
+
if (!r) return false;
|
|
324
|
+
const entry = genEls.get(r.gen);
|
|
325
|
+
if (!r.gen.text) entry.text.textContent = "";
|
|
326
|
+
const el = document.createElement("div");
|
|
327
|
+
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
328
|
+
el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
329
|
+
const follow = isNearBottom();
|
|
330
|
+
activityEl(r.gen).appendChild(el);
|
|
331
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A card shown during the turn (a plugin's, app.js draws it) as a row of
|
|
336
|
+
// the current step, like a notice; false with no running turn (app.js
|
|
337
|
+
// puts it between turns then).
|
|
338
|
+
function card(el) {
|
|
339
|
+
const gen = turn && !turn.ended ? model.currentGen(turn) : null;
|
|
340
|
+
const entry = gen && genEls.get(gen);
|
|
341
|
+
if (!entry) return false;
|
|
342
|
+
if (!gen.text) entry.text.textContent = "";
|
|
343
|
+
const follow = isNearBottom();
|
|
344
|
+
activityEl(gen).appendChild(el);
|
|
345
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The answer: the gen's narration moves out of the block as a bubble. With
|
|
350
|
+
// +heldHeight+ (the live box's height, measured before the step closed) the
|
|
351
|
+
// bubble is held at that height, scrolled to its tail, until answerReady()
|
|
352
|
+
// (the rendered markdown landed) or 1.5 s reveals it.
|
|
353
|
+
function promote(gen, { heldHeight = 0 } = {}) {
|
|
354
|
+
const entry = genEls.get(gen);
|
|
355
|
+
const bubble = entry.text;
|
|
356
|
+
bubble.className = "bubble output";
|
|
357
|
+
if (!gen.text) bubble.textContent = "";
|
|
358
|
+
// A hidden tab gets no pop: nobody sees it, and its timers are throttled.
|
|
359
|
+
const holding = heldHeight > 0 && !!gen.text && !document.hidden;
|
|
360
|
+
let holdHeight = 0;
|
|
361
|
+
if (holding) {
|
|
362
|
+
// Bubbles are border-box: the box's height plus the bubble's own
|
|
363
|
+
// padding and border keeps the same three lines in view.
|
|
364
|
+
const cs = getComputedStyle(bubble);
|
|
365
|
+
const chrome = ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"]
|
|
366
|
+
.reduce((sum, k) => sum + (parseFloat(cs[k]) || 0), 0);
|
|
367
|
+
holdHeight = heldHeight + chrome;
|
|
368
|
+
}
|
|
369
|
+
entry.text = document.createElement("div");
|
|
370
|
+
entry.text.className = "gen-text";
|
|
371
|
+
entry.summary.textContent = model.genLabel({ ...gen, text: "" });
|
|
372
|
+
model.takeAnswer(turn, gen);
|
|
373
|
+
// Cards and hook notices in a step that goes (below, or with the whole
|
|
374
|
+
// block for a plain answer) stay: before the answer, out of the block,
|
|
375
|
+
// where a reload puts a turn's cards; a notice as the bubble it is
|
|
376
|
+
// between turns.
|
|
377
|
+
const leaving = !gen.thinking && gen.tools.length === 0;
|
|
378
|
+
const plain = turn.gens.length === 1 && gen.tools.length === 0;
|
|
379
|
+
const cards = leaving || plain ? [...entry.el.querySelectorAll(".plugin-card, .hook-notice")] : [];
|
|
380
|
+
cards.forEach((el) => { if (el.classList.contains("hook-notice")) el.classList.add("bubble"); });
|
|
381
|
+
if (leaving) entry.el.remove();
|
|
382
|
+
if (plain) {
|
|
383
|
+
// A plain answer: no block, the bubble where it was (as the chat
|
|
384
|
+
// view); its thinking, if any, as the chat view's collapsed block.
|
|
385
|
+
const pieces = [];
|
|
386
|
+
if (gen.thinking) {
|
|
387
|
+
if (!entry.thinkingWrap) thinkingEl(gen);
|
|
388
|
+
// Reuse the live step's thinking wrap, re-classed as the plain
|
|
389
|
+
// answer's collapsed block (the same element, not a new one): plain
|
|
390
|
+
// summary, no timer, closed.
|
|
391
|
+
if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
|
|
392
|
+
entry.ticker.close();
|
|
393
|
+
entry.thinkingWrap.className = "bubble thinking";
|
|
394
|
+
entry.thinkingWrap.open = false;
|
|
395
|
+
entry.thinkingLine.textContent = "thinking";
|
|
396
|
+
pieces.push(entry.thinkingWrap);
|
|
397
|
+
}
|
|
398
|
+
pieces.push(...cards);
|
|
399
|
+
if (gen.text) pieces.push(bubble);
|
|
400
|
+
blockEl.replaceWith(...pieces);
|
|
401
|
+
blockEl = null;
|
|
402
|
+
blockSummaryEl = null;
|
|
403
|
+
} else {
|
|
404
|
+
cards.forEach((el) => appendToHistory(el));
|
|
405
|
+
if (gen.text) appendToHistory(bubble);
|
|
406
|
+
// The last step, left with its thinking only, reads "thinking" once.
|
|
407
|
+
if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
|
|
408
|
+
}
|
|
409
|
+
if (holding) hold.start(bubble, holdHeight);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Returns the cards that left the block (card.js leavesBlock), detached:
|
|
413
|
+
// app.js puts them after the turn's end line.
|
|
414
|
+
function turnEnded({ kind }) {
|
|
415
|
+
// Once per turn: a second end (a re-dispatched event) would promote again.
|
|
416
|
+
if (!turn || turn.ended) return [];
|
|
417
|
+
const last = model.currentGen(turn);
|
|
418
|
+
// A completed turn's answer is its last generation's text; a one-step
|
|
419
|
+
// turn without tools leaves the block in any case (canceled, it is the
|
|
420
|
+
// partial text bubble the chat view leaves).
|
|
421
|
+
const promoting = last && (kind === "completed" || (turn.gens.length === 1 && last.tools.length === 0));
|
|
422
|
+
// The box's height, measured while it is still in the DOM and the step
|
|
423
|
+
// still open: the apply below swaps it for the full text and collapses
|
|
424
|
+
// the step. Only a completed turn's answer pops; a canceled or failed
|
|
425
|
+
// one shows at once.
|
|
426
|
+
const box = promoting && kind === "completed" ? genEls.get(last)?.box : null;
|
|
427
|
+
const heldHeight = box ? box.getBoundingClientRect().height : 0;
|
|
428
|
+
apply(model.turnEnded(turn));
|
|
429
|
+
if (promoting) promote(last, { heldHeight });
|
|
430
|
+
const kept = [];
|
|
431
|
+
if (blockEl) {
|
|
432
|
+
updateBlockSummary();
|
|
433
|
+
blockEl.classList.add("done");
|
|
434
|
+
if (!blockToggled) blockEl.open = false;
|
|
435
|
+
for (const el of blockEl.querySelectorAll(".plugin-card")) {
|
|
436
|
+
if (leavesBlock({ warn: el.classList.contains("warn"), kind })) kept.push(el);
|
|
437
|
+
}
|
|
438
|
+
kept.forEach((el) => el.remove());
|
|
439
|
+
}
|
|
440
|
+
dirty.clear();
|
|
441
|
+
rafPending = false;
|
|
442
|
+
return kept;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function reset() {
|
|
446
|
+
hold.finish();
|
|
447
|
+
turn = null;
|
|
448
|
+
blockEl = null;
|
|
449
|
+
blockSummaryEl = null;
|
|
450
|
+
blockToggled = false;
|
|
451
|
+
// Clear every ticker timer before the map goes (a live step mid-dwell).
|
|
452
|
+
for (const entry of genEls.values()) {
|
|
453
|
+
if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
|
|
454
|
+
}
|
|
455
|
+
genEls.clear();
|
|
456
|
+
dirty.clear();
|
|
457
|
+
rafPending = false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
turnStarted(data) {
|
|
462
|
+
reset();
|
|
463
|
+
turn = model.newTurn();
|
|
464
|
+
createBlock();
|
|
465
|
+
const r = model.turnStarted(turn);
|
|
466
|
+
apply(r);
|
|
467
|
+
if (data.prompt) genEls.get(r.gen).text.textContent = "…";
|
|
468
|
+
},
|
|
469
|
+
generationStarted(data) {
|
|
470
|
+
if (!turn) this.turnStarted({});
|
|
471
|
+
apply(model.generationStarted(turn, data.iteration ?? null));
|
|
472
|
+
},
|
|
473
|
+
chunk({ text, thinking, iteration }) {
|
|
474
|
+
if (!text && !thinking) return;
|
|
475
|
+
if (!turn) this.turnStarted({});
|
|
476
|
+
const r = model.chunk(turn, { text, thinking, iteration });
|
|
477
|
+
apply(r);
|
|
478
|
+
scheduleFlush(r.gen);
|
|
479
|
+
},
|
|
480
|
+
generationCompleted() {
|
|
481
|
+
if (!turn) return;
|
|
482
|
+
const r = model.generationCompleted(turn);
|
|
483
|
+
if (!r) return;
|
|
484
|
+
// The text lands on the next animation frame; a generation can end
|
|
485
|
+
// before one runs (a burst of frames, a hidden tab).
|
|
486
|
+
writeGen(r.gen);
|
|
487
|
+
// The remainder is the step's last line: narration without a final
|
|
488
|
+
// period ("Let me check the shell first") would otherwise never show.
|
|
489
|
+
const entry = genEls.get(r.gen);
|
|
490
|
+
if (entry && r.gen.text && !r.gen.closed) appendSentences(entry, entry.feed.flush(r.gen.text));
|
|
491
|
+
},
|
|
492
|
+
toolStarted(data) {
|
|
493
|
+
if (!turn) this.turnStarted({});
|
|
494
|
+
toolEvent(model.toolStarted(turn, data));
|
|
495
|
+
},
|
|
496
|
+
toolCompleted(data) {
|
|
497
|
+
if (!turn) this.turnStarted({});
|
|
498
|
+
toolEvent(model.toolCompleted(turn, data));
|
|
499
|
+
},
|
|
500
|
+
turnEnded,
|
|
501
|
+
notice,
|
|
502
|
+
card,
|
|
503
|
+
flush,
|
|
504
|
+
// The rendered answer landed (or the re-read failed): let the held
|
|
505
|
+
// bubble pop. A no-op with nothing held.
|
|
506
|
+
answerReady() { hold.reveal(); },
|
|
507
|
+
// The live gen's narration box shows complete sentences, its thinking is
|
|
508
|
+
// appended by delta (the ticker); a selection in either is refused while
|
|
509
|
+
// the step is live — the quote lands once it closes (then on the full
|
|
510
|
+
// text).
|
|
511
|
+
containsLive(el) {
|
|
512
|
+
const gen = turn && model.currentGen(turn);
|
|
513
|
+
const entry = gen && genEls.get(gen);
|
|
514
|
+
return !!entry && (entry.text.contains(el) || !!entry.thinking?.contains(el));
|
|
515
|
+
},
|
|
516
|
+
reset,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// A reloaded tool row, the live row's markup (chat_view.js
|
|
521
|
+
// fillActivityRow): status, tool, params, output (its "[tool]" tag dropped,
|
|
522
|
+
// 300 chars, the rest on hover), plus the call's duration from its record.
|
|
523
|
+
// Params, output and the call's images come from the saved messages'
|
|
524
|
+
// parts; without them the row has the record's name, status and duration
|
|
525
|
+
// only. +thumbs(images)+ draws the images, as the live row does.
|
|
526
|
+
function reloadRowHtml(r, thumbs) {
|
|
527
|
+
const params = r.params ? `<span class="activity-params">${escapeHtml(r.params)}</span>` : "";
|
|
528
|
+
const duration = formatDuration(r.duration_ms);
|
|
529
|
+
const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
530
|
+
const output = full
|
|
531
|
+
? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
|
|
532
|
+
: "";
|
|
533
|
+
return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
|
|
534
|
+
`<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}${output}` +
|
|
535
|
+
`${r.images?.length ? thumbs(r.images) : ""}</div>`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// A reloaded history in the turn view (renderHistory's object path): per
|
|
539
|
+
// turn the prompt, a collapsed block of its steps when it had any (texts,
|
|
540
|
+
// and with the server's parts the thinking, params and output, from the
|
|
541
|
+
// saved messages; status and duration from the timing's tool records),
|
|
542
|
+
// then the answer and the turn's timing line under it.
|
|
543
|
+
// +thumbs(images)+ draws a prompt's or a tool row's image thumbs.
|
|
544
|
+
export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
545
|
+
const timingHtml = (group) => {
|
|
546
|
+
const record = group.record;
|
|
547
|
+
return record && formatDuration(record.duration_ms)
|
|
548
|
+
? `<div class="turn-timing">${escapeHtml(turnTimingText(group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
|
|
549
|
+
: "";
|
|
550
|
+
};
|
|
551
|
+
// The timing line after the bubble, not in it: where the live view
|
|
552
|
+
// leaves it at the turn's end.
|
|
553
|
+
const answerHtml = (m, timing) => {
|
|
554
|
+
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
555
|
+
return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
|
|
556
|
+
};
|
|
557
|
+
return turnGroups(items, timing).map((group) => {
|
|
558
|
+
if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
|
|
559
|
+
if (group.kind === "answer") return answerHtml(items[group.answer], "");
|
|
560
|
+
const prompt = items[group.user];
|
|
561
|
+
const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
|
|
562
|
+
const [only] = group.steps;
|
|
563
|
+
if (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking) {
|
|
564
|
+
// A plain answer with thinking: the chat view's collapsed block, as live.
|
|
565
|
+
parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
|
|
566
|
+
} else if (group.steps.length) {
|
|
567
|
+
const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
|
|
568
|
+
const rows = gens.map((gen) => {
|
|
569
|
+
// A thinking-only step: the body directly under the step's own
|
|
570
|
+
// "thinking" summary (no wrap that would repeat it), as live.
|
|
571
|
+
const body = gen.thinking ? `<div class="thinking-body">${escapeHtml(gen.thinking)}</div>` : "";
|
|
572
|
+
const thinking = body && (gen.text || gen.tools.length) ? `<details class="thinking"><summary>thinking</summary>${body}</details>` : body;
|
|
573
|
+
const text = gen.text ? `<div class="gen-text">${escapeHtml(gen.text)}</div>` : "";
|
|
574
|
+
const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
|
|
575
|
+
return `<details class="gen"><summary>${escapeHtml(model.genLabel(gen))}</summary>${thinking}${text}${tools}</details>`;
|
|
576
|
+
});
|
|
577
|
+
parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
|
|
578
|
+
}
|
|
579
|
+
// A turn with no answer (canceled, or running: no record yet) shows its
|
|
580
|
+
// timing after its prompt and steps, where the live view leaves it.
|
|
581
|
+
if (group.answer === null) parts.push(timingHtml(group));
|
|
582
|
+
else parts.push(answerHtml(items[group.answer], timingHtml(group)));
|
|
583
|
+
// A canceled turn ends with its cancel line, as the live view left it.
|
|
584
|
+
parts.push(cancelLineHtml(group.record, escapeHtml));
|
|
585
|
+
return parts.join("");
|
|
586
|
+
}).join("");
|
|
587
|
+
}
|