dsh-model-everything 0.5.1 → 0.6.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.
@@ -0,0 +1,349 @@
1
+ /**
2
+ * What an annotation message looks like after it has been sent.
3
+ *
4
+ * The message is one block of plain lines: a fixed opening, numbered items, a question line, then the reader's
5
+ * own words. That is the right thing for the model and the wrong thing to read as a human - the reader wrote one
6
+ * note about one node, and what landed is a page of protocol.
7
+ *
8
+ * The host renders a user message as text inside elements it owns and re-renders. So this never assigns an
9
+ * element's text: that deletes its children, React reconciles from a tree that no longer matches, and whole
10
+ * turns come back mangled. It edits the text NODES instead - trim the run before the cut, remove the runs that
11
+ * held the block - and puts the chip in as a sibling, exactly the way the shipped annotation plugin does it.
12
+ *
13
+ * Everything here is defensive: a page without the elements, an element without text nodes, an element already
14
+ * handled. A decorator that throws would take a render pass with it.
15
+ */
16
+
17
+ /** The first words of every annotation block, used to recognise one in the transcript. */
18
+ export const TRANSCRIPT_MARKER = 'Diagram annotations -'
19
+
20
+ /** Where the block hands over to the reader's own question. */
21
+ const QUESTION = 'Question:'
22
+
23
+ /** Marks an element the decorator has handled, and remembers the block it came from. */
24
+ const HANDLED = 'data-dme-annotated'
25
+ const ORIGINAL = 'data-dme-annotation-text'
26
+
27
+ /** Elements whose text can be the message. Nested containers are excluded by the descendant test below. */
28
+ const SELECTOR = 'div, p, span, li'
29
+
30
+ /**
31
+ * Another plugin's territory, which this one must never touch.
32
+ *
33
+ * The shipped annotation plugin marks its own nodes `data-annotation-*` and folds its own blocks by their own
34
+ * markers. Two plugins doing DOM work in the same transcript collide in exactly three ways: they fight over the
35
+ * same message element, they overwrite each other's chip, and they both react to the other's mutations. The
36
+ * rules this file follows, and that a future change here has to keep:
37
+ *
38
+ * 1. only our own marker starts work - `Diagram annotations -`, never a generic word like "annotation";
39
+ * 2. every attribute we add is `data-dme-*`, and anything already carrying `data-annotation-*` is skipped;
40
+ * 3. our inserted nodes carry `data-dme-own`, so a neighbouring plugin can skip them as easily as we skip its;
41
+ * 4. one pass over a message, marked, so a second plugin's re-render cannot make us fold the same block twice.
42
+ *
43
+ * The draft is the fourth surface: both plugins write it through the host's `setDraft`, and a blind overwrite
44
+ * would drop the other's block. `placeBlock` re-reads the draft immediately before writing and prepends,
45
+ * keeping the rest verbatim - never a replace.
46
+ */
47
+ const FOREIGN = '[data-annotation-for-dsh], [data-annotation-overlay], [data-annotation-chip], [data-annotation-tip-layer]'
48
+
49
+ const BORDER = '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.35))'
50
+
51
+ export interface TranscriptNode {
52
+ nodeType?: number
53
+ nodeValue?: string | null
54
+ textContent?: string | null
55
+ style?: Record<string, string | undefined>
56
+ setAttribute?: (name: string, value: string) => void
57
+ getAttribute?: (name: string) => string | null
58
+ addEventListener?: (type: string, handler: () => void) => void
59
+ querySelectorAll?: (selector: string) => ArrayLike<TranscriptNode>
60
+ getBoundingClientRect?: () => { left: number; top: number; width: number; height: number }
61
+ closest?: (selector: string) => TranscriptNode | null
62
+ childNodes?: ArrayLike<TranscriptNode>
63
+ nextSibling?: TranscriptNode | null
64
+ appendChild?: (node: TranscriptNode) => unknown
65
+ removeChild?: (node: TranscriptNode) => unknown
66
+ parentNode?: {
67
+ insertBefore?: (node: TranscriptNode, before: TranscriptNode | null) => unknown
68
+ removeChild?: (node: TranscriptNode) => unknown
69
+ } | null
70
+ }
71
+
72
+ export interface TranscriptDocument {
73
+ querySelectorAll?: (selector: string) => ArrayLike<TranscriptNode>
74
+ createElement?: (tag: string) => TranscriptNode
75
+ }
76
+
77
+ /** One note as the block states it. */
78
+ export interface AnnotationItem {
79
+ index: number
80
+ node: string
81
+ note: string
82
+ }
83
+
84
+ /** The count in the opening line, for the chip; null when the line says something unexpected. */
85
+ export function annotationCountOf(text: string): number | null {
86
+ const found = /Diagram annotations - (\d+)/.exec(text)
87
+ return found === null ? null : Number(found[1])
88
+ }
89
+
90
+ /** The reader's own words: what follows the question line, or nothing when the note was the whole message. */
91
+ export function questionOf(text: string): string {
92
+ const at = text.lastIndexOf(QUESTION)
93
+ return at === -1 ? '' : text.slice(at + QUESTION.length).trim()
94
+ }
95
+
96
+ /** The notes the block carries, read back out of its lines, so a reader sees what the model saw. */
97
+ export function itemsOf(text: string): AnnotationItem[] {
98
+ const items: AnnotationItem[] = []
99
+ for (const raw of text.split(String.fromCharCode(10))) {
100
+ const line = raw.trim()
101
+ const head = /^Diagram note \[(\d+)\] node (.+)$/.exec(line)
102
+ if (head !== null) {
103
+ const rest = head[2]
104
+ const dash = rest.lastIndexOf(' - ')
105
+ items.push({ index: Number(head[1]), node: dash === -1 ? rest : rest.slice(0, dash), note: dash === -1 ? '' : rest.slice(dash + 3) })
106
+ continue
107
+ }
108
+ const note = /^Note: (.*)$/.exec(line)
109
+ if (note !== null && items.length > 0) items[items.length - 1].note = note[1]
110
+ }
111
+ return items
112
+ }
113
+
114
+ /** Every text node inside an element, in document order. */
115
+ function textNodesOf(root: TranscriptNode): TranscriptNode[] {
116
+ const out: TranscriptNode[] = []
117
+ const visit = (node: TranscriptNode): void => {
118
+ const children = node.childNodes
119
+ if (children === undefined || children === null || children.length === 0) {
120
+ if (typeof node.nodeValue === 'string') out.push(node)
121
+ return
122
+ }
123
+ for (let index = 0; index < children.length; index += 1) visit(children[index])
124
+ }
125
+ visit(root)
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * Drop everything before the cut from the text nodes, and leave the reader's own words.
131
+ *
132
+ * The nodes are the only thing written: an element's `textContent` would take its child elements with it.
133
+ */
134
+ export function cutTextNodes(nodes: TranscriptNode[], cut: number): boolean {
135
+ let position = 0
136
+ let done = false
137
+ for (const node of nodes) {
138
+ const value = typeof node.nodeValue === 'string' ? node.nodeValue : ''
139
+ const length = value.length
140
+ if (done) {
141
+ position += length
142
+ continue
143
+ }
144
+ if (cut <= position + length) {
145
+ node.nodeValue = value.slice(cut - position).replace(/^\s+/, '')
146
+ done = true
147
+ } else {
148
+ node.nodeValue = ''
149
+ }
150
+ position += length
151
+ }
152
+ return done
153
+ }
154
+
155
+ /** Whether another plugin owns this node, or any of its ancestors. */
156
+ /**
157
+ * Whether this element IS another plugin's node.
158
+ *
159
+ * Only the element itself counts. An ancestor test looked safer and was wrong: that plugin marks the message
160
+ * container it has folded, so every message it touched also carried an ancestor marker - and this plugin then
161
+ * skipped the very message it was looking for.
162
+ */
163
+ function isForeign(node: TranscriptNode): boolean {
164
+ for (const name of ['data-annotation-for-dsh', 'data-annotation-overlay', 'data-annotation-chip', 'data-annotation-tip-layer']) {
165
+ const value = node.getAttribute?.(name)
166
+ if (value !== undefined && value !== null) return true
167
+ }
168
+ return false
169
+ }
170
+
171
+ /** Kept for the tests that assert the selector exists; the ancestor walk is deliberately not used. */
172
+ export const FOREIGN_SELECTOR = FOREIGN
173
+
174
+ function holdsAnotherBlock(node: TranscriptNode): boolean {
175
+ const inner = node.querySelectorAll?.(SELECTOR)
176
+ if (inner === undefined || inner === null) return false
177
+ for (let index = 0; index < inner.length; index += 1) {
178
+ const text = inner[index].textContent
179
+ if (typeof text === 'string' && text.indexOf(TRANSCRIPT_MARKER) !== -1) return true
180
+ }
181
+ return false
182
+ }
183
+
184
+ /** The card listing the notes, styled from the host's tokens so it reads as part of the page. */
185
+ function cardFor(doc: TranscriptDocument, items: AnnotationItem[], title: string, full: string): TranscriptNode | undefined {
186
+ const card = doc.createElement?.('div')
187
+ if (card === undefined) return undefined
188
+ styleOf(card, {
189
+ position: 'fixed', zIndex: '40', display: 'none', maxWidth: '360px', padding: '8px 10px', borderRadius: '10px',
190
+ background: 'var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1, Canvas))',
191
+ border: BORDER, boxShadow: '0 10px 30px rgba(0,0,0,0.28)',
192
+ color: 'var(--dsw-alias-label-primary, CanvasText)', font: '12px system-ui, sans-serif',
193
+ })
194
+ const head = doc.createElement?.('div')
195
+ if (head !== undefined) {
196
+ head.textContent = title
197
+ styleOf(head, {
198
+ color: 'var(--dsw-alias-label-secondary, GrayText)', paddingBottom: '6px', marginBottom: '4px',
199
+ borderBottom: BORDER, fontSize: '11px',
200
+ })
201
+ card.appendChild?.(head)
202
+ }
203
+ for (const item of items) {
204
+ const row = doc.createElement?.('div')
205
+ if (row === undefined) continue
206
+ styleOf(row, { display: 'flex', gap: '6px', alignItems: 'flex-start', padding: '3px 0' })
207
+ const badge = doc.createElement?.('span')
208
+ if (badge !== undefined) {
209
+ badge.textContent = String(item.index)
210
+ styleOf(badge, {
211
+ flex: '0 0 auto', width: '16px', height: '16px', lineHeight: '16px', textAlign: 'center',
212
+ borderRadius: '8px', fontSize: '10px',
213
+ background: 'var(--dsw-alias-brand-primary, #2563eb)', color: '#fff',
214
+ })
215
+ row.appendChild?.(badge)
216
+ }
217
+ const body = doc.createElement?.('div')
218
+ if (body !== undefined) {
219
+ body.textContent = item.node + (item.note === '' ? '' : String.fromCharCode(10) + item.note)
220
+ styleOf(body, { flex: '1 1 auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' })
221
+ row.appendChild?.(body)
222
+ }
223
+ card.appendChild?.(row)
224
+ }
225
+ const more = doc.createElement?.('div')
226
+ if (more !== undefined) {
227
+ more.textContent = full
228
+ styleOf(more, {
229
+ display: 'none', marginTop: '6px', paddingTop: '6px', borderTop: BORDER, fontSize: '11px',
230
+ whiteSpace: 'pre-wrap', color: 'var(--dsw-alias-label-secondary, GrayText)', maxHeight: '40vh', overflow: 'auto',
231
+ })
232
+ card.appendChild?.(more)
233
+ }
234
+ return card
235
+ }
236
+
237
+ /**
238
+ * Apply a style map to a real element.
239
+ *
240
+ * Assigning an object to element.style applies nothing on a real element - the browser keeps its own
241
+ * CSSStyleDeclaration - which is why a chip built that way rendered as plain words. Each property goes through
242
+ * setProperty; a fake node without one keeps the map so a test can read it back.
243
+ */
244
+ function styleOf(node: TranscriptNode, map: Record<string, string | undefined>): void {
245
+ const decl = node.style as unknown as { setProperty?: (name: string, value: string) => void } | undefined
246
+ if (typeof decl?.setProperty === 'function') {
247
+ const target = decl as unknown as { setProperty: (name: string, value: string) => void }
248
+ for (const key of Object.keys(map)) {
249
+ const value = map[key]
250
+ if (value === undefined) continue
251
+ target.setProperty(key.replace(/[A-Z]/g, (letter) => '-' + letter.toLowerCase()), value)
252
+ }
253
+ return
254
+ }
255
+ node.style = map
256
+ }
257
+
258
+ function chipFor(doc: TranscriptDocument, label: string, title: string, text: string): TranscriptNode | undefined {
259
+ const chip = doc.createElement?.('div')
260
+ if (chip === undefined) return undefined
261
+ chip.textContent = label
262
+ chip.setAttribute?.('role', 'button')
263
+ // A pill that sits in the reader's line, not a block that pushes it: the conversation is theirs, and the chip
264
+ // is a footnote to it.
265
+ styleOf(chip, {
266
+ display: 'inline-flex', alignItems: 'center', height: '20px', padding: '0 8px', margin: '4px 0 0',
267
+ fontSize: '11px', lineHeight: '20px', borderRadius: '10px', verticalAlign: 'middle', userSelect: 'none',
268
+ border: BORDER, background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))',
269
+ color: 'var(--dsw-alias-label-secondary, GrayText)', cursor: 'pointer', maxWidth: '100%',
270
+ })
271
+ const card = cardFor(doc, itemsOf(text), title, text)
272
+ if (card === undefined) return chip
273
+ const place = (): void => {
274
+ const box = chip.getBoundingClientRect?.()
275
+ styleOf(card, box === undefined
276
+ ? { display: 'block' }
277
+ : { display: 'block', left: String(Math.max(8, box.left)) + 'px', top: String(box.top + box.height + 6) + 'px' })
278
+ }
279
+ const hide = (): void => { styleOf(card, { display: 'none' }) }
280
+ chip.addEventListener?.('mouseenter', place)
281
+ chip.addEventListener?.('mouseleave', hide)
282
+ chip.addEventListener?.('click', () => {
283
+ if ((card.style ?? {}).display === 'block') { hide() } else { place() }
284
+ })
285
+ // The card travels with the chip, so a bubble that re-renders cannot leave one behind.
286
+ chip.appendChild?.(card)
287
+ return chip
288
+ }
289
+
290
+ /**
291
+ * Fold every annotation message in the transcript behind its count, and answer how many were folded.
292
+ *
293
+ * Idempotent: an element that already carries the marker is skipped, so the observer can call this on every
294
+ * change without stacking chips. Only the element whose text is the message is touched, and only its text nodes.
295
+ */
296
+ export function decorateTranscript(doc: TranscriptDocument | undefined, label = 'Notes', title = 'Notes in this message'): number {
297
+ if (doc === undefined || typeof doc.querySelectorAll !== 'function') return 0
298
+ let nodes: ArrayLike<TranscriptNode>
299
+ try {
300
+ nodes = doc.querySelectorAll(SELECTOR)
301
+ } catch {
302
+ return 0
303
+ }
304
+ let folded = 0
305
+ for (let index = 0; index < nodes.length; index += 1) {
306
+ const element = nodes[index]
307
+ const text = typeof element.textContent === 'string' ? element.textContent : ''
308
+ if (!text.includes(TRANSCRIPT_MARKER)) continue
309
+ if (element.getAttribute?.(HANDLED) === '1') continue
310
+ // Another plugin's node: it may be folding its own block in the same message, and two folders would take
311
+ // turns rewriting each other's text.
312
+ if (isForeign(element)) continue
313
+ if (holdsAnotherBlock(element)) continue
314
+ const texts = textNodesOf(element)
315
+ if (texts.length === 0) continue
316
+ const joined = texts.map((node) => (typeof node.nodeValue === 'string' ? node.nodeValue : '')).join('')
317
+ if (!joined.includes(TRANSCRIPT_MARKER)) continue
318
+ const at = joined.lastIndexOf(QUESTION)
319
+ const cut = at === -1 ? joined.length : at + QUESTION.length
320
+ const count = annotationCountOf(joined)
321
+ const chip = chipFor(doc, label + (count === null ? '' : ' ×' + String(count)), title, joined)
322
+ if (chip === undefined) continue
323
+ try {
324
+ if (!cutTextNodes(texts, cut)) continue
325
+ element.setAttribute?.(ORIGINAL, joined)
326
+ element.setAttribute?.(HANDLED, '1')
327
+ const parent = element.parentNode
328
+ if (parent?.insertBefore !== undefined) parent.insertBefore(chip, element.nextSibling ?? null)
329
+ else element.appendChild?.(chip)
330
+ folded += 1
331
+ } catch {
332
+ // A page that refuses the write keeps the plain message, which reads on its own.
333
+ }
334
+ }
335
+ return folded
336
+ }
337
+
338
+ /**
339
+ * One pass, and nothing that keeps running.
340
+ *
341
+ * There is deliberately no observer and no timer here. Both were tried: an observer over the document plus a
342
+ * one-second interval turns every DOM change into a full selector scan, and a streaming conversation changes the
343
+ * DOM many times a second - the container stopped responding. A caller that wants the fold has to decide when
344
+ * to run it, on a container it chose.
345
+ */
346
+ export function startTranscriptDecoration(doc: TranscriptDocument | undefined, label = 'Notes', title = 'Notes in this message'): () => void {
347
+ decorateTranscript(doc, label, title)
348
+ return () => {}
349
+ }