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,74 @@
|
|
|
1
|
+
// A plugin's card (docs/plugins.md, Cards): a title, a body and actions,
|
|
2
|
+
// each action a command line the session runs. app.js places and updates
|
|
3
|
+
// the element; this module draws it and says where it goes.
|
|
4
|
+
import { escapeHtml, recapPlace } from "./format.js";
|
|
5
|
+
|
|
6
|
+
// The card's class: a warn card wears the warning colour.
|
|
7
|
+
export function cardClass(card) {
|
|
8
|
+
return "bubble plugin-card" + (card?.level === "warn" ? " warn" : "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Whether a card shown as a row of a step leaves the turn's block when the
|
|
12
|
+
// turn ends and the block collapses (it would be hidden then): a warn card
|
|
13
|
+
// always, and any card of a turn that ended without completing (canceled,
|
|
14
|
+
// failed, the worker gone), which is often the card saying why and how to
|
|
15
|
+
// steer. It goes after the turn's end line, where a reload puts it. An info
|
|
16
|
+
// card of a completed turn stays a row of its step.
|
|
17
|
+
export function leavesBlock({ warn = false, kind = "completed" } = {}) {
|
|
18
|
+
return warn || kind !== "completed";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The body: the server's body_html (rendered markdown, sanitized; or the
|
|
22
|
+
// escaped text in a <pre>), else the plain text in a <pre> (a live event
|
|
23
|
+
// carries no html; the page's ?cards=1 re-read brings it).
|
|
24
|
+
export function cardBodyHtml(card) {
|
|
25
|
+
if (typeof card?.body_html === "string") return card.body_html;
|
|
26
|
+
const body = String(card?.body ?? "");
|
|
27
|
+
return body.trim() ? `<pre>${escapeHtml(body)}</pre>` : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The card's inner markup: its head (title, source), body and one button
|
|
31
|
+
// per action (the command as its tooltip and data).
|
|
32
|
+
export function cardInnerHtml(card) {
|
|
33
|
+
const source = card.source ? `<span class="card-source">${escapeHtml(card.source)}</span>` : "";
|
|
34
|
+
const head = `<div class="card-head"><span class="card-title">${escapeHtml(card.title || "")}</span>${source}</div>`;
|
|
35
|
+
const bodyHtml = cardBodyHtml(card);
|
|
36
|
+
const body = bodyHtml ? `<div class="card-body">${bodyHtml}</div>` : "";
|
|
37
|
+
const actions = (card.actions || []).filter((a) => a && a.command);
|
|
38
|
+
const buttons = actions.length
|
|
39
|
+
? `<div class="card-actions">${actions.map((a) =>
|
|
40
|
+
`<button type="button" class="card-action ghost" data-command="${escapeHtml(a.command)}" title="${escapeHtml(a.command)}">${escapeHtml(a.label || a.command)}</button>`).join("")}</div>`
|
|
41
|
+
: "";
|
|
42
|
+
return `${head}${body}${buttons}<div class="card-error hidden"></div>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Where a snapshot's card or between-turns notice goes: before the user
|
|
46
|
+
// bubble that starts the first turn after it (turns_since counts the turns
|
|
47
|
+
// completed after it), or null for the end of the history.
|
|
48
|
+
export function cardPlace(userBubbles, card) {
|
|
49
|
+
return recapPlace(userBubbles, card?.turns_since);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A snapshot's cards: the ones placed in the history, the running turn's
|
|
53
|
+
// (a row of its current step once the turn is drawn), and the ones that
|
|
54
|
+
// came during the running turn but aren't its own (an anytime command's,
|
|
55
|
+
// such as /btw: after the running turn once it is drawn, as they came).
|
|
56
|
+
export function splitSnapshotCards(cards) {
|
|
57
|
+
const list = Array.isArray(cards) ? cards : [];
|
|
58
|
+
return {
|
|
59
|
+
placed: list.filter((c) => !c.current && !c.during),
|
|
60
|
+
current: list.filter((c) => c.current),
|
|
61
|
+
during: list.filter((c) => !c.current && c.during),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Where a snapshot's turn notice (a hook's notice during a turn, in_turn)
|
|
66
|
+
// goes in its reloaded turn's block: the step of its iteration (the
|
|
67
|
+
// iteration-th .gen, 0-based index here) and the key of the tool row it
|
|
68
|
+
// came before (it came after +calls+ of that step's calls started), or null
|
|
69
|
+
// without an iteration (the notice then goes before the turn's answer).
|
|
70
|
+
export function turnNoticePlace(notice) {
|
|
71
|
+
const iteration = Number(notice?.iteration);
|
|
72
|
+
if (!notice?.in_turn || !Number.isInteger(iteration) || iteration < 1) return null;
|
|
73
|
+
return { step: iteration - 1, rowKey: `${iteration}:${(Number(notice.calls) || 0) + 1}` };
|
|
74
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
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 } from "./format.js";
|
|
14
|
+
import { thumbsHtml } from "./images.js";
|
|
15
|
+
import { shouldFollowScroll } from "./scroll.js";
|
|
16
|
+
import { tallyText } from "./tally.js";
|
|
17
|
+
import { cancelLineHtml, formatDuration, timedTurnIndexes, turnRecordAt, turnTimingText } from "./timing.js";
|
|
18
|
+
|
|
19
|
+
export function activityStatusClass(status) {
|
|
20
|
+
if (status === "running") return "running";
|
|
21
|
+
if (status === "error") return "error";
|
|
22
|
+
return "ok";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function activityStatusLabel(status) {
|
|
26
|
+
if (status === "running") return "running";
|
|
27
|
+
if (status === "error") return "error";
|
|
28
|
+
return "done";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createChatView({ historyEl, appendToHistory, isNearBottom, sawText, sessionId }) {
|
|
32
|
+
let streamingBubble = null;
|
|
33
|
+
let streamingBuffer = "";
|
|
34
|
+
let rafPending = false;
|
|
35
|
+
let activityModel = null;
|
|
36
|
+
let activityEl = null;
|
|
37
|
+
let activityElBody = null;
|
|
38
|
+
const activityRowEls = new Map();
|
|
39
|
+
// One live "thinking" block per turn (Claude-like); streams :generation_chunk
|
|
40
|
+
// `thinking` deltas and auto-collapses on turn_completed.
|
|
41
|
+
let thinkingEl = null;
|
|
42
|
+
let thinkingBodyEl = null;
|
|
43
|
+
let thinkingBuffer = "";
|
|
44
|
+
|
|
45
|
+
function removeHint() {
|
|
46
|
+
const hint = historyEl.querySelector(".hint");
|
|
47
|
+
if (hint) hint.remove();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureStreamingBubble() {
|
|
51
|
+
if (streamingBubble) return streamingBubble;
|
|
52
|
+
removeHint();
|
|
53
|
+
streamingBubble = document.createElement("div");
|
|
54
|
+
streamingBubble.className = "bubble output streaming";
|
|
55
|
+
streamingBubble.textContent = "";
|
|
56
|
+
appendToHistory(streamingBubble);
|
|
57
|
+
return streamingBubble;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function flush() {
|
|
61
|
+
rafPending = false;
|
|
62
|
+
// Runs every animation frame while streaming — must not yank a user who
|
|
63
|
+
// scrolled up to read; only follow when they were already near the bottom.
|
|
64
|
+
const follow = isNearBottom();
|
|
65
|
+
// An empty buffer keeps the "…" placeholder.
|
|
66
|
+
if (streamingBubble && streamingBuffer) {
|
|
67
|
+
streamingBubble.textContent = streamingBuffer;
|
|
68
|
+
}
|
|
69
|
+
if (thinkingBodyEl) {
|
|
70
|
+
// The capped thinking box follows its own tail the same way, unless the
|
|
71
|
+
// user scrolled up inside it.
|
|
72
|
+
const followThinking = shouldFollowScroll(thinkingBodyEl, 24);
|
|
73
|
+
thinkingBodyEl.textContent = thinkingBuffer;
|
|
74
|
+
if (followThinking) thinkingBodyEl.scrollTop = thinkingBodyEl.scrollHeight;
|
|
75
|
+
}
|
|
76
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function scheduleFlush() {
|
|
80
|
+
if (rafPending) return;
|
|
81
|
+
rafPending = true;
|
|
82
|
+
requestAnimationFrame(flush);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function appendToken(content) {
|
|
86
|
+
if (!content) return;
|
|
87
|
+
// Leading whitespace right after a closed think/tool_call block (or at the
|
|
88
|
+
// start of the stream) would otherwise render as an empty first line in
|
|
89
|
+
// the pre-wrap bubble. Trim it only at the start of a bubble so mid-bubble
|
|
90
|
+
// paragraph spacing is preserved.
|
|
91
|
+
if (streamingBuffer === "") content = content.replace(/^[ \t\r\n]+/, "");
|
|
92
|
+
if (!content) return;
|
|
93
|
+
streamingBuffer += content;
|
|
94
|
+
ensureStreamingBubble();
|
|
95
|
+
scheduleFlush();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Per-turn live thinking block ─────────────────────────────────────────
|
|
99
|
+
// Routed from :generation_chunk `thinking`. One <details> per turn; accumulates
|
|
100
|
+
// across the turn's generations and collapses on turn completion.
|
|
101
|
+
|
|
102
|
+
function ensureThinkingEl() {
|
|
103
|
+
if (thinkingEl) return thinkingEl;
|
|
104
|
+
removeHint();
|
|
105
|
+
const details = document.createElement("details");
|
|
106
|
+
details.className = "bubble thinking";
|
|
107
|
+
details.open = true;
|
|
108
|
+
const summary = document.createElement("summary");
|
|
109
|
+
summary.textContent = "thinking";
|
|
110
|
+
details.appendChild(summary);
|
|
111
|
+
const body = document.createElement("div");
|
|
112
|
+
body.className = "thinking-body";
|
|
113
|
+
details.appendChild(body);
|
|
114
|
+
thinkingEl = details;
|
|
115
|
+
thinkingBodyEl = body;
|
|
116
|
+
// Prefer Claude-like ordering: thinking above the streaming answer bubble.
|
|
117
|
+
if (streamingBubble && streamingBubble.parentNode === historyEl) {
|
|
118
|
+
historyEl.insertBefore(details, streamingBubble);
|
|
119
|
+
} else {
|
|
120
|
+
appendToHistory(details);
|
|
121
|
+
}
|
|
122
|
+
return details;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function appendThinking(content) {
|
|
126
|
+
// The box starts at the first visible character, not the "\n" after <think>.
|
|
127
|
+
const piece = leadTrimmed(thinkingBuffer, content);
|
|
128
|
+
if (!piece) return;
|
|
129
|
+
thinkingBuffer += piece;
|
|
130
|
+
ensureThinkingEl();
|
|
131
|
+
scheduleFlush();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function finalizeThinkingEl() {
|
|
135
|
+
if (thinkingBodyEl) thinkingBodyEl.textContent = thinkingBuffer.replace(/[ \t\r\n]+$/, "");
|
|
136
|
+
if (thinkingEl) thinkingEl.open = false;
|
|
137
|
+
thinkingBuffer = "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resetThinkingState() {
|
|
141
|
+
thinkingEl = null;
|
|
142
|
+
thinkingBodyEl = null;
|
|
143
|
+
thinkingBuffer = "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function finalizeStreaming() {
|
|
147
|
+
if (streamingBubble) {
|
|
148
|
+
const k = normalize(streamingBuffer);
|
|
149
|
+
// The text lands in the bubble on the next animation frame; a generation
|
|
150
|
+
// can end before one runs (a burst of frames, a hidden tab), so write it
|
|
151
|
+
// here too or the bubble keeps its "…" placeholder.
|
|
152
|
+
if (k) {
|
|
153
|
+
streamingBubble.textContent = streamingBuffer;
|
|
154
|
+
sawText(k);
|
|
155
|
+
}
|
|
156
|
+
else streamingBubble.remove(); // drop text-less bubbles (thinking/tool-only generations, "…" placeholder)
|
|
157
|
+
streamingBubble.classList.remove("streaming");
|
|
158
|
+
streamingBubble = null;
|
|
159
|
+
}
|
|
160
|
+
streamingBuffer = "";
|
|
161
|
+
rafPending = false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Per-turn tool/memory activity panel ──────────────────────────────────
|
|
165
|
+
// One collapsible <details> per turn; each :tool_call_started/completed upserts
|
|
166
|
+
// a row (keyed by iteration:call_index) that transitions running -> ok/error.
|
|
167
|
+
// Memory reads/writes flow through the same tool_call events, so they show here too.
|
|
168
|
+
|
|
169
|
+
function ensureActivityEl() {
|
|
170
|
+
if (activityEl) return activityEl;
|
|
171
|
+
removeHint();
|
|
172
|
+
const details = document.createElement("details");
|
|
173
|
+
details.className = "bubble activity";
|
|
174
|
+
details.open = true;
|
|
175
|
+
const summary = document.createElement("summary");
|
|
176
|
+
summary.textContent = "activity";
|
|
177
|
+
details.appendChild(summary);
|
|
178
|
+
const body = document.createElement("div");
|
|
179
|
+
body.className = "activity-body";
|
|
180
|
+
details.appendChild(body);
|
|
181
|
+
activityEl = details;
|
|
182
|
+
activityElBody = body;
|
|
183
|
+
appendToHistory(details);
|
|
184
|
+
return details;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderActivityRow(row) {
|
|
188
|
+
const el = document.createElement("div");
|
|
189
|
+
el.className = "activity-row";
|
|
190
|
+
el.dataset.key = row.key;
|
|
191
|
+
const statusEl = document.createElement("span");
|
|
192
|
+
statusEl.className = "activity-status";
|
|
193
|
+
const toolEl = document.createElement("span");
|
|
194
|
+
toolEl.className = "activity-tool";
|
|
195
|
+
toolEl.textContent = toolName(row);
|
|
196
|
+
const paramsEl = document.createElement("span");
|
|
197
|
+
paramsEl.className = "activity-params";
|
|
198
|
+
const outEl = document.createElement("div");
|
|
199
|
+
outEl.className = "activity-output";
|
|
200
|
+
outEl.style.display = "none";
|
|
201
|
+
el.appendChild(statusEl);
|
|
202
|
+
el.appendChild(toolEl);
|
|
203
|
+
el.appendChild(paramsEl);
|
|
204
|
+
el.appendChild(outEl);
|
|
205
|
+
activityRowEls.set(row.key, el);
|
|
206
|
+
const follow = isNearBottom();
|
|
207
|
+
(activityElBody || ensureActivityEl().querySelector(".activity-body")).appendChild(el);
|
|
208
|
+
updateActivityRowEl(row);
|
|
209
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function updateActivityRowEl(row) {
|
|
213
|
+
const el = activityRowEls.get(row.key);
|
|
214
|
+
if (!el) return;
|
|
215
|
+
fillActivityRow(el, row, sessionId());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function updateActivitySummary() {
|
|
219
|
+
if (!activityEl) return;
|
|
220
|
+
const summary = activityEl.querySelector("summary");
|
|
221
|
+
if (!summary) return;
|
|
222
|
+
// From the 3rd call: "activity · 12 tool calls (2 failed) · execute ×7 · …" (the rows show the last call).
|
|
223
|
+
const tally = tallyText(activityModel?.rows, { last: false });
|
|
224
|
+
summary.textContent = tally ? `activity · ${tally}` : `activity (${activityCount(activityModel)})`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function finalizeActivityEl() {
|
|
228
|
+
if (activityModel) finalizeActivity(activityModel);
|
|
229
|
+
if (activityEl) activityEl.open = false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resetActivityState() {
|
|
233
|
+
activityModel = null;
|
|
234
|
+
activityEl = null;
|
|
235
|
+
activityElBody = null;
|
|
236
|
+
activityRowEls.clear();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function toolEvent(data, add) {
|
|
240
|
+
const model = activityModel || (activityModel = newActivity());
|
|
241
|
+
ensureActivityEl();
|
|
242
|
+
const row = add(model, data);
|
|
243
|
+
if (!activityRowEls.has(row.key)) renderActivityRow(row);
|
|
244
|
+
else updateActivityRowEl(row);
|
|
245
|
+
updateActivitySummary();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
turnStarted(data) {
|
|
250
|
+
resetActivityState();
|
|
251
|
+
resetThinkingState();
|
|
252
|
+
streamingBuffer = "";
|
|
253
|
+
ensureStreamingBubble();
|
|
254
|
+
if (data.prompt) streamingBubble.textContent = "…";
|
|
255
|
+
},
|
|
256
|
+
generationStarted() {
|
|
257
|
+
// A generation left open (a join mid-way) ends where the next begins.
|
|
258
|
+
finalizeStreaming();
|
|
259
|
+
ensureStreamingBubble();
|
|
260
|
+
},
|
|
261
|
+
chunk({ text, thinking }) {
|
|
262
|
+
if (text) appendToken(text);
|
|
263
|
+
if (thinking) appendThinking(thinking);
|
|
264
|
+
},
|
|
265
|
+
generationCompleted() {
|
|
266
|
+
finalizeStreaming();
|
|
267
|
+
},
|
|
268
|
+
toolStarted(data) {
|
|
269
|
+
toolEvent(data, (model, event) => addStarted(model, event).row);
|
|
270
|
+
},
|
|
271
|
+
toolCompleted(data) {
|
|
272
|
+
toolEvent(data, addCompleted);
|
|
273
|
+
},
|
|
274
|
+
// Everything a turn's end (completed, canceled, failed) closes.
|
|
275
|
+
turnEnded() {
|
|
276
|
+
finalizeStreaming();
|
|
277
|
+
finalizeActivityEl();
|
|
278
|
+
finalizeThinkingEl();
|
|
279
|
+
},
|
|
280
|
+
// A hook's notice stays the page's bubble in this view.
|
|
281
|
+
notice() {
|
|
282
|
+
return false;
|
|
283
|
+
},
|
|
284
|
+
// So is a card.
|
|
285
|
+
card() {
|
|
286
|
+
return false;
|
|
287
|
+
},
|
|
288
|
+
flush,
|
|
289
|
+
// The element is rewritten every frame while the turn runs (annotate
|
|
290
|
+
// skips it).
|
|
291
|
+
containsLive(el) {
|
|
292
|
+
return !!thinkingEl?.contains(el);
|
|
293
|
+
},
|
|
294
|
+
reset() {
|
|
295
|
+
streamingBubble = null;
|
|
296
|
+
streamingBuffer = "";
|
|
297
|
+
rafPending = false;
|
|
298
|
+
resetActivityState();
|
|
299
|
+
resetThinkingState();
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// One tool row's status, name, params, output and image thumbs from its
|
|
305
|
+
// activity row (activity.js). Shared with the turn view.
|
|
306
|
+
// A reloaded history in the chat view (renderHistory's object path): one
|
|
307
|
+
// bubble per message; each turn's timing line under its last bubble, then
|
|
308
|
+
// its cancel line, where the live view leaves them.
|
|
309
|
+
// +thumbs(images)+ draws a prompt's image thumbs.
|
|
310
|
+
export function chatHistoryHtml(items, timing, { thumbs }) {
|
|
311
|
+
const timedTurns = timedTurnIndexes(items);
|
|
312
|
+
return items
|
|
313
|
+
.map((m, i) => {
|
|
314
|
+
if (m.role === "note") return `<div class="bubble note">${noteHtml(m)}</div>`;
|
|
315
|
+
const turnIndex = timedTurns[i];
|
|
316
|
+
const cls = m.role === "user" ? "user" : "output";
|
|
317
|
+
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
318
|
+
const markdown = renderedMarkdown ? " markdown" : "";
|
|
319
|
+
const record = turnIndex === null ? null : turnRecordAt(timing, turnIndex);
|
|
320
|
+
const timingHtml = record && formatDuration(record.duration_ms)
|
|
321
|
+
? `<div class="turn-timing">${escapeHtml(turnTimingText(turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
|
|
322
|
+
: "";
|
|
323
|
+
// A canceled turn ends with its cancel line, as the live view left it.
|
|
324
|
+
const cancelHtml = record ? cancelLineHtml(record, escapeHtml) : "";
|
|
325
|
+
// A user bubble is a flex row (label, text, badge): its text sits in
|
|
326
|
+
// one block so quotes stack instead of lining up side by side.
|
|
327
|
+
const inner = m.role === "user" ? `<div class="user-message">${body}${thumbs(m.images)}</div>` : body;
|
|
328
|
+
// The timing line goes after the bubble, not in it: under the answer,
|
|
329
|
+
// or under the prompt of a turn canceled before any answer.
|
|
330
|
+
return `<div class="bubble ${cls}${markdown}"${copySourceAttr(m, renderedMarkdown)}>${inner}</div>${timingHtml}${cancelHtml}`;
|
|
331
|
+
})
|
|
332
|
+
.join("");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function fillActivityRow(el, row, sessionId) {
|
|
336
|
+
const statusEl = el.querySelector(".activity-status");
|
|
337
|
+
statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
|
|
338
|
+
statusEl.textContent = activityStatusLabel(row.status);
|
|
339
|
+
const toolEl = el.querySelector(".activity-tool");
|
|
340
|
+
if (toolEl) toolEl.textContent = toolName(row);
|
|
341
|
+
const paramsEl = el.querySelector(".activity-params");
|
|
342
|
+
if (paramsEl) paramsEl.textContent = row.params || "";
|
|
343
|
+
const outEl = el.querySelector(".activity-output");
|
|
344
|
+
if (outEl) {
|
|
345
|
+
if (row.status === "running") {
|
|
346
|
+
outEl.style.display = "none";
|
|
347
|
+
outEl.textContent = "";
|
|
348
|
+
outEl.title = "";
|
|
349
|
+
} else {
|
|
350
|
+
// dispatch prefixes output with "[tool]"; strip that noise for display.
|
|
351
|
+
const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
352
|
+
outEl.title = full;
|
|
353
|
+
outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
|
|
354
|
+
outEl.style.display = full ? "" : "none";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// The images a tool returned (the chat view's reloaded history has no tool
|
|
358
|
+
// rows; the turn view's has, turn_view.js reloadRowHtml).
|
|
359
|
+
if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
|
|
360
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Pure routing for one :generation_chunk event into the two render lanes.
|
|
2
|
+
//
|
|
3
|
+
// Phase 2 (web-stream-rendering): the server enriches each :generation_chunk with
|
|
4
|
+
// additive `text` (visible prose, thinking + tool_call blocks removed) and
|
|
5
|
+
// `thinking` (thinking-only) fields, leaving raw `content` untouched. When those
|
|
6
|
+
// fields are present we use them and ignore raw `content`; when absent (a
|
|
7
|
+
// pre-Phase-2 server, or a non-splitting profile the server did not enrich) we
|
|
8
|
+
// fall back to raw `content` so behavior matches the pre-Phase-2 UI.
|
|
9
|
+
//
|
|
10
|
+
// Presence is checked with `typeof === "string"` (not truthiness) so an empty
|
|
11
|
+
// `text` (e.g. a chunk that is entirely a tool_call body) still selects the new
|
|
12
|
+
// wire instead of accidentally falling back to raw `content`.
|
|
13
|
+
export function routeChunk(data) {
|
|
14
|
+
data = data || {};
|
|
15
|
+
if (typeof data.text === "string" || typeof data.thinking === "string") {
|
|
16
|
+
return {
|
|
17
|
+
text: typeof data.text === "string" ? data.text : "",
|
|
18
|
+
thinking: typeof data.thinking === "string" ? data.thinking : "",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
text: typeof data.content === "string" ? data.content : "",
|
|
23
|
+
thinking: "",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The composer's / autocomplete: pure helpers over the session GET's
|
|
2
|
+
// `commands` (Commands::Registry#listing: name, description, anytime,
|
|
3
|
+
// local, uis, source).
|
|
4
|
+
|
|
5
|
+
// The commands to offer while the composer holds one "/word" being typed
|
|
6
|
+
// (no space or newline yet): the session's slash commands that start with
|
|
7
|
+
// it, by name. The terminal UIs' own (local: /stats, /exit …) don't run here.
|
|
8
|
+
export function commandMatches(commands, text) {
|
|
9
|
+
const typed = String(text || "");
|
|
10
|
+
if (!/^\/\S*$/.test(typed)) return [];
|
|
11
|
+
const needle = typed.toLowerCase();
|
|
12
|
+
return (Array.isArray(commands) ? commands : [])
|
|
13
|
+
.filter((c) => c && !c.local && typeof c.name === "string" && c.name.startsWith("/"))
|
|
14
|
+
.filter((c) => c.name.toLowerCase().startsWith(needle))
|
|
15
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// What picking +name+ does to the composer: {text, send}. The name
|
|
19
|
+
// completes with a space for its arguments; ⏎ on a name already typed in
|
|
20
|
+
// full sends it as it is (⏎ on "/help" runs /help).
|
|
21
|
+
export function pickCommand(name, text, { enter = false } = {}) {
|
|
22
|
+
if (enter && String(text || "") === name) return { text: name, send: true };
|
|
23
|
+
return { text: `${name} `, send: false };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The highlighted row after an arrow key (+delta+ ±1), wrapping.
|
|
27
|
+
export function moveSelection(index, count, delta) {
|
|
28
|
+
if (!count) return -1;
|
|
29
|
+
return (((index + delta) % count) + count) % count;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One row's parts: the name, its description, and a note (the bundle it
|
|
33
|
+
// comes from; "mid-turn too" for an anytime one), "" when there is none.
|
|
34
|
+
export function commandRow(command) {
|
|
35
|
+
const notes = [];
|
|
36
|
+
if (command.source && command.source !== "core") notes.push(command.source);
|
|
37
|
+
if (command.anytime) notes.push("mid-turn too");
|
|
38
|
+
return { name: command.name, description: command.description || "", note: notes.join(" · ") };
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The composer's height. It grows with its text up to a cap; the grip on
|
|
2
|
+
// its top edge sets a taller floor (the cap then is at least that floor).
|
|
3
|
+
export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field
|
|
4
|
+
export const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
|
|
5
|
+
export const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
|
|
6
|
+
|
|
7
|
+
// contentPx: the text's height (scrollHeight plus borders); floorPx: the
|
|
8
|
+
// grip's height, or null.
|
|
9
|
+
export function promptHeight({ contentPx, floorPx = null, viewportPx }) {
|
|
10
|
+
const floor = Math.max(PROMPT_MIN_PX, floorPx || 0);
|
|
11
|
+
const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
|
|
12
|
+
return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A grip drag: the floor it asks for, kept between the minimum and the
|
|
16
|
+
// grip's share of the window.
|
|
17
|
+
export function gripFloor(px, viewportPx) {
|
|
18
|
+
return Math.round(Math.min(Math.max(px, PROMPT_MIN_PX), viewportPx * PROMPT_GRIP_SHARE));
|
|
19
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Copy buttons: one on every prompt and answer bubble, one on every code
|
|
2
|
+
// block of a rendered answer. An answer copies its markdown source (the
|
|
3
|
+
// saved message's `content`, kept on the bubble as data-copy-source when the
|
|
4
|
+
// page shows rendered html), a prompt the text as typed, a code block just
|
|
5
|
+
// its code.
|
|
6
|
+
//
|
|
7
|
+
// The buttons carry no text (an icon; "copied ✓" is CSS content), so a
|
|
8
|
+
// bubble's textContent, the turn output dedupe and an annotation's selected
|
|
9
|
+
// text stay what they were. They are added by one observer on #history, so
|
|
10
|
+
// every path that fills it (a render, a live turn in either view, the
|
|
11
|
+
// turn-end markdown) gets them without knowing about them.
|
|
12
|
+
|
|
13
|
+
export const COPY_SOURCE_ATTR = "data-copy-source";
|
|
14
|
+
|
|
15
|
+
// +text+ to the clipboard; true when it got there. Without the Clipboard
|
|
16
|
+
// API (an insecure origin, e.g. chi web on a LAN address) the old way.
|
|
17
|
+
export async function copyText(text) {
|
|
18
|
+
try {
|
|
19
|
+
await navigator.clipboard.writeText(text);
|
|
20
|
+
return true;
|
|
21
|
+
} catch (_) {
|
|
22
|
+
const area = document.createElement("textarea");
|
|
23
|
+
area.value = text;
|
|
24
|
+
document.body.appendChild(area);
|
|
25
|
+
area.select();
|
|
26
|
+
const ok = document.execCommand("copy");
|
|
27
|
+
area.remove();
|
|
28
|
+
return ok;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// What a bubble copies: its kept source, else its visible text without the
|
|
33
|
+
// page's chrome (timing line, badges, labels, the buttons).
|
|
34
|
+
const CHROME = ".turn-timing, .state-badge, .origin-label, .thumbs, .copy-btn";
|
|
35
|
+
|
|
36
|
+
export function bubbleCopyText(bubble) {
|
|
37
|
+
const source = bubble.getAttribute?.(COPY_SOURCE_ATTR);
|
|
38
|
+
if (source !== null && source !== undefined) return source;
|
|
39
|
+
const root = bubble.classList?.contains("user") ? bubble.querySelector(".user-message") || bubble : bubble;
|
|
40
|
+
return visibleText(root, CHROME).replace(/^\s+|\s+$/g, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// +el+'s text without the subtrees matching +skip+.
|
|
44
|
+
export function visibleText(el, skip) {
|
|
45
|
+
let out = "";
|
|
46
|
+
for (const node of el.childNodes || []) {
|
|
47
|
+
if (node.nodeType === 3) out += node.nodeValue;
|
|
48
|
+
else if (node.nodeType === 1 && !(skip && node.matches?.(skip))) out += visibleText(node, skip);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A code block's code: the <code> inside it (else the <pre>), without the
|
|
54
|
+
// newline the markdown renderer ends every block with.
|
|
55
|
+
export function codeText(pre) {
|
|
56
|
+
const code = pre.querySelector?.("code") || pre;
|
|
57
|
+
return visibleText(code, ".copy-btn").replace(/\n$/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The attribute that keeps a message's source on its reloaded bubble:
|
|
61
|
+
// rendered html and a prompt (its `>` lines render as quotes) need it; a
|
|
62
|
+
// plain answer's text is its source.
|
|
63
|
+
export function copySourceAttr(message, renderedMarkdown) {
|
|
64
|
+
if (!renderedMarkdown && message?.role !== "user") return "";
|
|
65
|
+
const text = String(message?.content ?? "")
|
|
66
|
+
.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
67
|
+
return ` ${COPY_SOURCE_ATTR}="${text}"`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>';
|
|
71
|
+
|
|
72
|
+
const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
|
|
73
|
+
const CODE = ".bubble.output pre";
|
|
74
|
+
|
|
75
|
+
function makeButton(doc, kind) {
|
|
76
|
+
const btn = doc.createElement("button");
|
|
77
|
+
btn.type = "button";
|
|
78
|
+
btn.className = `copy-btn copy-${kind}`;
|
|
79
|
+
btn.title = kind === "code" ? "Copy code" : "Copy";
|
|
80
|
+
btn.setAttribute("aria-label", btn.title);
|
|
81
|
+
btn.innerHTML = ICON;
|
|
82
|
+
return btn;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Buttons on every bubble and code block in +root+ (and on +root+ itself)
|
|
86
|
+
// that has none yet.
|
|
87
|
+
export function decorate(root) {
|
|
88
|
+
const doc = root.ownerDocument;
|
|
89
|
+
const bubbles = [...root.querySelectorAll(BUBBLES)];
|
|
90
|
+
if (root.matches(BUBBLES)) bubbles.push(root);
|
|
91
|
+
for (const bubble of bubbles) {
|
|
92
|
+
// First, so the markdown's :last-child margins still see the last block.
|
|
93
|
+
if (!bubble.querySelector(":scope > .copy-btn")) bubble.prepend(makeButton(doc, "bubble"));
|
|
94
|
+
}
|
|
95
|
+
for (const pre of root.querySelectorAll(CODE)) {
|
|
96
|
+
if (pre.parentElement?.classList.contains("code-wrap")) continue;
|
|
97
|
+
const wrap = doc.createElement("div");
|
|
98
|
+
wrap.className = "code-wrap";
|
|
99
|
+
pre.replaceWith(wrap);
|
|
100
|
+
wrap.appendChild(pre);
|
|
101
|
+
wrap.appendChild(makeButton(doc, "code"));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Watch +historyEl+ and answer clicks on its buttons.
|
|
106
|
+
export function installCopy(historyEl) {
|
|
107
|
+
// Only what changed is looked at: a streaming bubble rewrites its text
|
|
108
|
+
// every frame, and a long history shouldn't be walked for that.
|
|
109
|
+
const roots = new Set();
|
|
110
|
+
const run = () => {
|
|
111
|
+
const batch = [...roots];
|
|
112
|
+
roots.clear();
|
|
113
|
+
for (const el of batch) if (el.isConnected) decorate(el);
|
|
114
|
+
};
|
|
115
|
+
new MutationObserver((records) => {
|
|
116
|
+
const was = roots.size;
|
|
117
|
+
for (const r of records) {
|
|
118
|
+
if (r.target !== historyEl) roots.add(r.target);
|
|
119
|
+
for (const n of r.addedNodes) if (n.nodeType === 1) roots.add(n);
|
|
120
|
+
}
|
|
121
|
+
if (!was && roots.size) requestAnimationFrame(run);
|
|
122
|
+
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
123
|
+
decorate(historyEl);
|
|
124
|
+
|
|
125
|
+
// A press on a button must not start (or drop) a text selection.
|
|
126
|
+
historyEl.addEventListener("mousedown", (e) => {
|
|
127
|
+
if (e.target.closest?.(".copy-btn")) e.preventDefault();
|
|
128
|
+
});
|
|
129
|
+
historyEl.addEventListener("click", async (e) => {
|
|
130
|
+
const btn = e.target.closest?.(".copy-btn");
|
|
131
|
+
if (!btn) return;
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
e.stopPropagation();
|
|
134
|
+
const text = btn.classList.contains("copy-code")
|
|
135
|
+
? codeText(btn.parentElement.querySelector("pre"))
|
|
136
|
+
: bubbleCopyText(btn.parentElement);
|
|
137
|
+
if (!(await copyText(text))) return;
|
|
138
|
+
btn.dataset.copied = "1";
|
|
139
|
+
clearTimeout(btn._copiedTimer);
|
|
140
|
+
btn._copiedTimer = setTimeout(() => { delete btn.dataset.copied; }, 1500);
|
|
141
|
+
});
|
|
142
|
+
}
|