@acornly/ui 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -4
- package/dist/brand.d.ts +50 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +84 -0
- package/dist/brand.js.map +1 -0
- package/dist/card.d.ts +24 -1
- package/dist/card.d.ts.map +1 -1
- package/dist/card.js +49 -9
- package/dist/card.js.map +1 -1
- package/dist/chat-model.d.ts +165 -0
- package/dist/chat-model.d.ts.map +1 -0
- package/dist/chat-model.js +576 -0
- package/dist/chat-model.js.map +1 -0
- package/dist/chat-panel.d.ts +36 -0
- package/dist/chat-panel.d.ts.map +1 -0
- package/dist/chat-panel.js +760 -0
- package/dist/chat-panel.js.map +1 -0
- package/dist/chat-picker.d.ts +56 -0
- package/dist/chat-picker.d.ts.map +1 -0
- package/dist/chat-picker.js +284 -0
- package/dist/chat-picker.js.map +1 -0
- package/dist/chat-protocol.d.ts +151 -0
- package/dist/chat-protocol.d.ts.map +1 -0
- package/dist/chat-protocol.js +113 -0
- package/dist/chat-protocol.js.map +1 -0
- package/dist/chat-speech.d.ts +61 -0
- package/dist/chat-speech.d.ts.map +1 -0
- package/dist/chat-speech.js +119 -0
- package/dist/chat-speech.js.map +1 -0
- package/dist/chat.d.ts +148 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +119 -0
- package/dist/chat.js.map +1 -0
- package/dist/compose.d.ts +1 -1
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js.map +1 -1
- package/dist/composer.d.ts +26 -1
- package/dist/composer.d.ts.map +1 -1
- package/dist/composer.js +73 -5
- package/dist/composer.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +0 -1
- package/dist/media.js.map +1 -1
- package/dist/painter.d.ts +20 -2
- package/dist/painter.d.ts.map +1 -1
- package/dist/painter.js +41 -6
- package/dist/painter.js.map +1 -1
- package/dist/panel.js +5 -5
- package/dist/parts.d.ts +2 -0
- package/dist/parts.d.ts.map +1 -1
- package/dist/parts.js +2 -0
- package/dist/parts.js.map +1 -1
- package/dist/pins.d.ts.map +1 -1
- package/dist/pins.js +14 -9
- package/dist/pins.js.map +1 -1
- package/dist/theme.d.ts +1 -1
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +14 -34
- package/dist/theme.js.map +1 -1
- package/dist/toolbar.js +3 -3
- package/package.json +11 -3
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { CHAT_LIMITS, focusLabel, lineage, } from "./chat-protocol.js";
|
|
2
|
+
import { ChatCallError, buildRequest, conversationView, draftTakeaway, estimateTokens, proxyTransport, relayTransport, runTurn, } from "./chat-model.js";
|
|
3
|
+
import { collectMarks, outlineItems, textTree } from "./chat-picker.js";
|
|
4
|
+
import { h, styleElement } from "./dom.js";
|
|
5
|
+
import { activeElement } from "./focus.js";
|
|
6
|
+
import { PARTS } from "./parts.js";
|
|
7
|
+
const CHAT_CSS = `
|
|
8
|
+
.ctx-chat[hidden] { display: none; }
|
|
9
|
+
.ctx-chat { position: fixed; z-index: 1; top: 0; bottom: 0; right: 0; box-sizing: border-box; width: min(380px, 100vw); display: flex; flex-direction: column; background: var(--_background); color: var(--_foreground); border-left: 1px solid var(--_border); box-shadow: var(--_shadow); font: inherit; text-align: start; }
|
|
10
|
+
@media (max-width: 520px) { .ctx-chat { top: auto; left: 0; width: 100vw; height: 62vh; height: 62dvh; padding-bottom: env(safe-area-inset-bottom); border-left: 0; border-top: 1px solid var(--_border); border-radius: var(--_radius) var(--_radius) 0 0; } }
|
|
11
|
+
.ctx-chat-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--_border); }
|
|
12
|
+
.ctx-chat h2 { margin: 0; font-size: 1.07em; font-weight: 500; }
|
|
13
|
+
.ctx-chat button { font: inherit; }
|
|
14
|
+
.ctx-chat-x { all: unset; padding: 2px 6px; color: var(--_muted); font-size: 18px; line-height: 1; cursor: pointer; }
|
|
15
|
+
.ctx-chat-x:focus-visible, .ctx-chat-link:focus-visible, .ctx-chat-pill:focus-visible { outline: 2px solid var(--_accent); }
|
|
16
|
+
.ctx-chat-chip { margin: 10px 14px 0; padding: 8px 10px; border: 1px solid var(--_border); border-radius: calc(var(--_radius) - 4px); font-size: 0.9em; }
|
|
17
|
+
.ctx-chat-chip strong { font-weight: 600; }
|
|
18
|
+
.ctx-chat-link { all: unset; margin-inline-start: 8px; color: var(--_accent); font-weight: 600; cursor: pointer; }
|
|
19
|
+
.ctx-chat-chip details { margin-top: 4px; color: var(--_muted); font-size: 0.9em; }
|
|
20
|
+
.ctx-chat-chip ul { margin: 4px 0 0; padding-inline-start: 18px; }
|
|
21
|
+
.ctx-chat-offer { margin: 6px 14px 0; font-size: 0.86em; color: var(--_muted); }
|
|
22
|
+
.ctx-chat-tree { list-style: none; margin: 6px 0 0; padding: 0; max-height: 40vh; overflow: auto; border-top: 1px solid var(--_border); }
|
|
23
|
+
.ctx-chat-tree [role="treeitem"] { padding: 4px 8px; cursor: pointer; }
|
|
24
|
+
.ctx-chat-tree [role="treeitem"]:focus-visible { outline: 2px solid var(--_accent); outline-offset: -2px; }
|
|
25
|
+
.ctx-chat-tree [aria-selected="true"] { font-weight: 600; }
|
|
26
|
+
.ctx-chat-log { flex: 1; overflow: auto; padding: 10px 14px; }
|
|
27
|
+
.ctx-chat-q { margin: 10px 0 4px auto; max-width: 85%; width: fit-content; padding: 6px 10px; border-radius: calc(var(--_radius) - 4px); background: color-mix(in srgb, var(--_accent) 12%, transparent); white-space: pre-wrap; }
|
|
28
|
+
.ctx-chat-a { margin: 4px 0 10px; line-height: 1.45; }
|
|
29
|
+
.ctx-chat-a p { margin: 0 0 6px; }
|
|
30
|
+
.ctx-chat-typing { display: inline-flex; gap: 4px; padding: 6px 2px; }
|
|
31
|
+
.ctx-chat-typing span:not(.ctx-chat-sr) { width: 6px; height: 6px; border-radius: 50%; background: var(--_muted); animation: ctx-chat-dot 1.2s ease-in-out infinite; }
|
|
32
|
+
.ctx-chat-typing span:nth-child(2) { animation-delay: 0.15s; }
|
|
33
|
+
.ctx-chat-typing span:nth-child(3) { animation-delay: 0.3s; }
|
|
34
|
+
@keyframes ctx-chat-dot { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
|
|
35
|
+
@media (prefers-reduced-motion: reduce) { .ctx-chat-typing span:not(.ctx-chat-sr) { animation: none; opacity: 0.6; } }
|
|
36
|
+
.ctx-chat-meta { color: var(--_muted); font-size: 0.79em; }
|
|
37
|
+
.ctx-chat-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
|
38
|
+
.ctx-chat-pill { all: unset; padding: 3px 12px; border: 1px solid var(--_border); border-radius: 999px; font-size: 0.86em; cursor: pointer; }
|
|
39
|
+
.ctx-chat-pill:hover { border-color: var(--_accent); }
|
|
40
|
+
.ctx-chat-pill[data-primary] { background: var(--_accent); border-color: var(--_accent); color: var(--_accent-contrast); }
|
|
41
|
+
.ctx-chat-keep textarea, .ctx-chat-form textarea { box-sizing: border-box; width: 100%; min-height: 3em; padding: 6px 8px; border: 1px solid var(--_border); border-radius: calc(var(--_radius) - 6px); background: transparent; color: inherit; font: inherit; resize: vertical; }
|
|
42
|
+
.ctx-chat-form { padding: 10px 14px; border-top: 1px solid var(--_border); }
|
|
43
|
+
.ctx-chat-note { margin: 4px 0 0; color: var(--_muted); font-size: 0.79em; }
|
|
44
|
+
.ctx-chat-error { margin: 6px 0 10px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--_accent) 30%, var(--_border)); border-radius: calc(var(--_radius) - 4px); background: color-mix(in srgb, var(--_accent) 8%, transparent); color: var(--_foreground); line-height: 1.45; }
|
|
45
|
+
.ctx-chat-error a { display: block; width: fit-content; margin-top: 8px; padding: 3px 12px; border-radius: 999px; background: var(--_accent); color: var(--_accent-contrast); font-size: 0.86em; font-weight: 600; text-decoration: none; }
|
|
46
|
+
.ctx-chat-error a:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; }
|
|
47
|
+
.ctx-chat-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
48
|
+
.ctx-chat-modes { display: flex; gap: 6px; margin-bottom: 6px; }
|
|
49
|
+
.ctx-chat-modes .ctx-chat-pill[aria-pressed="true"] { border-color: var(--_accent); color: var(--_accent); font-weight: 600; }
|
|
50
|
+
.ctx-chat-mic[data-listening] { border-color: var(--_accent); color: var(--_accent); }
|
|
51
|
+
.ctx-chat-mic[data-listening]::before { content: ""; display: inline-block; width: 0.5em; height: 0.5em; margin-inline-end: 6px; border-radius: 50%; background: currentColor; vertical-align: 0.1em; }
|
|
52
|
+
`;
|
|
53
|
+
/** The acorn target for an item: its anchor, found by id first, then by quote and source. */
|
|
54
|
+
export function keptTarget(item) {
|
|
55
|
+
if (!item.anchor)
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
source: item.anchor.source,
|
|
59
|
+
selector: [
|
|
60
|
+
{ type: "ProviderSelector", provider: "acornly-context", version: 1, value: { id: item.id } },
|
|
61
|
+
...(item.anchor.quote
|
|
62
|
+
? [{ type: "TextQuoteSelector", exact: item.anchor.quote }]
|
|
63
|
+
: []),
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The model proxy's reasons a failed ask names, with the hub page that fixes each one it can. */
|
|
68
|
+
const REASONS = new Map([
|
|
69
|
+
["no_grant", { label: "noGrant", page: "openAppsYouUse" }],
|
|
70
|
+
["no_key", { label: "noKey", page: "openYourAi" }],
|
|
71
|
+
["budget_exhausted", { label: "budgetExhausted", page: "openAppsYouUse" }],
|
|
72
|
+
["inactive", { label: "inactive" }],
|
|
73
|
+
["rate_limited", { label: "rateLimited" }],
|
|
74
|
+
]);
|
|
75
|
+
const HUB_PAGES = { openAppsYouUse: "/apps-you-use", openYourAi: "/ai" };
|
|
76
|
+
/** The proxy's reason for a failed call, when the panel names it. */
|
|
77
|
+
function reasonOf(error) {
|
|
78
|
+
return error instanceof ChatCallError && error.code ? REASONS.get(error.code) : undefined;
|
|
79
|
+
}
|
|
80
|
+
/** A page on the app's hub; an unset or non-http(s) `hubUrl` falls back to the hosted hub. */
|
|
81
|
+
export function hubLink(hubUrl, path) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(path, hubUrl ?? "https://acornly.ai");
|
|
84
|
+
if (url.protocol === "https:" || url.protocol === "http:")
|
|
85
|
+
return url.href;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// An unparseable hubUrl is the app's mistake; the hosted hub still helps the person.
|
|
89
|
+
}
|
|
90
|
+
return new URL(path, "https://acornly.ai").href;
|
|
91
|
+
}
|
|
92
|
+
export function openChatPanel(layer, options, labels, initial, initialMode) {
|
|
93
|
+
const doc = layer.ownerDocument;
|
|
94
|
+
const returnFocus = activeElement(doc);
|
|
95
|
+
let snapshot = initial.snapshot;
|
|
96
|
+
let dropped = initial.dropped;
|
|
97
|
+
let focus = [...snapshot.focus];
|
|
98
|
+
const loaded = new Map();
|
|
99
|
+
// The conversation's copy of its focus and the focus's ancestors, as last seen (design doc, 11).
|
|
100
|
+
const kept = new Map();
|
|
101
|
+
const turns = [];
|
|
102
|
+
let unusable = false;
|
|
103
|
+
let capped = false;
|
|
104
|
+
let busy = null;
|
|
105
|
+
let picking = null;
|
|
106
|
+
let closed = false;
|
|
107
|
+
const direct = options.proxy ? proxyTransport(options.proxy) : null;
|
|
108
|
+
const relay = options.relay ? relayTransport(options.relay) : null;
|
|
109
|
+
// Comment beside Ask when the app saves comments; Comment alone when it has no model to call.
|
|
110
|
+
const canAsk = direct !== null || relay !== null;
|
|
111
|
+
const canComment = options.onComment !== undefined;
|
|
112
|
+
let mode = canComment && (initialMode === "comment" || !canAsk) ? "comment" : "ask";
|
|
113
|
+
const transport = () => snapshot.rules?.guard === "relay" ? relay : (direct ?? relay);
|
|
114
|
+
/** The guard the rules ask for; `false` when they ask for a callback the app did not give. */
|
|
115
|
+
const guard = () => snapshot.rules?.guard === "callback" ? (options.guard ?? false) : undefined;
|
|
116
|
+
/** What the conversation sees: the latest snapshot, plus its focus once the app moves on. */
|
|
117
|
+
const view = () => conversationView(snapshot, kept).view;
|
|
118
|
+
const title = h(doc, "h2", { id: "ctx-chat-title" }, labels.title);
|
|
119
|
+
const x = h(doc, "button", { class: "ctx-chat-x", type: "button", "aria-label": labels.close }, "×");
|
|
120
|
+
const chipLabel = h(doc, "strong", {});
|
|
121
|
+
const change = h(doc, "button", { class: "ctx-chat-link", type: "button" }, labels.change);
|
|
122
|
+
const details = h(doc, "details", {});
|
|
123
|
+
const treeBox = h(doc, "div", {});
|
|
124
|
+
const chip = h(doc, "div", { class: "ctx-chat-chip" }, `${labels.about} `, chipLabel, change, details, treeBox);
|
|
125
|
+
const offerBox = h(doc, "div", { class: "ctx-chat-offer", "aria-live": "polite" });
|
|
126
|
+
const log = h(doc, "div", { class: "ctx-chat-log", role: "log", "aria-live": "polite" });
|
|
127
|
+
const input = h(doc, "textarea", {
|
|
128
|
+
"aria-label": labels.placeholder,
|
|
129
|
+
placeholder: labels.placeholder,
|
|
130
|
+
rows: "2",
|
|
131
|
+
});
|
|
132
|
+
const send = h(doc, "button", { class: "ctx-chat-pill", type: "submit", "data-primary": "" }, labels.send);
|
|
133
|
+
const recognizer = options.speech === false ? null : speechRecognizer(doc);
|
|
134
|
+
const mic = recognizer
|
|
135
|
+
? h(doc, "button", { class: "ctx-chat-pill ctx-chat-mic", type: "button" }, labels.speak)
|
|
136
|
+
: null;
|
|
137
|
+
const status = h(doc, "p", { class: "ctx-chat-note", "aria-live": "polite" });
|
|
138
|
+
const modeNote = h(doc, "p", { class: "ctx-chat-note" });
|
|
139
|
+
const modeButtons = new Map();
|
|
140
|
+
const modes = h(doc, "div", {
|
|
141
|
+
class: "ctx-chat-modes",
|
|
142
|
+
role: "group",
|
|
143
|
+
"aria-label": labels.modes,
|
|
144
|
+
});
|
|
145
|
+
if (canAsk && canComment) {
|
|
146
|
+
for (const [m, label] of [
|
|
147
|
+
["ask", labels.modeAsk],
|
|
148
|
+
["comment", labels.modeComment],
|
|
149
|
+
]) {
|
|
150
|
+
const button = h(doc, "button", { class: "ctx-chat-pill", type: "button" }, label);
|
|
151
|
+
button.addEventListener("click", () => {
|
|
152
|
+
setMode(m);
|
|
153
|
+
input.focus();
|
|
154
|
+
});
|
|
155
|
+
modeButtons.set(m, button);
|
|
156
|
+
modes.append(button);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Speaking's own line, so it never takes the place of the chat's status.
|
|
160
|
+
const heard = h(doc, "p", { class: "ctx-chat-note", "aria-live": "polite" });
|
|
161
|
+
const form = h(doc, "form", { class: "ctx-chat-form" }, ...(modeButtons.size > 0 ? [modes] : []), input, h(doc, "div", { class: "ctx-chat-row" }, send, mic), status, heard, modeNote);
|
|
162
|
+
const element = h(doc, "aside", { class: "ctx-chat", part: PARTS.chat, "aria-labelledby": "ctx-chat-title" }, styleElement(doc, CHAT_CSS), h(doc, "div", { class: "ctx-chat-header" }, title, x), chip, offerBox, log, form);
|
|
163
|
+
layer.append(element);
|
|
164
|
+
/** Switches the form between asking the chat and commenting on the item. */
|
|
165
|
+
function setMode(next) {
|
|
166
|
+
mode = next;
|
|
167
|
+
const comment = mode === "comment";
|
|
168
|
+
for (const [m, button] of modeButtons)
|
|
169
|
+
button.setAttribute("aria-pressed", String(m === mode));
|
|
170
|
+
const placeholder = comment ? labels.commentPlaceholder : labels.placeholder;
|
|
171
|
+
input.placeholder = placeholder;
|
|
172
|
+
input.setAttribute("aria-label", placeholder);
|
|
173
|
+
send.textContent = comment ? labels.commentSave : labels.send;
|
|
174
|
+
modeNote.textContent = comment ? labels.commentNote : labels.notStored;
|
|
175
|
+
title.textContent = comment && !canAsk ? labels.modeComment : labels.title;
|
|
176
|
+
}
|
|
177
|
+
function itemById(id) {
|
|
178
|
+
return view().items.find((item) => item.id === id) ?? loaded.get(id);
|
|
179
|
+
}
|
|
180
|
+
/** Copies the focus and its ancestors as they are now, so later turns stay about them. */
|
|
181
|
+
function keepFocus() {
|
|
182
|
+
const now = view();
|
|
183
|
+
const next = new Map();
|
|
184
|
+
for (const id of focus) {
|
|
185
|
+
const line = lineage(now, id);
|
|
186
|
+
const own = loaded.get(id);
|
|
187
|
+
if (line.length === 0 && own)
|
|
188
|
+
next.set(id, own);
|
|
189
|
+
for (const item of line)
|
|
190
|
+
next.set(item.id, item);
|
|
191
|
+
}
|
|
192
|
+
kept.clear();
|
|
193
|
+
for (const [id, item] of next)
|
|
194
|
+
kept.set(id, item);
|
|
195
|
+
}
|
|
196
|
+
function renderChip() {
|
|
197
|
+
chipLabel.textContent = focusLabel(view(), focus) || (loaded.get(focus[0] ?? "")?.label ?? "");
|
|
198
|
+
const never = snapshot.rules?.never ?? [];
|
|
199
|
+
details.replaceChildren();
|
|
200
|
+
if (never.length > 0 || dropped.length > 0) {
|
|
201
|
+
details.append(h(doc, "summary", {}, labels.rules));
|
|
202
|
+
if (never.length > 0)
|
|
203
|
+
details.append(h(doc, "ul", {}, ...never.map((line) => h(doc, "li", {}, line))));
|
|
204
|
+
if (dropped.length > 0) {
|
|
205
|
+
details.append(h(doc, "p", {}, labels.dropped), h(doc, "ul", {}, ...dropped.map((d) => h(doc, "li", {}, d))));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
details.hidden = details.childElementCount === 0;
|
|
209
|
+
}
|
|
210
|
+
function renderOffer() {
|
|
211
|
+
offerBox.replaceChildren();
|
|
212
|
+
const now = snapshot.focus;
|
|
213
|
+
if (turns.length === 0 || now.join("\n") === focus.join("\n"))
|
|
214
|
+
return;
|
|
215
|
+
const switchTo = h(doc, "button", { class: "ctx-chat-link", type: "button" }, labels.askInstead);
|
|
216
|
+
switchTo.addEventListener("click", () => setFocus([...now]));
|
|
217
|
+
offerBox.append(labels.nowOn(focusLabel(snapshot, now)), switchTo);
|
|
218
|
+
}
|
|
219
|
+
function setFocus(next) {
|
|
220
|
+
focus = next;
|
|
221
|
+
kept.clear();
|
|
222
|
+
treeBox.replaceChildren();
|
|
223
|
+
renderChip();
|
|
224
|
+
renderOffer();
|
|
225
|
+
status.textContent = labels.nowAsking(chipLabel.textContent ?? "");
|
|
226
|
+
}
|
|
227
|
+
function openTree() {
|
|
228
|
+
const onScreen = new Set(collectMarks(doc, snapshot, options.rect).keys());
|
|
229
|
+
const tree = textTree({
|
|
230
|
+
doc,
|
|
231
|
+
snapshot,
|
|
232
|
+
focus,
|
|
233
|
+
onScreen,
|
|
234
|
+
labels: { tree: labels.tree, inFocus: labels.inFocus, onScreen: labels.onScreen },
|
|
235
|
+
onPick: (id) => {
|
|
236
|
+
setFocus([id]);
|
|
237
|
+
change.focus();
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
treeBox.replaceChildren(tree);
|
|
241
|
+
tree.querySelector('[tabindex="0"]')?.focus();
|
|
242
|
+
}
|
|
243
|
+
// From the keyboard "Change" opens the text tree; with a pointer, the outlines over the page.
|
|
244
|
+
let pointer = false;
|
|
245
|
+
change.addEventListener("pointerdown", () => (pointer = true));
|
|
246
|
+
change.addEventListener("click", () => {
|
|
247
|
+
const viaPointer = pointer;
|
|
248
|
+
pointer = false;
|
|
249
|
+
if (treeBox.firstChild) {
|
|
250
|
+
treeBox.replaceChildren();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// The panel steps aside first, so what it covers can be picked.
|
|
254
|
+
element.hidden = true;
|
|
255
|
+
const marks = viaPointer ? collectMarks(doc, snapshot, options.rect) : new Map();
|
|
256
|
+
if (marks.size === 0) {
|
|
257
|
+
element.hidden = false;
|
|
258
|
+
return openTree();
|
|
259
|
+
}
|
|
260
|
+
picking = outlineItems({
|
|
261
|
+
layer,
|
|
262
|
+
snapshot,
|
|
263
|
+
marks,
|
|
264
|
+
focus,
|
|
265
|
+
labels: { hint: labels.pickHint, list: labels.pickList, cancel: labels.cancel },
|
|
266
|
+
onPick: (id) => {
|
|
267
|
+
element.hidden = false;
|
|
268
|
+
picking = null;
|
|
269
|
+
setFocus([id]);
|
|
270
|
+
change.focus();
|
|
271
|
+
},
|
|
272
|
+
onList: () => {
|
|
273
|
+
element.hidden = false;
|
|
274
|
+
picking = null;
|
|
275
|
+
openTree();
|
|
276
|
+
},
|
|
277
|
+
onCancel: () => {
|
|
278
|
+
element.hidden = false;
|
|
279
|
+
picking = null;
|
|
280
|
+
change.focus();
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
async function renderReply(el, text) {
|
|
285
|
+
const { markdownFragment } = await import("./markdown/render.js");
|
|
286
|
+
const { source, math } = options.renderMath ? protectMath(text) : { source: text, math: [] };
|
|
287
|
+
const fragment = markdownFragment(source, doc);
|
|
288
|
+
if (options.renderMath && math.length > 0)
|
|
289
|
+
restoreMath(fragment, math, options.renderMath);
|
|
290
|
+
el.replaceChildren(fragment);
|
|
291
|
+
}
|
|
292
|
+
function offerButtons(offers, into) {
|
|
293
|
+
for (const offer of offers) {
|
|
294
|
+
const hb = snapshot.handBacks?.find((b) => b.id === offer.handBack);
|
|
295
|
+
if (!hb)
|
|
296
|
+
continue;
|
|
297
|
+
const item = itemById(offer.item);
|
|
298
|
+
const button = h(doc, "button", { class: "ctx-chat-pill", type: "button" }, item && offer.item !== focus[0] ? `${hb.label}: ${item.label}` : hb.label);
|
|
299
|
+
button.addEventListener("click", () => options.onHandBack?.(offer.handBack, offer.item));
|
|
300
|
+
into.append(button);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function keptItem() {
|
|
304
|
+
const now = view();
|
|
305
|
+
for (const id of focus) {
|
|
306
|
+
const found = lineage(now, id).find((item) => item.anchor) ??
|
|
307
|
+
(loaded.get(id)?.anchor ? loaded.get(id) : undefined);
|
|
308
|
+
if (found)
|
|
309
|
+
return found;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
function keepButton(turn, row) {
|
|
314
|
+
const item = keptItem();
|
|
315
|
+
if (!item || !options.onKeep)
|
|
316
|
+
return;
|
|
317
|
+
const keep = h(doc, "button", { class: "ctx-chat-pill", type: "button" }, labels.keep);
|
|
318
|
+
keep.addEventListener("click", async () => {
|
|
319
|
+
const t = transport();
|
|
320
|
+
if (!t)
|
|
321
|
+
return;
|
|
322
|
+
keep.disabled = true;
|
|
323
|
+
const box = h(doc, "div", { class: "ctx-chat-keep" });
|
|
324
|
+
turn.el.append(box);
|
|
325
|
+
const check = guard();
|
|
326
|
+
if (capped || check === false) {
|
|
327
|
+
// No draft without the app's guard, or past chat's share: the person writes their own.
|
|
328
|
+
box.append(h(doc, "p", { class: "ctx-chat-meta" }, capped ? labels.capReached : labels.noGuard));
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
box.append(h(doc, "p", { class: "ctx-chat-meta" }, labels.drafting));
|
|
332
|
+
}
|
|
333
|
+
let draft = "";
|
|
334
|
+
if (!capped && check !== false) {
|
|
335
|
+
try {
|
|
336
|
+
const abort = new AbortController();
|
|
337
|
+
draft = await draftTakeaway(turn, snapshot, options.model, t, abort.signal, check);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (error instanceof ChatCallError && error.code === "feature_cap")
|
|
341
|
+
capped = true;
|
|
342
|
+
// A reason the person can act on (a spent budget, say) is said here too.
|
|
343
|
+
if (!capped && reasonOf(error))
|
|
344
|
+
box.append(failure(error));
|
|
345
|
+
draft = "";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const area = h(doc, "textarea", { "aria-label": labels.takeaway });
|
|
349
|
+
area.value = draft;
|
|
350
|
+
const save = h(doc, "button", { class: "ctx-chat-pill", type: "button", "data-primary": "" }, labels.save);
|
|
351
|
+
const cancel = h(doc, "button", { class: "ctx-chat-pill", type: "button" }, labels.cancel);
|
|
352
|
+
const note = capped || check === false ? [box.firstChild] : [];
|
|
353
|
+
box.replaceChildren(...note, area, h(doc, "div", { class: "ctx-chat-row" }, save, cancel));
|
|
354
|
+
area.focus();
|
|
355
|
+
cancel.addEventListener("click", () => {
|
|
356
|
+
box.remove();
|
|
357
|
+
keep.disabled = false;
|
|
358
|
+
});
|
|
359
|
+
save.addEventListener("click", async () => {
|
|
360
|
+
const text = area.value.trim();
|
|
361
|
+
const target = keptTarget(item);
|
|
362
|
+
if (!text || !target)
|
|
363
|
+
return;
|
|
364
|
+
save.disabled = true;
|
|
365
|
+
const takeaway = noteOn(item, text, target);
|
|
366
|
+
try {
|
|
367
|
+
await options.onKeep(takeaway);
|
|
368
|
+
box.replaceChildren(h(doc, "p", { class: "ctx-chat-meta" }, labels.kept));
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
save.disabled = false;
|
|
372
|
+
box.append(h(doc, "p", { class: "ctx-chat-meta ctx-chat-error" }, labels.keepFailed));
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
row.append(keep);
|
|
377
|
+
}
|
|
378
|
+
/** A takeaway or comment on `item`, as the app saves it. */
|
|
379
|
+
function noteOn(item, text, target) {
|
|
380
|
+
const note = { text, item: item.id, target, about: item.about ?? [] };
|
|
381
|
+
if (item.anchor?.quote)
|
|
382
|
+
note.quote = item.anchor.quote;
|
|
383
|
+
return note;
|
|
384
|
+
}
|
|
385
|
+
/** Saves a comment on the item in focus (or its nearest ancestor that can be anchored to). */
|
|
386
|
+
async function comment(text) {
|
|
387
|
+
const item = keptItem();
|
|
388
|
+
const target = item && keptTarget(item);
|
|
389
|
+
if (!item || !target) {
|
|
390
|
+
status.textContent = labels.noAnchor;
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
busy = new AbortController();
|
|
394
|
+
send.disabled = true;
|
|
395
|
+
status.textContent = "";
|
|
396
|
+
try {
|
|
397
|
+
await options.onComment(noteOn(item, text, target));
|
|
398
|
+
log.append(h(doc, "div", { class: "ctx-chat-q" }, text), h(doc, "p", { class: "ctx-chat-meta" }, labels.commented(item.label)));
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
status.textContent = labels.commentFailed;
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
busy = null;
|
|
407
|
+
send.disabled = false;
|
|
408
|
+
log.scrollTop = log.scrollHeight;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async function ask(question) {
|
|
412
|
+
const t = transport();
|
|
413
|
+
if (!t) {
|
|
414
|
+
status.textContent = labels.noTransport;
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (unusable) {
|
|
418
|
+
status.textContent = labels.badContext;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (capped) {
|
|
422
|
+
status.textContent = labels.capReached;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const check = guard();
|
|
426
|
+
if (check === false) {
|
|
427
|
+
// The rules ask for the app's guard and there is none: nothing is sent (fails closed).
|
|
428
|
+
status.textContent = labels.noGuard;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (turns.length >= CHAT_LIMITS.turns)
|
|
432
|
+
return full();
|
|
433
|
+
keepFocus();
|
|
434
|
+
const turnInput = {
|
|
435
|
+
snapshot,
|
|
436
|
+
focus,
|
|
437
|
+
loaded,
|
|
438
|
+
kept,
|
|
439
|
+
history: turns,
|
|
440
|
+
question,
|
|
441
|
+
model: options.model,
|
|
442
|
+
};
|
|
443
|
+
const { body } = await buildRequest(turnInput);
|
|
444
|
+
if (estimateTokens(body) > CHAT_LIMITS.conversationTokens)
|
|
445
|
+
return full();
|
|
446
|
+
const el = h(doc, "div", { class: "ctx-chat-a" });
|
|
447
|
+
const turn = { question, reply: "", el };
|
|
448
|
+
log.append(h(doc, "div", { class: "ctx-chat-q" }, question), el);
|
|
449
|
+
// The reply as it streams, its Markdown drawn once a frame, after dots while nothing has come.
|
|
450
|
+
const live = h(doc, "div", { class: "ctx-chat-live" });
|
|
451
|
+
const typing = h(doc, "div", { class: "ctx-chat-typing", role: "status" }, h(doc, "span", {}), h(doc, "span", {}), h(doc, "span", {}), h(doc, "span", { class: "ctx-chat-sr" }, labels.thinking));
|
|
452
|
+
el.append(live, typing);
|
|
453
|
+
let streamed = "";
|
|
454
|
+
let frame = 0;
|
|
455
|
+
const drawLive = () => {
|
|
456
|
+
frame = 0;
|
|
457
|
+
void renderReply(live, streamed);
|
|
458
|
+
log.scrollTop = log.scrollHeight;
|
|
459
|
+
};
|
|
460
|
+
busy = new AbortController();
|
|
461
|
+
send.disabled = true;
|
|
462
|
+
// Screen readers hear the reply once it is whole, not each frame of it.
|
|
463
|
+
log.setAttribute("aria-busy", "true");
|
|
464
|
+
const reads = [];
|
|
465
|
+
try {
|
|
466
|
+
const result = await runTurn(turnInput, {
|
|
467
|
+
transport: t,
|
|
468
|
+
load: async (id) => {
|
|
469
|
+
const item = (await options.load?.(id)) ?? null;
|
|
470
|
+
if (item)
|
|
471
|
+
loaded.set(item.id, item);
|
|
472
|
+
return item;
|
|
473
|
+
},
|
|
474
|
+
guard: check,
|
|
475
|
+
onText: (text) => {
|
|
476
|
+
streamed += text;
|
|
477
|
+
typing.remove();
|
|
478
|
+
frame ||= (doc.defaultView ?? window).requestAnimationFrame(drawLive);
|
|
479
|
+
},
|
|
480
|
+
onRead: (item) => {
|
|
481
|
+
reads.push(item.label);
|
|
482
|
+
el.append(typing);
|
|
483
|
+
},
|
|
484
|
+
signal: busy.signal,
|
|
485
|
+
});
|
|
486
|
+
if (frame)
|
|
487
|
+
(doc.defaultView ?? window).cancelAnimationFrame(frame);
|
|
488
|
+
turn.reply = result.refused ? labels.refused : result.reply;
|
|
489
|
+
turns.push(turn);
|
|
490
|
+
const body = h(doc, "div", {});
|
|
491
|
+
el.replaceChildren(body);
|
|
492
|
+
await renderReply(body, turn.reply);
|
|
493
|
+
if (reads.length > 0)
|
|
494
|
+
el.append(h(doc, "p", { class: "ctx-chat-meta" }, labels.read(reads)));
|
|
495
|
+
const row = h(doc, "div", { class: "ctx-chat-row" });
|
|
496
|
+
const offers = result.refused
|
|
497
|
+
? (snapshot.rules?.insteadOffer ?? []).map((handBack) => ({
|
|
498
|
+
handBack,
|
|
499
|
+
item: focus[0] ?? "",
|
|
500
|
+
}))
|
|
501
|
+
: result.offers;
|
|
502
|
+
offerButtons(offers, row);
|
|
503
|
+
if (!result.refused)
|
|
504
|
+
keepButton(turn, row);
|
|
505
|
+
if (row.firstChild)
|
|
506
|
+
el.append(row);
|
|
507
|
+
if (result.dropped.length > 0)
|
|
508
|
+
dropped = [...new Set([...dropped, ...result.dropped])];
|
|
509
|
+
renderChip();
|
|
510
|
+
renderOffer();
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (error instanceof ChatCallError && error.code === "feature_cap")
|
|
514
|
+
capped = true;
|
|
515
|
+
el.replaceChildren(failure(error));
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
busy = null;
|
|
519
|
+
send.disabled = false;
|
|
520
|
+
log.removeAttribute("aria-busy");
|
|
521
|
+
log.scrollTop = log.scrollHeight;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Why an ask failed, with a link to the hub page that fixes it when the proxy said why. */
|
|
525
|
+
function failure(error) {
|
|
526
|
+
if (capped)
|
|
527
|
+
return h(doc, "p", { class: "ctx-chat-error" }, labels.capReached);
|
|
528
|
+
const reason = reasonOf(error);
|
|
529
|
+
if (!reason)
|
|
530
|
+
return h(doc, "p", { class: "ctx-chat-error" }, labels.failed);
|
|
531
|
+
const link = reason.page &&
|
|
532
|
+
h(doc, "a", {
|
|
533
|
+
href: hubLink(options.hubUrl, HUB_PAGES[reason.page]),
|
|
534
|
+
target: "_blank",
|
|
535
|
+
rel: "noopener noreferrer",
|
|
536
|
+
}, labels[reason.page], h(doc, "span", { class: "ctx-chat-sr" }, ` ${labels.newTab}`));
|
|
537
|
+
return h(doc, "p", { class: "ctx-chat-error" }, labels[reason.label], link && " ", link);
|
|
538
|
+
}
|
|
539
|
+
function full() {
|
|
540
|
+
status.replaceChildren(labels.full, " ");
|
|
541
|
+
const fresh = h(doc, "button", { class: "ctx-chat-link", type: "button" }, labels.newChat);
|
|
542
|
+
fresh.addEventListener("click", () => {
|
|
543
|
+
turns.length = 0;
|
|
544
|
+
loaded.clear();
|
|
545
|
+
kept.clear();
|
|
546
|
+
log.replaceChildren();
|
|
547
|
+
status.replaceChildren();
|
|
548
|
+
setFocus([...snapshot.focus]);
|
|
549
|
+
});
|
|
550
|
+
status.append(fresh);
|
|
551
|
+
}
|
|
552
|
+
// Speaking (#734): the browser's recognizer writes into the box; nothing is sent until Send.
|
|
553
|
+
let dictation = null;
|
|
554
|
+
let starting = false;
|
|
555
|
+
let stopping = false;
|
|
556
|
+
function showListening(on, note = "") {
|
|
557
|
+
mic.toggleAttribute("data-listening", on);
|
|
558
|
+
mic.textContent = on ? labels.stopSpeaking : labels.speak;
|
|
559
|
+
heard.textContent = note;
|
|
560
|
+
try {
|
|
561
|
+
options.onListening?.(on);
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
console.error(error);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function startListening() {
|
|
568
|
+
if (dictation || starting || !recognizer)
|
|
569
|
+
return;
|
|
570
|
+
starting = true;
|
|
571
|
+
stopping = false;
|
|
572
|
+
let speech;
|
|
573
|
+
try {
|
|
574
|
+
speech = await import("./chat-speech.js");
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
starting = false;
|
|
578
|
+
// The messages are in the chunk that failed; Speak goes, and typing stays.
|
|
579
|
+
mic.hidden = true;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// Sending, typing or closing while the chunk loaded cancels the start.
|
|
583
|
+
if (!starting || closed)
|
|
584
|
+
return;
|
|
585
|
+
starting = false;
|
|
586
|
+
const said = { ...speech.DEFAULT_SPEECH_LABELS, ...labels };
|
|
587
|
+
// What the person typed stays; the words they speak follow it.
|
|
588
|
+
const typed = input.value.trimEnd();
|
|
589
|
+
const lang = (options.speech && options.speech.lang) || doc.documentElement.lang || undefined;
|
|
590
|
+
const own = speech.dictate(recognizer, {
|
|
591
|
+
lang,
|
|
592
|
+
onText: (text) => {
|
|
593
|
+
if (dictation !== own)
|
|
594
|
+
return;
|
|
595
|
+
input.value = typed && text ? `${typed} ${text}` : typed || text;
|
|
596
|
+
},
|
|
597
|
+
onEnd: (error) => {
|
|
598
|
+
if (dictation !== own)
|
|
599
|
+
return;
|
|
600
|
+
dictation = null;
|
|
601
|
+
showListening(false, error ? speech.speechError(error, said) : "");
|
|
602
|
+
// Back to the box after Stop; not when the browser ends it, which would pop up a keyboard.
|
|
603
|
+
if (stopping && !closed)
|
|
604
|
+
input.focus();
|
|
605
|
+
stopping = false;
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
dictation = own;
|
|
609
|
+
showListening(true, said.listening);
|
|
610
|
+
}
|
|
611
|
+
/** Stops listening now, keeping the box as it is (the person sends, types or closes). */
|
|
612
|
+
function dropListening() {
|
|
613
|
+
starting = stopping = false;
|
|
614
|
+
const own = dictation;
|
|
615
|
+
if (!own)
|
|
616
|
+
return;
|
|
617
|
+
dictation = null;
|
|
618
|
+
own.abort();
|
|
619
|
+
showListening(false);
|
|
620
|
+
}
|
|
621
|
+
mic?.addEventListener("click", () => {
|
|
622
|
+
if (!dictation)
|
|
623
|
+
return void startListening();
|
|
624
|
+
stopping = true;
|
|
625
|
+
dictation.stop();
|
|
626
|
+
});
|
|
627
|
+
// The person typing takes the box back.
|
|
628
|
+
input.addEventListener("input", () => dropListening());
|
|
629
|
+
form.addEventListener("submit", (event) => {
|
|
630
|
+
event.preventDefault();
|
|
631
|
+
const question = input.value.trim();
|
|
632
|
+
if (!question || busy)
|
|
633
|
+
return;
|
|
634
|
+
dropListening();
|
|
635
|
+
input.value = "";
|
|
636
|
+
if (mode === "comment") {
|
|
637
|
+
// A comment that was not saved stays in the box, to try again.
|
|
638
|
+
void comment(question).then((saved) => {
|
|
639
|
+
if (!saved && !input.value)
|
|
640
|
+
input.value = question;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
void ask(question);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
input.addEventListener("keydown", (event) => {
|
|
648
|
+
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
|
649
|
+
event.preventDefault();
|
|
650
|
+
form.requestSubmit();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
x.addEventListener("click", () => panel.close());
|
|
654
|
+
element.addEventListener("keydown", (event) => {
|
|
655
|
+
if (event.key !== "Escape" || treeBox.firstChild)
|
|
656
|
+
return;
|
|
657
|
+
// Escape stops listening first, and closes the chat on the next press (or while Speak loads).
|
|
658
|
+
if (dictation)
|
|
659
|
+
dropListening();
|
|
660
|
+
else
|
|
661
|
+
panel.close();
|
|
662
|
+
});
|
|
663
|
+
renderChip();
|
|
664
|
+
setMode(mode);
|
|
665
|
+
input.focus();
|
|
666
|
+
const panel = {
|
|
667
|
+
element,
|
|
668
|
+
setContext(next, nextDropped) {
|
|
669
|
+
unusable = next === null;
|
|
670
|
+
if (unusable)
|
|
671
|
+
status.textContent = labels.badContext;
|
|
672
|
+
else if (status.textContent === labels.badContext)
|
|
673
|
+
status.textContent = "";
|
|
674
|
+
if (next === null)
|
|
675
|
+
return;
|
|
676
|
+
snapshot = next;
|
|
677
|
+
dropped = nextDropped;
|
|
678
|
+
// Rules follow the screen: content the app now withholds leaves what later turns resend.
|
|
679
|
+
for (const item of next.items) {
|
|
680
|
+
if (item.content?.some((part) => part.use === "label-only"))
|
|
681
|
+
loaded.delete(item.id);
|
|
682
|
+
}
|
|
683
|
+
if (turns.length === 0 && !busy)
|
|
684
|
+
focus = [...next.focus];
|
|
685
|
+
renderChip();
|
|
686
|
+
renderOffer();
|
|
687
|
+
},
|
|
688
|
+
ask(next, nextMode) {
|
|
689
|
+
if (turns.length === 0)
|
|
690
|
+
setFocus([...(next ?? snapshot.focus)]);
|
|
691
|
+
else if (next)
|
|
692
|
+
renderOffer();
|
|
693
|
+
if (nextMode && (nextMode === "ask" ? canAsk : canComment))
|
|
694
|
+
setMode(nextMode);
|
|
695
|
+
element.hidden = false;
|
|
696
|
+
input.focus();
|
|
697
|
+
},
|
|
698
|
+
close() {
|
|
699
|
+
if (closed)
|
|
700
|
+
return;
|
|
701
|
+
closed = true;
|
|
702
|
+
dropListening();
|
|
703
|
+
busy?.abort();
|
|
704
|
+
picking?.close();
|
|
705
|
+
element.remove();
|
|
706
|
+
options.onClose?.();
|
|
707
|
+
if (returnFocus instanceof HTMLElement && returnFocus.isConnected)
|
|
708
|
+
returnFocus.focus();
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
return panel;
|
|
712
|
+
}
|
|
713
|
+
/** The browser's speech recognizer, or `null` where it has none (Firefox today). */
|
|
714
|
+
function speechRecognizer(doc) {
|
|
715
|
+
const win = doc.defaultView;
|
|
716
|
+
return win?.SpeechRecognition ?? win?.webkitSpeechRecognition ?? null;
|
|
717
|
+
}
|
|
718
|
+
const MATH = /\$\$([^$]+)\$\$|\$([^$\n]+)\$|\\\((.+?)\\\)|\\\[(.+?)\\\]/gs;
|
|
719
|
+
/** Marks a math span's place while Markdown is rendered: invisible separators around its index. */
|
|
720
|
+
const SLOT = /\u2063M(\d+)\u2063/g;
|
|
721
|
+
/** Takes `$…$`, `$$…$$`, `\(…\)` and `\[…\]` out of a reply before Markdown can alter them. */
|
|
722
|
+
export function protectMath(text) {
|
|
723
|
+
const math = [];
|
|
724
|
+
const source = text.replace(MATH, (raw, block, inline, paren, bracket) => {
|
|
725
|
+
const tex = (block ?? inline ?? paren ?? bracket ?? "");
|
|
726
|
+
math.push({ tex, display: block !== undefined || bracket !== undefined, raw });
|
|
727
|
+
return `\u2063M${math.length - 1}\u2063`;
|
|
728
|
+
});
|
|
729
|
+
return { source, math };
|
|
730
|
+
}
|
|
731
|
+
/** Puts each math span back, drawn by the app's renderer (or as written, if it throws). */
|
|
732
|
+
function restoreMath(root, math, render) {
|
|
733
|
+
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
734
|
+
const texts = [];
|
|
735
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
|
736
|
+
texts.push(node);
|
|
737
|
+
for (const text of texts) {
|
|
738
|
+
const value = text.data;
|
|
739
|
+
if (!value.includes("\u2063"))
|
|
740
|
+
continue;
|
|
741
|
+
const parts = [];
|
|
742
|
+
let last = 0;
|
|
743
|
+
for (const match of value.matchAll(SLOT)) {
|
|
744
|
+
parts.push(value.slice(last, match.index));
|
|
745
|
+
const span = math[Number(match[1])];
|
|
746
|
+
if (span) {
|
|
747
|
+
try {
|
|
748
|
+
parts.push(render(span.tex, span.display));
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
parts.push(span.raw);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
last = match.index + match[0].length;
|
|
755
|
+
}
|
|
756
|
+
parts.push(value.slice(last));
|
|
757
|
+
text.replaceWith(...parts);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=chat-panel.js.map
|