samagotchi 0.154.0 → 0.155.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 +30 -1
- data/docs/architecture.md +3 -2
- data/docs/bundles.md +21 -0
- data/docs/cli.md +10 -8
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +4 -4
- data/docs/internals/attached-context.md +8 -6
- data/docs/internals/block-renderers.md +8 -1
- data/docs/internals/bridge.md +18 -5
- data/docs/internals/engine.md +221 -100
- data/docs/internals/inline-widgets.md +136 -0
- data/docs/internals/objectives.md +8 -6
- data/docs/internals/participants.md +5 -5
- data/docs/internals/prompt-caching.md +12 -6
- data/docs/internals/web-frontend.md +2 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/plugins.md +2 -0
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +16 -1
- data/lib/samagotchi/answer_text.rb +1 -2
- data/lib/samagotchi/block_renderers.rb +5 -0
- data/lib/samagotchi/bridge/card_places.rb +56 -7
- data/lib/samagotchi/bridge/card_store.rb +24 -9
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command/check.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
- data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
- data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -125
- data/lib/samagotchi/engine/guardrails.rb +4 -2
- data/lib/samagotchi/engine/messages.rb +9 -6
- data/lib/samagotchi/engine/objectives.rb +22 -90
- data/lib/samagotchi/engine/participants.rb +15 -10
- data/lib/samagotchi/engine/plugins.rb +3 -1
- data/lib/samagotchi/engine/questions.rb +1 -1
- data/lib/samagotchi/engine/side_requests.rb +5 -2
- data/lib/samagotchi/engine/toolkit.rb +33 -190
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +16 -39
- data/lib/samagotchi/engine/verify.rb +7 -143
- data/lib/samagotchi/engine.rb +162 -99
- data/lib/samagotchi/engine_peers.rb +1 -1
- data/lib/samagotchi/engine_snapshots.rb +9 -8
- data/lib/samagotchi/event_fanout.rb +18 -12
- data/lib/samagotchi/guardrail_wiring.rb +83 -82
- data/lib/samagotchi/guardrails/gate.rb +41 -40
- data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/targets.rb +6 -4
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/idle_recap.rb +3 -1
- data/lib/samagotchi/inline_widgets.rb +325 -0
- data/lib/samagotchi/kernel_loop.rb +194 -216
- data/lib/samagotchi/llm/backend.rb +3 -1
- data/lib/samagotchi/llm/chat_loop.rb +66 -42
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/llm_context_forget.rb +5 -3
- data/lib/samagotchi/model_binding.rb +3 -3
- data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
- data/lib/samagotchi/question_desk.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +1 -1
- data/lib/samagotchi/session_manager.rb +8 -13
- data/lib/samagotchi/session_retention.rb +24 -4
- data/lib/samagotchi/steer.rb +12 -1
- data/lib/samagotchi/stream_event.rb +22 -0
- data/lib/samagotchi/system_prompt.rb +84 -61
- data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
- data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
- data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
- data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
- data/lib/samagotchi/testing/boundary.rb +7 -0
- data/lib/samagotchi/testing/bundle.rb +7 -5
- data/lib/samagotchi/testing/install.rb +3 -2
- data/lib/samagotchi/tool_activity.rb +8 -6
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tool_response.rb +4 -3
- data/lib/samagotchi/tool_runner.rb +93 -101
- data/lib/samagotchi/toolkit_binding.rb +40 -14
- data/lib/samagotchi/tools/deferred_tools.rb +17 -21
- data/lib/samagotchi/tools/registry.rb +43 -40
- data/lib/samagotchi/tools/tool_index.rb +135 -88
- data/lib/samagotchi/tools/tool_surface.rb +173 -0
- data/lib/samagotchi/turn_context.rb +67 -0
- data/lib/samagotchi/turn_flow.rb +7 -2
- data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
- data/lib/samagotchi/turn_stages/objectives.rb +146 -0
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
- data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
- data/lib/samagotchi/turn_stages/warmup.rb +83 -0
- data/lib/samagotchi/turn_state.rb +46 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -1
- data/lib/samagotchi/web/markdown_renderer.rb +39 -14
- data/lib/samagotchi/web/message_display.rb +8 -1
- data/lib/samagotchi/web/public/app.js +5 -1
- data/lib/samagotchi/web/public/block_host.js +74 -199
- data/lib/samagotchi/web/public/block_renderers.css +2 -0
- data/lib/samagotchi/web/public/block_source.js +7 -4
- data/lib/samagotchi/web/public/block_watch.js +256 -0
- data/lib/samagotchi/web/public/card_stamps.js +27 -0
- data/lib/samagotchi/web/public/cards_place.js +5 -3
- data/lib/samagotchi/web/public/composer_fold.js +3 -0
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -2
- data/lib/samagotchi/web/public/index.html +28 -9
- data/lib/samagotchi/web/public/inline_widgets.js +146 -0
- data/lib/samagotchi/web/public/owner_verify.js +1 -1
- data/lib/samagotchi/web/public/participant.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +1 -1
- data/lib/samagotchi/web/public/stage_watch.js +6 -2
- data/lib/samagotchi/web/public/text_roots.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/verify_target.js +1 -1
- data/lib/samagotchi/web/public/widget_picks.js +130 -0
- data/lib/samagotchi/web/public/widget_walk.js +230 -0
- data/lib/samagotchi/web/public/widgets.css +70 -0
- data/lib/samagotchi/web/session_access_routes.rb +8 -0
- data/lib/samagotchi/web/session_show.rb +4 -0
- data/lib/samagotchi/worker.rb +8 -5
- data/lib/samagotchi.rb +0 -1
- metadata +19 -2
- data/lib/samagotchi/pending_input_queue.rb +0 -55
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "nokogiri"
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "../block_renderers"
|
|
6
|
+
require_relative "../inline_widgets"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module Web
|
|
@@ -25,6 +26,12 @@ module Samagotchi
|
|
|
25
26
|
extension: { header_ids: nil }
|
|
26
27
|
}.freeze
|
|
27
28
|
|
|
29
|
+
# A code block as the parse tree has it: its language (nil for an indented
|
|
30
|
+
# block or a bare fence) and its literal, the source between the fences.
|
|
31
|
+
Fence = Data.define(:language, :literal)
|
|
32
|
+
# A JSON string's < > & as \u escapes: the same JSON once parsed.
|
|
33
|
+
JSON_MARKUP = { "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026" }.freeze
|
|
34
|
+
|
|
28
35
|
def initialize(enabled: false)
|
|
29
36
|
@enabled = enabled
|
|
30
37
|
end
|
|
@@ -61,7 +68,7 @@ module Samagotchi
|
|
|
61
68
|
})
|
|
62
69
|
return nil unless html
|
|
63
70
|
|
|
64
|
-
sanitize(html,
|
|
71
|
+
sanitize(html, fences(markdown.to_s))
|
|
65
72
|
end
|
|
66
73
|
|
|
67
74
|
private
|
|
@@ -81,17 +88,17 @@ module Samagotchi
|
|
|
81
88
|
@commonmarker_installed = Gem::Specification.find_all_by_name("commonmarker").any?
|
|
82
89
|
end
|
|
83
90
|
|
|
84
|
-
# Every code block
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
def
|
|
91
|
+
# Every code block, in document order: the highlighter drops the language
|
|
92
|
+
# from the HTML, the parse tree has it. [] for a text with no fence: its
|
|
93
|
+
# blocks have no language.
|
|
94
|
+
def fences(markdown)
|
|
88
95
|
return [] unless markdown.include?("```") || markdown.include?("~~~")
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
fences = []
|
|
91
98
|
Commonmarker.parse(markdown, options: OPTIONS).walk do |node|
|
|
92
|
-
|
|
99
|
+
fences << Fence.new(language: language_of(node.fence_info), literal: node.string_content) if node.type == :code_block
|
|
93
100
|
end
|
|
94
|
-
|
|
101
|
+
fences
|
|
95
102
|
end
|
|
96
103
|
|
|
97
104
|
def language_of(info)
|
|
@@ -100,16 +107,34 @@ module Samagotchi
|
|
|
100
107
|
end
|
|
101
108
|
|
|
102
109
|
# After the walk, which drops every attribute it doesn't know. Blocks and
|
|
103
|
-
#
|
|
110
|
+
# fences pair by position; a count that differs says the two parses
|
|
104
111
|
# disagree, and then no block gets one (it shows as source).
|
|
105
|
-
def mark_languages(fragment,
|
|
112
|
+
def mark_languages(fragment, fences)
|
|
106
113
|
blocks = fragment.css("pre")
|
|
107
|
-
return unless blocks.size ==
|
|
114
|
+
return unless blocks.size == fences.size
|
|
115
|
+
|
|
116
|
+
blocks.zip(fences) do |pre, fence|
|
|
117
|
+
next unless fence.language
|
|
118
|
+
|
|
119
|
+
pre["data-lang"] = fence.language
|
|
120
|
+
mark_widget(pre, fence.literal) if fence.language == InlineWidgets::LANG
|
|
121
|
+
end
|
|
122
|
+
end
|
|
108
123
|
|
|
109
|
-
|
|
124
|
+
# A chi-ui block carries what the page draws or why it doesn't. The JSON
|
|
125
|
+
# is an attribute value (Nokogiri escapes its quotes), and its < > & are
|
|
126
|
+
# \u escapes, so the HTML holds no model markup even as attribute text.
|
|
127
|
+
def mark_widget(pre, literal)
|
|
128
|
+
widget = InlineWidgets.parse(literal)
|
|
129
|
+
if widget.ok?
|
|
130
|
+
pre["data-widget"] = widget.widget_json.gsub(/[<>&]/, JSON_MARKUP)
|
|
131
|
+
else
|
|
132
|
+
pre["data-widget-error"] = widget.error
|
|
133
|
+
end
|
|
134
|
+
pre["data-widget-lenient"] = "" if widget.lenient?
|
|
110
135
|
end
|
|
111
136
|
|
|
112
|
-
def sanitize(html,
|
|
137
|
+
def sanitize(html, fences)
|
|
113
138
|
fragment = Nokogiri::HTML5.fragment(html)
|
|
114
139
|
fragment.traverse do |node|
|
|
115
140
|
next unless node.element?
|
|
@@ -122,7 +147,7 @@ module Samagotchi
|
|
|
122
147
|
sanitize_attributes(node)
|
|
123
148
|
end
|
|
124
149
|
end
|
|
125
|
-
mark_languages(fragment,
|
|
150
|
+
mark_languages(fragment, fences)
|
|
126
151
|
fragment.to_html
|
|
127
152
|
end
|
|
128
153
|
|
|
@@ -134,6 +134,9 @@ module Samagotchi
|
|
|
134
134
|
end
|
|
135
135
|
next if role == "system"
|
|
136
136
|
next if role == "tool_response"
|
|
137
|
+
# KernelLoop's recovery line: a row of its turn the page showed as
|
|
138
|
+
# nothing live (no turn opens with it, Steer.recovery?).
|
|
139
|
+
next if Samagotchi::Steer.recovery?(m)
|
|
137
140
|
|
|
138
141
|
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
139
142
|
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
|
|
@@ -165,7 +168,11 @@ module Samagotchi
|
|
|
165
168
|
# record by this, not by its place.
|
|
166
169
|
turn_id = m[:turn_id] || m["turn_id"]
|
|
167
170
|
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
168
|
-
#
|
|
171
|
+
# An opening no turn ran (TurnFlow#abort_continue!'s reason): its own
|
|
172
|
+
# id, which a card's stamp names (Bridge::CardPlaces#opening_of).
|
|
173
|
+
opening_id = m[:opening_id] || m["opening_id"]
|
|
174
|
+
message[:opening_id] = opening_id.to_s if norm_role == "user" && opening_id && !turn_id && !message[:merged]
|
|
175
|
+
# The turn it revised (a Revise stack, TurnStages::VerifyAnchors#stamp_opening), and
|
|
169
176
|
# whether it was a Verify turn (the page offers no Revise on its
|
|
170
177
|
# answer).
|
|
171
178
|
if norm_role == "user" && turn_id && !message[:merged]
|
|
@@ -71,6 +71,7 @@ import { createAnnotateBar } from "./annotate_bar.js";
|
|
|
71
71
|
import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
|
|
72
72
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
73
73
|
import { createBlockHost } from "./block_host.js";
|
|
74
|
+
import { createWidgetDrawer } from "./inline_widgets.js";
|
|
74
75
|
import { copyText, installCopy } from "./copy.js";
|
|
75
76
|
import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
|
|
76
77
|
import { installDetailsMotion } from "./details_motion.js";
|
|
@@ -1856,7 +1857,10 @@ historyEl.addEventListener("click", (e) => {
|
|
|
1856
1857
|
});
|
|
1857
1858
|
turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
|
|
1858
1859
|
turnRoots().forEach((root) => installCopy(root));
|
|
1859
|
-
|
|
1860
|
+
// chi-ui blocks (inline_widgets.js) are a core drawer, ahead of the bundles'
|
|
1861
|
+
// renderers; a picks' Fill reply only fills the composer.
|
|
1862
|
+
const widgets = createWidgetDrawer({ roots: turnRoots(), composerText: () => promptEl.value, fillComposer });
|
|
1863
|
+
createBlockHost({ roots: turnRoots(), sessionId: () => store.selected, drawers: [widgets] });
|
|
1860
1864
|
// A steps block, a thinking block and the recap open and close by hand
|
|
1861
1865
|
// with their height animated (details_motion.js), in the history and on
|
|
1862
1866
|
// the stage.
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
// Draws a bundle's fenced blocks in the page (docs/internals/block-renderers.md)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// answer_display, Revise's clones, a step's swap) arrives the same way, and
|
|
5
|
-
// cards, the asking slot and question replies are never touched.
|
|
1
|
+
// Draws a bundle's fenced blocks in the page (docs/internals/block-renderers.md),
|
|
2
|
+
// in a hidden frame per language. Finding and watching the blocks is
|
|
3
|
+
// block_watch.js's; this is its frame drawer, after any core drawers.
|
|
6
4
|
//
|
|
7
5
|
// The DOM is the state: a <figure class="block-drawn"> right before the <pre>
|
|
8
6
|
// (block_source.js) says it is drawn, so a Revise clone needs nothing, and
|
|
@@ -16,16 +14,13 @@
|
|
|
16
14
|
import { html, render as litRender } from "./vendor/lit-html.js";
|
|
17
15
|
import { createBlockFrame, FRAME_TIMEOUT_MS } from "./block_frame.js";
|
|
18
16
|
import { DRAWN, figureOf, setSourceShown } from "./block_source.js";
|
|
17
|
+
import { createBlockWatch, failBlock, holdingView } from "./block_watch.js";
|
|
19
18
|
import { copyText } from "./copy.js";
|
|
20
|
-
import { keepFollowing, shouldFollowScroll } from "./scroll.js";
|
|
21
|
-
import { TEXTS } from "./text_roots.js";
|
|
22
19
|
|
|
23
|
-
const BLOCKS = "pre[data-lang]";
|
|
24
20
|
// MessageDisplay::MAX_RENDER_BYTES's number: a source over it stays source.
|
|
25
21
|
export const MAX_SOURCE_BYTES = 64 * 1024;
|
|
26
22
|
export const CACHE_SIZE = 200;
|
|
27
23
|
export const MARK_PREFIX = "chi_block_rendering:";
|
|
28
|
-
const IO_MARGIN = "600px 0px";
|
|
29
24
|
const MAX_RETRIES = 5;
|
|
30
25
|
// The biggest picture a page shows, whatever the renderer: a model-written
|
|
31
26
|
// directive can ask Mermaid for an SVG tens of millions of px tall, which breaks
|
|
@@ -126,43 +121,6 @@ export function currentTheme(doc = document, win = window) {
|
|
|
126
121
|
};
|
|
127
122
|
}
|
|
128
123
|
|
|
129
|
-
// The nearest scroller holding +el+, +root+ at the latest. IntersectionObserver
|
|
130
|
-
// needs it named: with root:null a rootMargin doesn't pass an overflow:auto
|
|
131
|
-
// between, so a block just under #history's fold wouldn't count.
|
|
132
|
-
function scrollerOf(el, root, win) {
|
|
133
|
-
for (let at = el.parentElement; at && at !== root; at = at.parentElement) {
|
|
134
|
-
const { overflowY } = win.getComputedStyle(at);
|
|
135
|
-
if (overflowY === "auto" || overflowY === "scroll") return at;
|
|
136
|
-
}
|
|
137
|
-
return root;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// One IntersectionObserver per scroller. +el+ moved to another scroller (the
|
|
141
|
-
// stage's hand-off to the history) moves with it.
|
|
142
|
-
function ioWatcher(win) {
|
|
143
|
-
const observers = new Map();
|
|
144
|
-
const watched = new WeakMap();
|
|
145
|
-
return (el, scroller, onNear) => {
|
|
146
|
-
let io = observers.get(scroller);
|
|
147
|
-
if (!io) {
|
|
148
|
-
io = new win.IntersectionObserver((entries) => {
|
|
149
|
-
for (const entry of entries) {
|
|
150
|
-
if (!entry.isIntersecting) continue;
|
|
151
|
-
io.unobserve(entry.target);
|
|
152
|
-
const near = watched.get(entry.target);
|
|
153
|
-
watched.delete(entry.target);
|
|
154
|
-
near?.onNear();
|
|
155
|
-
}
|
|
156
|
-
}, { root: scroller, rootMargin: IO_MARGIN });
|
|
157
|
-
observers.set(scroller, io);
|
|
158
|
-
}
|
|
159
|
-
const before = watched.get(el);
|
|
160
|
-
if (before && before.io !== io) before.io.unobserve(el);
|
|
161
|
-
watched.set(el, { io, onNear });
|
|
162
|
-
io.observe(el);
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
124
|
function guardedStorage(get) {
|
|
167
125
|
const use = (fn, fallback) => { try { return fn(get()); } catch (_) { return fallback; } };
|
|
168
126
|
return {
|
|
@@ -172,26 +130,22 @@ function guardedStorage(get) {
|
|
|
172
130
|
};
|
|
173
131
|
}
|
|
174
132
|
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
133
|
+
|
|
134
|
+
// The drawer of a bundle's renderers (block_watch.js has the contract): one
|
|
135
|
+
// frame per language, waiting for /api/block_renderers to say who draws what.
|
|
136
|
+
// @param roots the turn roots, for the view held and the zoom's listeners
|
|
137
|
+
// The rest are createBlockHost's.
|
|
138
|
+
export function createFrameDrawer({
|
|
139
|
+
roots, listRenderers = fetchRendererList, fetchFile = fetchRendererFile, makeFrame = createBlockFrame,
|
|
181
140
|
decodeImage = async (url) => { const img = new win.Image(); img.src = url; await img.decode(); return { width: img.naturalWidth, height: img.naturalHeight }; },
|
|
182
|
-
decodeTimeoutMs = DECODE_TIMEOUT_MS, theme = currentTheme,
|
|
183
|
-
|
|
141
|
+
decodeTimeoutMs = DECODE_TIMEOUT_MS, theme = currentTheme,
|
|
142
|
+
doc = document, win = window, storage = guardedStorage(() => globalThis.sessionStorage),
|
|
184
143
|
}) {
|
|
185
|
-
const near = watch || ioWatcher(win);
|
|
186
144
|
const cache = new Map();
|
|
187
145
|
const frames = new Map();
|
|
188
146
|
// A language whose renderer didn't load, and why: until the session changes,
|
|
189
147
|
// its blocks don't fetch it again one by one.
|
|
190
148
|
const broken = new Map();
|
|
191
|
-
// Blocks with a draw on its way (not a drawn state: that is the figure): pre =>
|
|
192
|
-
// {target, scroller, start, drawing}. Until it starts, a block whose scroller
|
|
193
|
-
// changed (the stage's hand-off to the history) gets watched there.
|
|
194
|
-
const waiting = new WeakMap();
|
|
195
149
|
// The marks of the renders running now: mark => when it started. Such a mark isn't
|
|
196
150
|
// a leftover from an earlier load.
|
|
197
151
|
const inFlight = new Map();
|
|
@@ -199,17 +153,19 @@ export function createBlockHost({
|
|
|
199
153
|
// render has its blocks wait for that one, and each draws into whichever is connected.
|
|
200
154
|
const coming = new Map();
|
|
201
155
|
const retries = new WeakMap();
|
|
156
|
+
// What needsDraw checked, so draw draws that: pre => {lang, source, renderer}.
|
|
157
|
+
const checked = new WeakMap();
|
|
202
158
|
let list = null;
|
|
203
159
|
let listLoad = null;
|
|
204
|
-
let session = sessionId();
|
|
205
160
|
|
|
206
|
-
|
|
207
|
-
|
|
161
|
+
// The list once loaded; till then a promise of it, asked for once per page load.
|
|
162
|
+
function renderers() {
|
|
163
|
+
if (list) return list;
|
|
208
164
|
listLoad ??= listRenderers().then(
|
|
209
|
-
(
|
|
165
|
+
(got) => { list = new Map((got || []).map((r) => [r.lang, r])); return list; },
|
|
210
166
|
() => { list = new Map(); return list; }, // a failed list: no drawing
|
|
211
167
|
);
|
|
212
|
-
listLoad
|
|
168
|
+
return listLoad;
|
|
213
169
|
}
|
|
214
170
|
|
|
215
171
|
function dropFrames() {
|
|
@@ -218,13 +174,6 @@ export function createBlockHost({
|
|
|
218
174
|
broken.clear();
|
|
219
175
|
}
|
|
220
176
|
|
|
221
|
-
function noticeSession() {
|
|
222
|
-
const now = sessionId();
|
|
223
|
-
if (now === session) return;
|
|
224
|
-
session = now;
|
|
225
|
-
dropFrames();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
177
|
function frameFor(lang, renderer) {
|
|
229
178
|
let frame = frames.get(lang);
|
|
230
179
|
if (frame && !frame.dead()) return frame;
|
|
@@ -242,24 +191,7 @@ export function createBlockHost({
|
|
|
242
191
|
|
|
243
192
|
const cacheKey = (lang, themeName, source) => `${lang}\u0000${themeName}\u0000${source}`;
|
|
244
193
|
const markKey = (lang, source) => `${MARK_PREFIX}${lang}:${sourceHash(source)}`;
|
|
245
|
-
|
|
246
|
-
function rootOf(el) {
|
|
247
|
-
return roots.find((root) => root.contains(el)) || roots[0];
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// +mutate+ with the view held: a viewer at the bottom stays there, and a
|
|
251
|
-
// block above a reader's view keeps what they read where it is. Browsers that
|
|
252
|
-
// anchor scrolling have done it by the second read, and there is nothing left.
|
|
253
|
-
function holdingView(pre, mutate, bottomOf) {
|
|
254
|
-
const scroller = scrollerOf(pre, rootOf(pre), win);
|
|
255
|
-
if (shouldFollowScroll(scroller)) return keepFollowing(scroller, mutate);
|
|
256
|
-
const before = pre.getBoundingClientRect().bottom;
|
|
257
|
-
if (before > scroller.getBoundingClientRect().top) return mutate();
|
|
258
|
-
const result = mutate();
|
|
259
|
-
const after = bottomOf();
|
|
260
|
-
if (after !== before) scroller.scrollTop += after - before;
|
|
261
|
-
return result;
|
|
262
|
-
}
|
|
194
|
+
const langOf = (pre) => pre.getAttribute("data-lang");
|
|
263
195
|
|
|
264
196
|
function setMark(mark) {
|
|
265
197
|
storage.set(mark);
|
|
@@ -347,28 +279,10 @@ export function createBlockHost({
|
|
|
347
279
|
return;
|
|
348
280
|
}
|
|
349
281
|
const figure = makeFigure(picture, themeName, lang);
|
|
350
|
-
holdingView(pre, () => {
|
|
282
|
+
holdingView(pre, roots, () => {
|
|
351
283
|
pre.before(figure);
|
|
352
284
|
setSourceShown(figure, pre, false);
|
|
353
|
-
}, () => figure.getBoundingClientRect().bottom);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// The <pre> stays as it is, under one muted line: never markup (the words are
|
|
357
|
-
// a renderer's, driven by the model's input), and shown by CSS, so a bubble's
|
|
358
|
-
// textContent, its dedupe and an annotation's text stay what they were (copy.js's
|
|
359
|
-
// rule for its buttons).
|
|
360
|
-
function fail(pre, lang, message) {
|
|
361
|
-
if (!pre.isConnected) return;
|
|
362
|
-
pre.dataset.blockFailed = "1";
|
|
363
|
-
const note = doc.createElement("div");
|
|
364
|
-
note.className = "block-note";
|
|
365
|
-
const first = String(message ?? "").split("\n")[0].trim();
|
|
366
|
-
const words = `Couldn't draw this ${lang} block${first ? `: ${first}` : ""}`;
|
|
367
|
-
note.dataset.text = words;
|
|
368
|
-
note.setAttribute("role", "note");
|
|
369
|
-
note.setAttribute("aria-label", words);
|
|
370
|
-
note.title = "Click to copy";
|
|
371
|
-
pre.after(note);
|
|
285
|
+
}, () => figure.getBoundingClientRect().bottom, win);
|
|
372
286
|
}
|
|
373
287
|
|
|
374
288
|
function pictureFor(key, lang, source, renderer, now) {
|
|
@@ -407,21 +321,37 @@ export function createBlockHost({
|
|
|
407
321
|
}
|
|
408
322
|
}
|
|
409
323
|
|
|
410
|
-
|
|
324
|
+
// A claimed language has a renderer: the list is loaded by then.
|
|
325
|
+
function needsDraw(pre, figure) {
|
|
326
|
+
const lang = langOf(pre);
|
|
327
|
+
const source = pre.textContent;
|
|
328
|
+
const now = theme(doc, win);
|
|
329
|
+
if (figure && figure.dataset.theme === now.name) return false;
|
|
330
|
+
const hit = cache.get(cacheKey(lang, now.name, source));
|
|
331
|
+
if (hit) { place(pre, hit, now.name, lang); return false; } // no flash: this frame
|
|
332
|
+
if (!figure) {
|
|
333
|
+
if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { failBlock(pre, lang, "it is over 64 KB"); return false; }
|
|
334
|
+
const mark = markKey(lang, source);
|
|
335
|
+
if (storage.has(mark) && !inFlight.has(mark)) { failBlock(pre, lang, "it hung or took too long the last time"); return false; }
|
|
336
|
+
}
|
|
337
|
+
checked.set(pre, { lang, source, renderer: list.get(lang) });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function draw(pre, { reconsider }) {
|
|
342
|
+
const { lang, source, renderer } = checked.get(pre);
|
|
411
343
|
const now = theme(doc, win);
|
|
412
344
|
const key = cacheKey(lang, now.name, source);
|
|
413
345
|
const hit = cache.get(key);
|
|
414
|
-
if (hit) {
|
|
415
|
-
if (broken.has(lang)) {
|
|
346
|
+
if (hit) { place(pre, hit, now.name, lang); return; }
|
|
347
|
+
if (broken.has(lang)) { failBlock(pre, lang, broken.get(lang)); return; }
|
|
416
348
|
let retry = false;
|
|
417
349
|
try {
|
|
418
350
|
place(pre, await pictureFor(key, lang, source, renderer, now), now.name, lang);
|
|
419
351
|
} catch (e) {
|
|
420
352
|
// A redraw that failed keeps the picture it has.
|
|
421
|
-
if (!e?.dropped && !figureOf(pre))
|
|
353
|
+
if (!e?.dropped && !figureOf(pre)) failBlock(pre, lang, e?.message);
|
|
422
354
|
retry = !!e?.dropped;
|
|
423
|
-
} finally {
|
|
424
|
-
waiting.delete(pre);
|
|
425
355
|
}
|
|
426
356
|
// Queued behind a frame that died (another block's timeout): a new frame's
|
|
427
357
|
// turn. A session switch's frames go with their blocks, which aren't connected.
|
|
@@ -430,100 +360,45 @@ export function createBlockHost({
|
|
|
430
360
|
if (!retry) return;
|
|
431
361
|
const tries = (retries.get(pre) || 0) + 1;
|
|
432
362
|
retries.set(pre, tries);
|
|
433
|
-
if (tries <= MAX_RETRIES)
|
|
434
|
-
else
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// A copy.js wrap took the <pre> away from a figure placed first: back with it.
|
|
438
|
-
function rejoin(pre) {
|
|
439
|
-
if (figureOf(pre) || !pre.hidden) return;
|
|
440
|
-
const wrap = pre.parentElement;
|
|
441
|
-
const stray = wrap?.classList.contains("code-wrap") && wrap.previousElementSibling;
|
|
442
|
-
if (stray?.classList?.contains(DRAWN)) wrap.insertBefore(stray, pre);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function watchFor(entry) {
|
|
446
|
-
const scroller = scrollerOf(entry.target, rootOf(entry.target), win);
|
|
447
|
-
if (scroller === entry.scroller) return;
|
|
448
|
-
entry.scroller = scroller;
|
|
449
|
-
near(entry.target, scroller, entry.start);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function consider(pre) {
|
|
453
|
-
if (!pre.isConnected || !pre.closest(TEXTS) || pre.dataset.blockFailed) return;
|
|
454
|
-
const pending = waiting.get(pre);
|
|
455
|
-
if (pending) {
|
|
456
|
-
if (!pending.drawing) watchFor(pending);
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
rejoin(pre);
|
|
460
|
-
const lang = pre.getAttribute("data-lang");
|
|
461
|
-
const source = pre.textContent;
|
|
462
|
-
if (!source.trim()) return; // an empty fence is left alone
|
|
463
|
-
withRenderers((renderers) => {
|
|
464
|
-
const renderer = renderers.get(lang);
|
|
465
|
-
if (!renderer || !pre.isConnected || waiting.has(pre) || pre.dataset.blockFailed) return;
|
|
466
|
-
const now = theme(doc, win);
|
|
467
|
-
const figure = figureOf(pre);
|
|
468
|
-
if (figure && figure.dataset.theme === now.name) return;
|
|
469
|
-
const hit = cache.get(cacheKey(lang, now.name, source));
|
|
470
|
-
if (hit) { place(pre, hit, now.name, lang); return; } // no flash: this frame
|
|
471
|
-
if (!figure) {
|
|
472
|
-
if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { fail(pre, lang, "it is over 64 KB"); return; }
|
|
473
|
-
const mark = markKey(lang, source);
|
|
474
|
-
if (storage.has(mark) && !inFlight.has(mark)) { fail(pre, lang, "it hung or took too long the last time"); return; }
|
|
475
|
-
}
|
|
476
|
-
// A drawn block's <pre> is hidden, which never intersects: its figure does.
|
|
477
|
-
const entry = {
|
|
478
|
-
target: figure || pre,
|
|
479
|
-
scroller: null,
|
|
480
|
-
drawing: false,
|
|
481
|
-
start: () => { entry.drawing = true; draw(pre, lang, source, renderer); },
|
|
482
|
-
};
|
|
483
|
-
waiting.set(pre, entry);
|
|
484
|
-
watchFor(entry);
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function scan(el) {
|
|
489
|
-
if (el.matches?.(BLOCKS)) consider(el);
|
|
490
|
-
for (const pre of el.querySelectorAll?.(BLOCKS) || []) consider(pre);
|
|
363
|
+
if (tries <= MAX_RETRIES) reconsider(pre);
|
|
364
|
+
else failBlock(pre, lang, "its renderer keeps stopping");
|
|
491
365
|
}
|
|
492
366
|
|
|
493
367
|
for (const root of roots) {
|
|
494
|
-
new win.MutationObserver((records) => {
|
|
495
|
-
noticeSession();
|
|
496
|
-
for (const record of records) {
|
|
497
|
-
for (const node of record.addedNodes) if (node.nodeType === 1) scan(node);
|
|
498
|
-
}
|
|
499
|
-
}).observe(root, { childList: true, subtree: true });
|
|
500
|
-
scan(root);
|
|
501
|
-
|
|
502
368
|
root.addEventListener("mousedown", (e) => {
|
|
503
|
-
if (e.target.closest?.(".block-
|
|
369
|
+
if (e.target.closest?.(".block-zoom")) e.preventDefault();
|
|
504
370
|
});
|
|
505
371
|
// Delegated, so a Revise clone's work too.
|
|
506
372
|
root.addEventListener("click", (e) => {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
e.stopPropagation();
|
|
511
|
-
const figure = toggle.parentElement;
|
|
512
|
-
setSourceShown(figure, figure.nextElementSibling, !figure.classList.contains("source"));
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
const note = e.target.closest?.(".block-note");
|
|
516
|
-
if (note) { copy(note.dataset.text); return; }
|
|
517
|
-
if (e.target.closest?.(".block-zoom, .block-img")) {
|
|
518
|
-
const figure = e.target.closest(`.${DRAWN}`);
|
|
519
|
-
setZoomed(figure, !figure.classList.contains("natural"));
|
|
520
|
-
}
|
|
373
|
+
if (!e.target.closest?.(".block-zoom, .block-img")) return;
|
|
374
|
+
const figure = e.target.closest(`.${DRAWN}`);
|
|
375
|
+
setZoomed(figure, !figure.classList.contains("natural"));
|
|
521
376
|
});
|
|
522
377
|
}
|
|
523
378
|
|
|
379
|
+
return {
|
|
380
|
+
claims(lang) {
|
|
381
|
+
const known = renderers();
|
|
382
|
+
return known.then ? known.then((got) => got.has(lang)) : known.has(lang);
|
|
383
|
+
},
|
|
384
|
+
needsDraw,
|
|
385
|
+
draw,
|
|
386
|
+
sessionChanged: dropFrames,
|
|
387
|
+
dropFrames,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// @param roots the turn roots (app.js turnRoots())
|
|
392
|
+
// @param sessionId () => the open session's id: the frames go on a change, to
|
|
393
|
+
// limit what they hold (docs/internals/block-renderers.md, residual risk)
|
|
394
|
+
// @param drawers core drawers, asked before the bundle renderers
|
|
395
|
+
// The rest are seams for the specs.
|
|
396
|
+
export function createBlockHost({ roots, sessionId, drawers = [], copy = copyText, watch = null, win = window, ...frameOpts }) {
|
|
397
|
+
const frames = createFrameDrawer({ roots, win, ...frameOpts });
|
|
398
|
+
const watcher = createBlockWatch({ roots, sessionId, drawers: [...drawers, frames], watch, copy, win });
|
|
399
|
+
// A system theme change redraws through the figure (needsDraw's theme check).
|
|
524
400
|
win.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
|
|
525
|
-
for (const root of roots) scan(root);
|
|
401
|
+
for (const root of roots) watcher.scan(root);
|
|
526
402
|
});
|
|
527
|
-
|
|
528
|
-
return { scan, dropFrames };
|
|
403
|
+
return { scan: watcher.scan, dropFrames: frames.dropFrames };
|
|
529
404
|
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
.block-img{display:block;max-width:100%;max-height:50000px;object-fit:contain;height:auto;margin:0 auto;cursor:zoom-in}
|
|
9
9
|
.block-drawn.natural .block-img{max-width:50000px;margin:0;cursor:zoom-out}
|
|
10
10
|
.block-drawn.source .block-scroll{display:none}
|
|
11
|
+
/* A chi-ui widget (inline_widgets.js): its host holds the shadow root, whose own sheet is widgets.css; the source hides it as the picture. */
|
|
12
|
+
.block-drawn.source>.widget-host{display:none}
|
|
11
13
|
.block-toggle,.block-zoom{position:absolute;top:5px;right:5px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-3);opacity:0;transition:opacity .12s}
|
|
12
14
|
.code-wrap>.block-drawn>.block-toggle{right:34px}
|
|
13
15
|
.block-zoom{right:34px}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A drawn block's two faces (block_host.js): the picture, or its source. The
|
|
1
|
+
// A drawn block's two faces (block_watch.js, block_host.js): the picture, or its source. The
|
|
2
2
|
// source <pre> stays in the DOM right after the figure, `hidden` while the
|
|
3
3
|
// picture shows, so whatever reads text (a claim's walk, copy) sees what it
|
|
4
4
|
// always did. The DOM is the state: a Revise clone carries both with it.
|
|
@@ -18,15 +18,18 @@ export function figureOf(pre) {
|
|
|
18
18
|
return before?.classList?.contains(DRAWN) ? before : null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
// The source shown (and the picture hidden), or the other way round.
|
|
21
|
+
// The source shown (and the picture hidden), or the other way round. A
|
|
22
|
+
// figure that isn't a picture names its own face (data-toggle-picture: a
|
|
23
|
+
// chi-ui widget's "Show the widget").
|
|
22
24
|
export function setSourceShown(figure, pre, shown) {
|
|
23
25
|
pre.hidden = !shown;
|
|
24
26
|
figure.classList.toggle("source", shown);
|
|
25
27
|
const toggle = figure.querySelector(":scope > .block-toggle");
|
|
26
28
|
if (!toggle) return;
|
|
27
29
|
toggle.setAttribute("aria-pressed", shown ? "true" : "false");
|
|
28
|
-
|
|
29
|
-
toggle.
|
|
30
|
+
const hide = figure.dataset.togglePicture || HIDE;
|
|
31
|
+
toggle.setAttribute("aria-label", shown ? hide : SHOW);
|
|
32
|
+
toggle.title = shown ? hide : SHOW;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
// A jump that lands in a drawn block's source shows it (history_reveal.js).
|