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
|
@@ -15,7 +15,7 @@ export const DIAGRAM_TOOL_NAME = 'diagram_render'
|
|
|
15
15
|
export const diagramRenderTool = defineTool({
|
|
16
16
|
name: DIAGRAM_TOOL_NAME,
|
|
17
17
|
description:
|
|
18
|
-
'Draw a diagram from its source text and get back SVG.
|
|
18
|
+
'Draw a diagram from its source text and get back SVG. Structure is read from a picture, so reach for this whenever the answer is a layout, a flow, a set of boundaries or a change between two of them - not only when the request says draw. Pass `out` to write the SVG and show it to the reader beside the explanation it carries, and read the model-everything-diagram skill for what this notation subset covers and what it refuses. It accepts Mermaid (flowchart, stateDiagram) and PlantUML (component, package, state), lays out boxes and arrows itself, and reports every line it refused with its line number instead of drawing half a diagram. This tool draws; it does not parse a diagram back into a model, and it does not review one - uml_parse and uml_review do that.',
|
|
19
19
|
parameters: {
|
|
20
20
|
source: {
|
|
21
21
|
type: 'string',
|
|
@@ -62,6 +62,18 @@ export const diagramRenderTool = defineTool({
|
|
|
62
62
|
}
|
|
63
63
|
return [{ type: 'text' as const, text: lines.join('\n') }]
|
|
64
64
|
},
|
|
65
|
+
// The card a UI shows. The host keeps this on the settled call as `block.meta`, which is the one place a
|
|
66
|
+
// view can find the drawing's own source: by then the arguments are gone. Measured against how
|
|
67
|
+
// dsh-deepread's card reads its payload - the same field.
|
|
68
|
+
presentationMeta(args, value) {
|
|
69
|
+
const result = value as Record<string, unknown>
|
|
70
|
+
const asked = args as { source?: unknown; scheme?: unknown }
|
|
71
|
+
return {
|
|
72
|
+
source: typeof asked.source === 'string' ? asked.source : '',
|
|
73
|
+
scheme: typeof asked.scheme === 'string' ? asked.scheme : 'default',
|
|
74
|
+
caption: typeof result.caption === 'string' ? result.caption : '',
|
|
75
|
+
}
|
|
76
|
+
},
|
|
65
77
|
},
|
|
66
78
|
timeoutMs: 10_000,
|
|
67
79
|
isConcurrencySafe: () => true,
|
|
@@ -25,7 +25,7 @@ function limitsOf(args: { maxMembers?: unknown; maxDepth?: unknown; maxNodes?: u
|
|
|
25
25
|
export const diagramReviewTool = defineTool({
|
|
26
26
|
name: DIAGRAM_REVIEW_TOOL_NAME,
|
|
27
27
|
description:
|
|
28
|
-
'Review the structure of a diagram from its source text: how wide each boundary is, how deep they nest, and how many nodes the drawing holds, against budgets you can set. Accepts the same Mermaid and PlantUML subset as diagram_render and reports what it measured, so an empty report cannot be mistaken for a clean one. Read `verdict`, never `ok`. This reviews structure; it does not draw (diagram_render does) and it does not check behaviour (model_verify does).',
|
|
28
|
+
'Review the structure of a diagram from its source text: how wide each boundary is, how deep they nest, and how many nodes the drawing holds, against budgets you can set. Run it before a drawing goes in front of a reader: a picture that needs scrolling is no longer a summary. Accepts the same Mermaid and PlantUML subset as diagram_render and reports what it measured, so an empty report cannot be mistaken for a clean one. Read `verdict`, never `ok`. This reviews structure; it does not draw (diagram_render does) and it does not check behaviour (model_verify does).',
|
|
29
29
|
parameters: {
|
|
30
30
|
source: {
|
|
31
31
|
type: 'string',
|
package/src/diagram.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import z from '@deepseek-ai/schemastery'
|
|
14
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
14
15
|
import { diagramDiffTool } from './diagram-diff-tool.js'
|
|
15
16
|
import { diagramRenderTool } from './diagram-render-tool.js'
|
|
16
17
|
import { diagramReviewTool } from './diagram-review-tool.js'
|
|
@@ -56,6 +57,36 @@ export function apply(ctx: ComponentContext, config: Config): void {
|
|
|
56
57
|
ctx.tools.register(diagramRenderTool)
|
|
57
58
|
ctx.tools.register(diagramReviewTool)
|
|
58
59
|
ctx.tools.register(diagramDiffTool)
|
|
60
|
+
registerAnnotateCommand(ctx)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Offer an annotation as a session command, so a page can send one without a composer.
|
|
65
|
+
*
|
|
66
|
+
* Optional on purpose, and failure-tolerant on purpose: a host without the commands service keeps the
|
|
67
|
+
* composer path, and a registration that throws must not take the drawing tools down with it.
|
|
68
|
+
*/
|
|
69
|
+
export function registerAnnotateCommand(ctx: unknown): void {
|
|
70
|
+
const commands = (ctx as { get?: (name: string) => unknown }).get?.('commands') as { register?: (definition: unknown) => unknown } | undefined
|
|
71
|
+
if (commands === undefined || typeof commands.register !== 'function') return
|
|
72
|
+
try {
|
|
73
|
+
commands.register({
|
|
74
|
+
name: 'annotate',
|
|
75
|
+
description: 'Send a diagram annotation to this session',
|
|
76
|
+
handler: (invocation: { agent?: { followup?: (input: unknown) => unknown }; rawInput?: string; commandId?: unknown; attachments?: unknown; signal?: unknown }) => {
|
|
77
|
+
const raw = typeof invocation.rawInput === 'string' ? invocation.rawInput.trim().replace(/^\/?annotate\s*/i, '') : ''
|
|
78
|
+
const agent = invocation.agent
|
|
79
|
+
const followup = agent?.followup
|
|
80
|
+
if (raw === '' || typeof followup !== 'function') return { kind: 'error' as const, text: 'this host cannot take a follow-up note' }
|
|
81
|
+
// A follow-up takes a UserMessage, not a bag of fields: the runtime stamps the id and the time, and a
|
|
82
|
+
// hand-built object reaches the inbox without them. `createUserMessage` is the host's own constructor.
|
|
83
|
+
followup.call(agent, createUserMessage({ content: [{ type: 'text', text: raw }], source: { kind: 'user' } }))
|
|
84
|
+
return { kind: 'success' as const, text: 'annotation sent' }
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
} catch {
|
|
88
|
+
// A host whose command API differs keeps the composer path; the tools above are already registered.
|
|
89
|
+
}
|
|
59
90
|
}
|
|
60
91
|
|
|
61
92
|
export const inject = ['tools']
|
package/src/gate.ts
CHANGED
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Why it is a separate row. The plugin's doctrine is that a capability is discoverable
|
|
12
12
|
* through its own tool description or skill text. Injection departs from that, so it is
|
|
13
|
-
*
|
|
14
|
-
* without touching the tools or the skills. `enabled` is a live switch, so the Web
|
|
15
|
-
* page flips it inside a running host.
|
|
13
|
+
* mounted as its own component: the bundle's patch enables it, and the row can be turned off
|
|
14
|
+
* or removed without touching the tools or the skills. `enabled` is a live switch, so the Web
|
|
15
|
+
* Plugins page flips it inside a running host.
|
|
16
16
|
*
|
|
17
|
-
* Why
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Why the bundle turns it on. Measured: a session that never decides to load a skill is never
|
|
18
|
+
* routed, and the catalog line is cut at 500 characters, so a domain the request names can be
|
|
19
|
+
* invisible at the moment of choosing. The cost is a fixed message in every session, which is
|
|
20
|
+
* why the switch is live and documented: turn it off where the bundle's default is wrong. The
|
|
21
|
+
* component's own `enabled` script default is off, so a bare `app.add(gate)` injects nothing.
|
|
20
22
|
*
|
|
21
23
|
* What it does not do. It routes; it checks nothing. It cannot make a skill load, and it
|
|
22
24
|
* does not restate the doctrine - the skill bodies carry that, and duplicating them here
|
|
@@ -109,9 +111,9 @@ export function renderGateText(skills: string[] = shippedSkills(), extraGuidance
|
|
|
109
111
|
const rows = skills.map((skill) => '| ' + (SKILL_ROWS[skill] ?? 'see its catalog line') + ' | `' + skill + '` |')
|
|
110
112
|
const extra = extraGuidance.trim()
|
|
111
113
|
return `<EXTREMELY_IMPORTANT>
|
|
112
|
-
Before answering anything about code, a design or a diagram: load the skill
|
|
114
|
+
Before answering anything about code, a design or a diagram: load the matching skill and let its tool decide. Do not answer from memory.
|
|
113
115
|
|
|
114
|
-
dsh-model-everything
|
|
116
|
+
dsh-model-everything checks claims about designs and code: facts against the source, behaviour as a model, structure as a graph.
|
|
115
117
|
|
|
116
118
|
**Unsure which row applies?** Load \`model-everything\` first: it owns the doctrine, the schema and the routing to its siblings.
|
|
117
119
|
|
|
@@ -121,17 +123,19 @@ ${rows.join('\n')}
|
|
|
121
123
|
|
|
122
124
|
**Tools** (dsh):
|
|
123
125
|
|
|
124
|
-
- \`model_verify\` —
|
|
125
|
-
- \`model_compose_verify\` —
|
|
126
|
-
- \`model_datamodel_verify\` — a data model and its migration: keys, domains, coverage, copy consistency, rollback
|
|
127
|
-
- \`model_design_review\` — a design against its own declarations: layering, boundaries, substitutability, interface width.
|
|
128
|
-
- \`model_interleaving_scan\` — finds claims
|
|
129
|
-
- \`model_export\` — UPPAAL, TLA+, PRISM or SPIN input from a model. It
|
|
130
|
-
- \`uml_render\`, \`uml_parse\`, \`uml_review\` — draw a model, read a diagram into one, audit one
|
|
131
|
-
- \`uml_explain_labels\` — the
|
|
132
|
-
- \`uml_structure_verify\` — dependency review of a structure drawing
|
|
133
|
-
- \`diagram_render\`, \`diagram_review\`, \`diagram_diff\` — draw, size
|
|
134
|
-
- \`
|
|
126
|
+
- \`model_verify\` — a state machine: structural checks, adversarial probes, budgets, deadlines, regression.
|
|
127
|
+
- \`model_compose_verify\` — several machines: deadlock, a handshake that never fires, an unordered write (C4), a non-atomic update (C5).
|
|
128
|
+
- \`model_datamodel_verify\` — a data model and its migration: keys, domains, coverage, copy consistency, rollback.
|
|
129
|
+
- \`model_design_review\` — a design against its own declarations: layering, boundaries, substitutability, interface width.
|
|
130
|
+
- \`model_interleaving_scan\` — finds concurrency claims, names the family, routes each to what decides it. Proves nothing itself.
|
|
131
|
+
- \`model_export\` — UPPAAL, TLA+, PRISM or SPIN input from a model. It does not check the target.
|
|
132
|
+
- \`uml_render\`, \`uml_parse\`, \`uml_review\` — draw a model, read a diagram into one, audit one; round-trip measured.
|
|
133
|
+
- \`uml_explain_labels\` — the label and comment forms a notation accepts.
|
|
134
|
+
- \`uml_structure_verify\` — dependency review of a structure drawing. It audits the drawing, not the code.
|
|
135
|
+
- \`diagram_render\`, \`diagram_review\`, \`diagram_diff\` — draw, size, compare a Mermaid or PlantUML source. Undrawable lines are refused by number.
|
|
136
|
+
- \`model_automaton_verify\` — an automaton or two: what it accepts, trap states, language relation, with a witness word.
|
|
137
|
+
- \`model_edit\` — one named change to a model file plus the finding delta. Refuses an edit that leaves the model invalid.
|
|
138
|
+
- \`report_diff\` — baseline against current: findings added, removed or changed.
|
|
135
139
|
|
|
136
140
|
**Read a report**: gate on \`verdict\`, never on \`ok\`. \`verdictReason\` says why and \`nextSteps\` says what to do. \`ran\` says whether it executed, \`checksRun\` how much it examined: a \`skip\` is not a \`pass\`, and a code ending \`_UNDECIDED\` means the search hit its limit and decided nothing. Keys starting with \`_\` are notes: ignored, and left out of the model hash.
|
|
137
141
|
|
package/src/render/paint.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface Point {
|
|
|
52
52
|
|
|
53
53
|
export interface BoxShape {
|
|
54
54
|
kind: 'box'
|
|
55
|
+
/** Which node this box is. It travels into the markup, so a reader can target one node by delegation. */
|
|
56
|
+
id: string
|
|
55
57
|
x: number
|
|
56
58
|
y: number
|
|
57
59
|
width: number
|
|
@@ -565,6 +567,7 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
|
|
|
565
567
|
const text = textOf(node)
|
|
566
568
|
shapes.push({
|
|
567
569
|
kind: 'box',
|
|
570
|
+
id: node.id,
|
|
568
571
|
x: box.x,
|
|
569
572
|
y: box.y,
|
|
570
573
|
width: box.width,
|
|
@@ -1550,6 +1553,8 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
|
|
|
1550
1553
|
...Array.from(groupRects.values(), (rect) => rect.y + rect.height),
|
|
1551
1554
|
)
|
|
1552
1555
|
const captionTop = contentBottom + CAPTION_GAP
|
|
1556
|
+
// Anchored at the left margin, where the title is. The writer starts the text there: centred on a left-edge x,
|
|
1557
|
+
// half of a long caption fell outside the drawing and was clipped at the bottom edge of the picture.
|
|
1553
1558
|
shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) })
|
|
1554
1559
|
const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2
|
|
1555
1560
|
|
package/src/render/svg.ts
CHANGED
|
@@ -143,7 +143,9 @@ const writers: { [K in Shape['kind']]: Writer<K> } = {
|
|
|
143
143
|
const lines = shape.lines
|
|
144
144
|
.map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
|
|
145
145
|
.join('')
|
|
146
|
-
|
|
146
|
+
// Start-anchored: the caption begins at the margin the title uses, so a long one grows right into the space
|
|
147
|
+
// the canvas has already reserved for it instead of hanging off the left edge.
|
|
148
|
+
return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" fill="' + colour + '">' + lines + '</text>'
|
|
147
149
|
},
|
|
148
150
|
}
|
|
149
151
|
|
|
@@ -258,7 +260,11 @@ export function toSvg(canvas: Canvas, colours: Palette, label?: string): { marku
|
|
|
258
260
|
dropped += 1
|
|
259
261
|
continue
|
|
260
262
|
}
|
|
261
|
-
|
|
263
|
+
const drawn = writer(shape, colours)
|
|
264
|
+
// A node carries its id in the markup, so a card or a board can target one node by delegation, the way a
|
|
265
|
+
// boundary already carries `data-group-id`, instead of working it out from the click's geometry.
|
|
266
|
+
const nodeId = (shape as { id?: unknown }).id
|
|
267
|
+
body.push(shape.kind === 'box' && typeof nodeId === 'string' ? '<g data-node-id="' + escape(nodeId) + '">' + drawn + '</g>' : drawn)
|
|
262
268
|
}
|
|
263
269
|
const header =
|
|
264
270
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
|
package/src/routing.ts
CHANGED
|
@@ -78,6 +78,18 @@ export const ROUTES: Record<string, Route> = {
|
|
|
78
78
|
owns: 'a dependency graph as a graph - cycles, layers, dangling edges - from a drawing or a source tree',
|
|
79
79
|
notFor: { when: 'the question is a rule the design declared for itself, not an edge that exists', tool: 'model_design_review' },
|
|
80
80
|
},
|
|
81
|
+
diagram_render: {
|
|
82
|
+
owns: 'one drawing: source text in, a laid-out SVG out, every refused line named',
|
|
83
|
+
notFor: { when: 'the drawing has to fit a budget or a pane before a reader sees it', tool: 'diagram_review' },
|
|
84
|
+
},
|
|
85
|
+
diagram_review: {
|
|
86
|
+
owns: 'the structure of one drawing against budgets: boundary width, nesting depth, node count',
|
|
87
|
+
notFor: { when: 'the diagram has to be read as a model, and its modelling judged', tool: 'uml_review' },
|
|
88
|
+
},
|
|
89
|
+
diagram_diff: {
|
|
90
|
+
owns: 'what moved between two drawings: boundaries added, removed, renamed or re-parented',
|
|
91
|
+
notFor: { when: 'two reports have to be compared rather than two drawings', tool: 'report_diff' },
|
|
92
|
+
},
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/** The route for a tool, or undefined when the tool has none. */
|
|
@@ -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
|
+
}
|