dsh-model-everything 0.5.2 → 0.6.1
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/lib/annotation.js +323 -0
- package/lib/client.js +1614 -44
- package/lib/diagram-render-tool.js +13 -1
- package/lib/diagram-review-tool.js +1 -1
- package/lib/diagram.js +33 -0
- package/lib/gate.js +23 -19
- package/lib/render/paint.js +3 -0
- package/lib/render/svg.js +8 -2
- package/lib/routing.js +12 -0
- package/lib/transcript.js +327 -0
- package/lib/types/annotation.d.ts +128 -0
- package/lib/types/client.d.ts +89 -6
- package/lib/types/diagram.d.ts +7 -0
- package/lib/types/gate.d.ts +8 -6
- package/lib/types/render/paint.d.ts +2 -0
- package/lib/types/transcript.d.ts +82 -0
- package/package.json +149 -148
- package/skills/model-everything/SKILL.md +3 -2
- package/skills/model-everything-diagram/SKILL.md +21 -1
- package/src/annotation.ts +333 -0
- package/src/client.ts +1841 -307
- package/src/diagram-render-tool.ts +13 -1
- package/src/diagram-review-tool.ts +1 -1
- package/src/diagram.ts +31 -0
- package/src/gate.ts +23 -19
- package/src/render/paint.ts +5 -0
- package/src/render/svg.ts +8 -2
- package/src/routing.ts +12 -0
- package/src/transcript.ts +349 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An annotation: the reader points at a node in a drawing and says something about it.
|
|
3
|
+
*
|
|
4
|
+
* The note has to reach the session as an ordinary message, because the session log is the only record a
|
|
5
|
+
* replay, a fork or a resumed agent can rebuild from. Two transports can carry it, and they consume the
|
|
6
|
+
* same text: a host command that turns the note into a follow-up (planned; the interface is
|
|
7
|
+
* `annotationMessage`), and the composer, which hands the text to the reader's own input box (works today).
|
|
8
|
+
*
|
|
9
|
+
* Splitting the text from the transport is what keeps the two interchangeable, and what lets this module be
|
|
10
|
+
* tested without a browser.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The text that travels to the session: what was marked, and what was said, and nothing invented.
|
|
14
|
+
*
|
|
15
|
+
* A blank note is not dropped - it says the node was marked - because "the reader pointed here" and "the
|
|
16
|
+
* reader pointed here and said this" are different messages, and only one of them survives a silent trim.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The text that travels to the session: what was marked, what was said, and nothing invented.
|
|
20
|
+
*
|
|
21
|
+
* One message for N notes, with a fixed opening so both the reader and the model know what they are looking
|
|
22
|
+
* at: a note arrives without the picture, so the block says what it is about, which drawing, and that every
|
|
23
|
+
* numbered item is a separate answer. The shape follows the annotation plugin this harness already ships -
|
|
24
|
+
* an opening sentence, numbered items, a question line - and it stays English: this is protocol between
|
|
25
|
+
* reader and model, not interface chrome that follows the page's language.
|
|
26
|
+
*
|
|
27
|
+
* Plain lines, not a markdown quote: the reader's bubble is rendered as one text node, so a quote never
|
|
28
|
+
* becomes a quote there - it would only show its own `>` characters. The bubble is folded afterwards.
|
|
29
|
+
* A blank note is not dropped: 'marked, no note' and 'marked with this note' are different messages.
|
|
30
|
+
*/
|
|
31
|
+
export function annotationBlock(annotations) {
|
|
32
|
+
const count = annotations.length;
|
|
33
|
+
const lines = [
|
|
34
|
+
'Diagram annotations - ' + String(count) + (count === 1 ? ' item' : ' items') + ' (numbers match the picture).',
|
|
35
|
+
'Please answer each item separately, and start each answer with "Annotation N:".',
|
|
36
|
+
];
|
|
37
|
+
const caption = (annotations[0]?.caption ?? '').trim();
|
|
38
|
+
if (caption !== '')
|
|
39
|
+
lines.push('Drawing: ' + caption);
|
|
40
|
+
annotations.forEach((annotation, index) => {
|
|
41
|
+
const label = (annotation.label ?? '').trim();
|
|
42
|
+
const node = label === '' || label === annotation.nodeId ? annotation.nodeId : label + ' (' + annotation.nodeId + ')';
|
|
43
|
+
const note = (annotation.note ?? '').trim();
|
|
44
|
+
lines.push('');
|
|
45
|
+
// One bracketed line per item. A message can carry a diagram block and a quote block at once, and the
|
|
46
|
+
// shipped annotation plugin folds its own blocks by their numbered lines; brackets are not its grammar, so
|
|
47
|
+
// each plugin folds only its own. The word says what the note is about, and the number is still the one the
|
|
48
|
+
// model answers with.
|
|
49
|
+
lines.push('Diagram note [' + String(index + 1) + '] node ' + node + ' - ' + (note === '' ? '(marked, no note)' : note));
|
|
50
|
+
});
|
|
51
|
+
lines.push('', 'Question:');
|
|
52
|
+
return lines.join(String.fromCharCode(10));
|
|
53
|
+
}
|
|
54
|
+
/** One annotation, in the same block shape: a caller with a single note should not get a second format. */
|
|
55
|
+
export function annotationMessage(annotation) {
|
|
56
|
+
return annotationBlock([annotation]);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Where the reader's next message is typed.
|
|
60
|
+
*
|
|
61
|
+
* The host marks its composer with `data-focus-flow`; the board's own source box does not carry it, which is
|
|
62
|
+
* what keeps an annotation from being typed into the diagram it describes.
|
|
63
|
+
*/
|
|
64
|
+
/** The marks a page may put on the box a reader types into, most specific first. */
|
|
65
|
+
const COMPOSER_MARKS = [
|
|
66
|
+
'[data-focus-flow] textarea',
|
|
67
|
+
'textarea[data-focus-flow]',
|
|
68
|
+
'[data-focus-flow] [contenteditable="true"]',
|
|
69
|
+
'[data-focus-flow][contenteditable="true"]',
|
|
70
|
+
// No marker on this host - measured, not assumed. Everything this plugin writes carries
|
|
71
|
+
// `data-dme-own`, so what remains is somebody else's writing surface.
|
|
72
|
+
'textarea:not([data-dme-own])',
|
|
73
|
+
'[contenteditable="true"]:not([data-dme-own])',
|
|
74
|
+
];
|
|
75
|
+
export function findComposer(doc) {
|
|
76
|
+
if (doc === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
try {
|
|
79
|
+
for (const mark of COMPOSER_MARKS) {
|
|
80
|
+
const found = doc.querySelector(mark);
|
|
81
|
+
if (found !== null && typeof found === 'object')
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
// The mark may sit on the composer's wrapper rather than on the writing surface itself.
|
|
85
|
+
const marked = doc.querySelector('[data-focus-flow]');
|
|
86
|
+
if (marked !== null && typeof marked === 'object') {
|
|
87
|
+
const inner = marked.querySelector?.('textarea, [contenteditable="true"]');
|
|
88
|
+
if (inner !== null && inner !== undefined && typeof inner === 'object')
|
|
89
|
+
return inner;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* What the page has, for a status line: a probe with no console needs one place to report from.
|
|
99
|
+
*
|
|
100
|
+
* Counts only. A reader's draft is never read into the report.
|
|
101
|
+
*/
|
|
102
|
+
export function pageCensus(doc) {
|
|
103
|
+
if (doc === undefined)
|
|
104
|
+
return 'no document';
|
|
105
|
+
try {
|
|
106
|
+
const textareas = doc.querySelectorAll === undefined ? -1 : doc.querySelectorAll('textarea').length;
|
|
107
|
+
const editables = doc.querySelectorAll === undefined ? -1 : doc.querySelectorAll('[contenteditable="true"]').length;
|
|
108
|
+
const focus = doc.querySelector('[data-focus-flow]') !== null ? 'yes' : 'no';
|
|
109
|
+
return 'textarea=' + String(textareas) + ' editable=' + String(editables) + ' focus-flow=' + focus;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return 'census failed';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The event constructor, or undefined where the page has none (a test, or a document that is not a browser). */
|
|
116
|
+
function eventOf(type, init) {
|
|
117
|
+
const scope = globalThis;
|
|
118
|
+
if (scope.Event === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
try {
|
|
121
|
+
return new scope.Event(type, init);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** React tracks the last value it wrote; assigning through the prototype setter is what makes it see a change. */
|
|
128
|
+
function setValue(element, next) {
|
|
129
|
+
const scope = globalThis;
|
|
130
|
+
for (const ctor of [scope.HTMLTextAreaElement, scope.HTMLInputElement]) {
|
|
131
|
+
const descriptor = ctor === undefined ? undefined : Object.getOwnPropertyDescriptor(ctor.prototype, 'value');
|
|
132
|
+
if (descriptor !== undefined && typeof descriptor.set === 'function') {
|
|
133
|
+
descriptor.set.call(element, next);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
element.value = next;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Add the text to the composer, above whatever the reader has already typed.
|
|
141
|
+
*
|
|
142
|
+
* Appending rather than replacing is the only behavior that cannot lose work: a reader who was halfway
|
|
143
|
+
* through a message and clicked an annotation should find both halves in the box, not the annotation alone.
|
|
144
|
+
*/
|
|
145
|
+
export function fillComposer(doc, text) {
|
|
146
|
+
const composer = findComposer(doc);
|
|
147
|
+
if (composer === undefined)
|
|
148
|
+
return 'no-composer';
|
|
149
|
+
const isField = typeof composer.value === 'string' || composer.tagName === 'TEXTAREA' || composer.tagName === 'INPUT';
|
|
150
|
+
if (isField) {
|
|
151
|
+
const current = typeof composer.value === 'string' ? composer.value : '';
|
|
152
|
+
setValue(composer, current.trim() === '' ? text : current.replace(/\s*$/, '') + '\n\n' + text);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// A contenteditable keeps its own document: assigning `textContent` looks right until the next keystroke,
|
|
156
|
+
// which restores what the editor thought was there. Insert the way a paste does, and fall back to the
|
|
157
|
+
// assignment only where the page has no editing command at all.
|
|
158
|
+
if (composer.focus !== undefined)
|
|
159
|
+
composer.focus();
|
|
160
|
+
const editing = globalThis;
|
|
161
|
+
// One insertText per line, with a paragraph between them: a rich editor stores line breaks in its own
|
|
162
|
+
// model, so a single insertText carrying a newline lands as one run of characters - which is exactly
|
|
163
|
+
// what the first live test showed.
|
|
164
|
+
// HTML first: an editor that accepts `insertHTML` keeps the breaks as breaks, and the `>` that opens a
|
|
165
|
+
// markdown quote arrives as text because it is escaped. The measured failure before this was a block that
|
|
166
|
+
// reached the session as one long line with its quotes glued together.
|
|
167
|
+
const escapeHtml = (value) => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
168
|
+
const insertHtml = () => {
|
|
169
|
+
const command = editing.document?.execCommand;
|
|
170
|
+
if (command === undefined || editing.document === undefined)
|
|
171
|
+
return false;
|
|
172
|
+
const html = text.split(String.fromCharCode(10)).map(escapeHtml).join('<br>');
|
|
173
|
+
return command.call(editing.document, 'insertHTML', false, html) === true;
|
|
174
|
+
};
|
|
175
|
+
const insertLines = () => {
|
|
176
|
+
const command = editing.document?.execCommand;
|
|
177
|
+
if (command === undefined || editing.document === undefined)
|
|
178
|
+
return false;
|
|
179
|
+
const lines = text.split(String.fromCharCode(10));
|
|
180
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
181
|
+
if (index > 0 && command.call(editing.document, 'insertParagraph', false, '') !== true)
|
|
182
|
+
return false;
|
|
183
|
+
if (lines[index] !== '' && command.call(editing.document, 'insertText', false, lines[index]) !== true)
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
};
|
|
188
|
+
const inserted = insertHtml() || insertLines();
|
|
189
|
+
if (!inserted) {
|
|
190
|
+
const current = typeof composer.textContent === 'string' ? composer.textContent : '';
|
|
191
|
+
composer.textContent = current.trim() === '' ? text : current.replace(/\s*$/, '') + '\n\n' + text;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const input = eventOf('input', { bubbles: true });
|
|
195
|
+
if (input !== undefined && composer.dispatchEvent !== undefined)
|
|
196
|
+
composer.dispatchEvent(input);
|
|
197
|
+
if (composer.focus !== undefined)
|
|
198
|
+
composer.focus();
|
|
199
|
+
return 'filled';
|
|
200
|
+
}
|
|
201
|
+
/** Whether the page can take a Return key for the composer at all, so a button may promise it. */
|
|
202
|
+
export function submitComposer(doc) {
|
|
203
|
+
const composer = findComposer(doc);
|
|
204
|
+
if (composer === undefined || composer.dispatchEvent === undefined)
|
|
205
|
+
return false;
|
|
206
|
+
const scope = globalThis;
|
|
207
|
+
if (scope.KeyboardEvent === undefined)
|
|
208
|
+
return false;
|
|
209
|
+
try {
|
|
210
|
+
composer.dispatchEvent(new scope.KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }));
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* One note, two actions.
|
|
219
|
+
*
|
|
220
|
+
* Returns the sentence to show the reader: `sent` and `the box was not there` are different outcomes, and a
|
|
221
|
+
* button that reports the wrong one is worse than no button. The text is appended above a half-written draft,
|
|
222
|
+
* never in place of it.
|
|
223
|
+
*/
|
|
224
|
+
export function pushNote(document, picked, note, caption, submit) {
|
|
225
|
+
if (picked === null)
|
|
226
|
+
return '';
|
|
227
|
+
if (fillComposer(document, annotationMessage({ nodeId: picked.id, note, caption })) !== 'filled') {
|
|
228
|
+
return '没找到输入框:先把图贴进会话,或手动复制这段文字';
|
|
229
|
+
}
|
|
230
|
+
if (!submit)
|
|
231
|
+
return '已放入输入框';
|
|
232
|
+
return submitComposer(document) ? '已发送' : '已放入输入框:按一次回车发送';
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The session a page is showing, read from its own address.
|
|
236
|
+
*
|
|
237
|
+
* The client services expose no "current session", and a slot's props are not guaranteed to carry one, but the
|
|
238
|
+
* page has to name the session in its URL to survive a reload - so the address is the one source that is always
|
|
239
|
+
* there. Both spellings are accepted: the `session-<uuid>` id this Harness mints and a bare uuid.
|
|
240
|
+
*/
|
|
241
|
+
export function sessionFromLocation(location) {
|
|
242
|
+
if (location === undefined)
|
|
243
|
+
return undefined;
|
|
244
|
+
const address = [location.pathname ?? '', location.search ?? '', location.hash ?? ''].join(' ');
|
|
245
|
+
const named = /session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(address);
|
|
246
|
+
if (named !== null)
|
|
247
|
+
return named[0];
|
|
248
|
+
const bare = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(address);
|
|
249
|
+
if (bare !== null)
|
|
250
|
+
return bare[0];
|
|
251
|
+
const parameter = /[?&#](?:session|sessionId|id)=([A-Za-z0-9_-]+)/.exec(address);
|
|
252
|
+
return parameter === null ? undefined : parameter[1];
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Put a whole annotation block in the composer, and say what happened.
|
|
256
|
+
*
|
|
257
|
+
* The block is one message for N notes: the model is told to answer each item, and the reader's input box sees
|
|
258
|
+
* the structured text rather than N raw fragments. The same failure vocabulary as a single note.
|
|
259
|
+
*/
|
|
260
|
+
export function pushBlock(document, annotations, caption, submit) {
|
|
261
|
+
if (annotations.length === 0)
|
|
262
|
+
return '';
|
|
263
|
+
const block = annotationBlock(annotations.map((annotation) => ({ ...annotation, caption })));
|
|
264
|
+
if (fillComposer(document, block) !== 'filled')
|
|
265
|
+
return '没找到输入框:先把图贴进会话,或手动复制这段文字';
|
|
266
|
+
if (!submit)
|
|
267
|
+
return '已放入输入框(' + String(annotations.length) + ' 条图注)';
|
|
268
|
+
return submitComposer(document) ? '已发送(' + String(annotations.length) + ' 条图注)' : '已放入输入框:按一次回车发送';
|
|
269
|
+
}
|
|
270
|
+
/** Where the reader's input box is, for a widget that has to sit right above it. */
|
|
271
|
+
export function composerRect(doc) {
|
|
272
|
+
const composer = findComposer(doc);
|
|
273
|
+
// The mark may sit on a wrapper that also holds the toolbar row; measuring it would put the count on top of
|
|
274
|
+
// that row. The writing surface is the line the reader types on, and the count belongs just above it.
|
|
275
|
+
const inner = composer?.querySelector?.('textarea, [contenteditable="true"]');
|
|
276
|
+
const surface = inner !== null && inner !== undefined && typeof inner === 'object' ? inner : composer;
|
|
277
|
+
const measure = surface?.getBoundingClientRect;
|
|
278
|
+
if (surface === undefined || typeof measure !== 'function')
|
|
279
|
+
return undefined;
|
|
280
|
+
try {
|
|
281
|
+
const rect = measure.call(surface);
|
|
282
|
+
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Put the block above what the reader has already typed, and say what happened.
|
|
290
|
+
*
|
|
291
|
+
* Above, because the annotations introduce the message: the reader's own words are the point, and a block
|
|
292
|
+
* appended after them reads as an afterthought. A rich editor has no portable way to insert at the start, so
|
|
293
|
+
* it takes the same path as an empty box - the block becomes the message and the reader's text follows it.
|
|
294
|
+
*/
|
|
295
|
+
export function prependBlock(document, annotations, caption) {
|
|
296
|
+
if (annotations.length === 0)
|
|
297
|
+
return '';
|
|
298
|
+
const block = annotationBlock(annotations.map((annotation) => ({ ...annotation, caption })));
|
|
299
|
+
const composer = findComposer(document);
|
|
300
|
+
if (composer === undefined)
|
|
301
|
+
return '没找到输入框:先把图贴进会话,或手动复制这段文字';
|
|
302
|
+
const isField = typeof composer.value === 'string' || composer.tagName === 'TEXTAREA' || composer.tagName === 'INPUT';
|
|
303
|
+
if (isField) {
|
|
304
|
+
const current = typeof composer.value === 'string' ? composer.value : '';
|
|
305
|
+
setValue(composer, current.trim() === '' ? block : block + String.fromCharCode(10, 10) + current);
|
|
306
|
+
}
|
|
307
|
+
else if (fillComposer(document, block) === 'no-composer') {
|
|
308
|
+
return '没找到输入框:先把图贴进会话,或手动复制这段文字';
|
|
309
|
+
}
|
|
310
|
+
const input = eventOf('input', { bubbles: true });
|
|
311
|
+
if (input !== undefined && composer.dispatchEvent !== undefined)
|
|
312
|
+
composer.dispatchEvent(input);
|
|
313
|
+
return '已放入输入框(' + String(annotations.length) + ' 条图注)';
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The block without its question line, for a message that has no question.
|
|
317
|
+
*
|
|
318
|
+
* The line is an invitation for the reader's own words; with the box empty it is the last thing the bubble shows
|
|
319
|
+
* and the only thing a chip would be folded away from, so it goes.
|
|
320
|
+
*/
|
|
321
|
+
export function withoutEmptyQuestion(block) {
|
|
322
|
+
return block.replace(/\n+Question:\s*$/, '');
|
|
323
|
+
}
|