@ai-gui/plugin-molecule 0.4.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/index.cjs +494 -0
- package/dist/index.d.cts +57 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +488 -0
- package/package.json +64 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @ai-gui/plugin-molecule
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add plugin authoring helpers, secure source citation blocks, revisioned artifacts, bounded declarative AI-generated UI trees, molecular structures, and interactive maps.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @ai-gui/core@0.4.0
|
|
13
|
+
|
|
14
|
+
## 0.3.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- Add strict, complete-gated molecular structure rendering for SMILES and Molfile definitions.
|
|
19
|
+
- Add sanitized responsive OpenChemLib 2D SVG output and lazily loaded interactive 3Dmol rendering with deterministic styles and lifecycle cleanup.
|
|
20
|
+
- Add public parsing, validation, prompt, option, result, error, definition, and CSS APIs.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Liang Li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @ai-gui/plugin-molecule
|
|
2
|
+
|
|
3
|
+
Safe molecular structure rendering for [AIGUI](../../README.md). The plugin claims one complete-gated `molecule` fence and renders validated SMILES or Molfile source as responsive 2D SVG or an interactive 3D viewer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @ai-gui/plugin-molecule
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { molecule, moleculeCss } from "@ai-gui/plugin-molecule"
|
|
15
|
+
import { AIRenderer } from "@ai-gui/react"
|
|
16
|
+
|
|
17
|
+
<style>{moleculeCss}</style>
|
|
18
|
+
<AIRenderer plugins={[molecule({ enable3D: true })]} />
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
SMILES is supported for 2D structures only:
|
|
22
|
+
|
|
23
|
+
```molecule
|
|
24
|
+
{"version":1,"format":"smiles","source":"CCO","view":"2d","atomLabels":"standard","highlight":{"atoms":[2]}}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Molfile supports 2D and 3D. A 3D structure must contain finite, genuinely non-flat z coordinates:
|
|
28
|
+
|
|
29
|
+
```molecule
|
|
30
|
+
{"version":1,"format":"molfile","source":"...local Molfile text...","view":"3d","style":"ball-and-stick"}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The protocol accepts only `version`, `format`, `source`, `view`, optional `style`, optional `atomLabels`, and optional `highlight`. Unknown fields, URLs, class instances, cycles, sparse arrays, duplicate indexes, unsafe integers, nonfinite values, malformed chemistry, and oversized structures are rejected with a generic non-reflective error.
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
- `molecule(options?)` creates the AIGUI plugin.
|
|
38
|
+
- `moleculePromptSpec(options?)` returns the model-facing protocol description.
|
|
39
|
+
- `parseMoleculeDefinition(source, options?)` strictly parses and validates JSON and chemistry.
|
|
40
|
+
- `validateMoleculeDefinition(value, options?)` validates an existing value and its chemistry.
|
|
41
|
+
- `moleculeCss` contains optional package styling.
|
|
42
|
+
|
|
43
|
+
## Options
|
|
44
|
+
|
|
45
|
+
- `width?: number`: 160 to 1200, default `600`.
|
|
46
|
+
- `height?: number`: 160 to 900, default `400`.
|
|
47
|
+
- `enable3D?: boolean`: default `true`.
|
|
48
|
+
- `maxAtoms?: number`: 1 to 1024, default `256`.
|
|
49
|
+
- `maxBonds?: number`: 0 to 2048, default `512`.
|
|
50
|
+
- `maxSourceBytes?: number`: 1 to 256 KiB, default 64 KiB.
|
|
51
|
+
|
|
52
|
+
Both OpenChemLib and 3Dmol are loaded dynamically. Importing this package in Node does not load browser chemistry libraries. 3D rendering passes only validated local Molfile text to `addModel`; it never calls remote loading, download, fetch, get, or autoload APIs.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
width: 600,
|
|
6
|
+
height: 400,
|
|
7
|
+
enable3D: true,
|
|
8
|
+
maxAtoms: 256,
|
|
9
|
+
maxBonds: 512,
|
|
10
|
+
maxSourceBytes: 64 * 1024
|
|
11
|
+
};
|
|
12
|
+
const MAX_SOURCE_BYTES = 256 * 1024;
|
|
13
|
+
const MAX_ATOMS = 1024;
|
|
14
|
+
const MAX_BONDS = 2048;
|
|
15
|
+
const DEFINITION_KEYS = new Set([
|
|
16
|
+
"version",
|
|
17
|
+
"format",
|
|
18
|
+
"source",
|
|
19
|
+
"view",
|
|
20
|
+
"style",
|
|
21
|
+
"atomLabels",
|
|
22
|
+
"highlight"
|
|
23
|
+
]);
|
|
24
|
+
const HIGHLIGHT_KEYS = new Set(["atoms", "bonds"]);
|
|
25
|
+
const OPTION_KEYS = new Set([
|
|
26
|
+
"width",
|
|
27
|
+
"height",
|
|
28
|
+
"enable3D",
|
|
29
|
+
"maxAtoms",
|
|
30
|
+
"maxBonds",
|
|
31
|
+
"maxSourceBytes"
|
|
32
|
+
]);
|
|
33
|
+
const DANGEROUS_KEYS = new Set([
|
|
34
|
+
"__proto__",
|
|
35
|
+
"prototype",
|
|
36
|
+
"constructor"
|
|
37
|
+
]);
|
|
38
|
+
const encoder = new TextEncoder();
|
|
39
|
+
let oclPromise = null;
|
|
40
|
+
const loadOpenChemLib = () => oclPromise ??= import("openchemlib");
|
|
41
|
+
function invalid() {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: {
|
|
45
|
+
code: "invalid-definition",
|
|
46
|
+
message: "Molecule definition is invalid."
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function isPlainObject(value) {
|
|
51
|
+
if (value === null || typeof value !== "object") return false;
|
|
52
|
+
const prototype = Object.getPrototypeOf(value);
|
|
53
|
+
return prototype === Object.prototype || prototype === null;
|
|
54
|
+
}
|
|
55
|
+
function isDenseUniqueIndexes(value) {
|
|
56
|
+
if (!Array.isArray(value)) return false;
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (let index = 0; index < value.length; index++) {
|
|
59
|
+
if (!(index in value)) return false;
|
|
60
|
+
const item = value[index];
|
|
61
|
+
if (!Number.isSafeInteger(item) || item < 0 || seen.has(item)) return false;
|
|
62
|
+
seen.add(item);
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
function isStructurallySafe(value, seen = new Set()) {
|
|
67
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
68
|
+
if (value === null || ["string", "boolean"].includes(typeof value)) return true;
|
|
69
|
+
if (typeof value !== "object") return false;
|
|
70
|
+
if (seen.has(value)) return false;
|
|
71
|
+
seen.add(value);
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
for (let i = 0; i < value.length; i++) if (!(i in value) || !isStructurallySafe(value[i], seen)) return false;
|
|
74
|
+
seen.delete(value);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (!isPlainObject(value)) return false;
|
|
78
|
+
for (const key of Object.keys(value)) if (DANGEROUS_KEYS.has(key) || !isStructurallySafe(value[key], seen)) return false;
|
|
79
|
+
seen.delete(value);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
function resolveOptions(options = {}) {
|
|
83
|
+
if (!isPlainObject(options)) throw new TypeError("Molecule options must be a plain object");
|
|
84
|
+
for (const key of Object.keys(options)) if (!OPTION_KEYS.has(key) || DANGEROUS_KEYS.has(key)) throw new TypeError("Unknown molecule option");
|
|
85
|
+
const resolved = {
|
|
86
|
+
...DEFAULTS,
|
|
87
|
+
...options
|
|
88
|
+
};
|
|
89
|
+
const boundedInteger = (name, min, max) => {
|
|
90
|
+
const value = resolved[name];
|
|
91
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new TypeError(`${name} is out of range`);
|
|
92
|
+
};
|
|
93
|
+
boundedInteger("width", 160, 1200);
|
|
94
|
+
boundedInteger("height", 160, 900);
|
|
95
|
+
boundedInteger("maxAtoms", 1, MAX_ATOMS);
|
|
96
|
+
boundedInteger("maxBonds", 0, MAX_BONDS);
|
|
97
|
+
boundedInteger("maxSourceBytes", 1, MAX_SOURCE_BYTES);
|
|
98
|
+
if (typeof resolved.enable3D !== "boolean") throw new TypeError("enable3D must be boolean");
|
|
99
|
+
return resolved;
|
|
100
|
+
}
|
|
101
|
+
function validateShape(value, options) {
|
|
102
|
+
if (!isStructurallySafe(value) || !isPlainObject(value)) return null;
|
|
103
|
+
const keys = Object.keys(value);
|
|
104
|
+
if (keys.some((key) => !DEFINITION_KEYS.has(key) || DANGEROUS_KEYS.has(key))) return null;
|
|
105
|
+
if (value.version !== 1 || value.format !== "smiles" && value.format !== "molfile") return null;
|
|
106
|
+
if (typeof value.source !== "string" || encoder.encode(value.source).byteLength > options.maxSourceBytes) return null;
|
|
107
|
+
if (value.view !== "2d" && value.view !== "3d") return null;
|
|
108
|
+
if (value.atomLabels !== void 0 && value.atomLabels !== "standard" && value.atomLabels !== "all") return null;
|
|
109
|
+
if (value.highlight !== void 0) {
|
|
110
|
+
if (!isPlainObject(value.highlight)) return null;
|
|
111
|
+
if (Object.keys(value.highlight).some((key) => !HIGHLIGHT_KEYS.has(key) || DANGEROUS_KEYS.has(key))) return null;
|
|
112
|
+
if (value.highlight.atoms !== void 0 && !isDenseUniqueIndexes(value.highlight.atoms)) return null;
|
|
113
|
+
if (value.highlight.bonds !== void 0 && !isDenseUniqueIndexes(value.highlight.bonds)) return null;
|
|
114
|
+
}
|
|
115
|
+
if (value.view === "2d") {
|
|
116
|
+
if (value.style !== void 0) return null;
|
|
117
|
+
} else {
|
|
118
|
+
if (value.format !== "molfile" || !options.enable3D) return null;
|
|
119
|
+
if (value.style !== void 0 && value.style !== "ball-and-stick" && value.style !== "space-filling") return null;
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
async function validateChemistry(definition, options) {
|
|
124
|
+
try {
|
|
125
|
+
const OCL = await loadOpenChemLib();
|
|
126
|
+
const parsed = definition.format === "smiles" ? OCL.Molecule.fromSmiles(definition.source) : OCL.Molecule.fromMolfile(definition.source);
|
|
127
|
+
const atomCount = parsed.getAllAtoms();
|
|
128
|
+
const bondCount = parsed.getAllBonds();
|
|
129
|
+
if (!Number.isSafeInteger(atomCount) || atomCount < 1 || atomCount > options.maxAtoms) return null;
|
|
130
|
+
if (!Number.isSafeInteger(bondCount) || bondCount < 0 || bondCount > options.maxBonds) return null;
|
|
131
|
+
if (definition.highlight?.atoms?.some((index) => index >= atomCount)) return null;
|
|
132
|
+
if (definition.highlight?.bonds?.some((index) => index >= bondCount)) return null;
|
|
133
|
+
if (definition.view === "3d") {
|
|
134
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
135
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
136
|
+
for (let atom = 0; atom < atomCount; atom++) {
|
|
137
|
+
const x = parsed.getAtomX(atom);
|
|
138
|
+
const y = parsed.getAtomY(atom);
|
|
139
|
+
const z = parsed.getAtomZ(atom);
|
|
140
|
+
if (![
|
|
141
|
+
x,
|
|
142
|
+
y,
|
|
143
|
+
z
|
|
144
|
+
].every(Number.isFinite)) return null;
|
|
145
|
+
minZ = Math.min(minZ, z);
|
|
146
|
+
maxZ = Math.max(maxZ, z);
|
|
147
|
+
}
|
|
148
|
+
if (maxZ - minZ <= 1e-6) return null;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
definition,
|
|
152
|
+
molecule: parsed,
|
|
153
|
+
atomCount,
|
|
154
|
+
bondCount
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function validateMoleculeDefinition(value, options = {}) {
|
|
161
|
+
let resolved;
|
|
162
|
+
try {
|
|
163
|
+
resolved = resolveOptions(options);
|
|
164
|
+
} catch {
|
|
165
|
+
throw new TypeError("Invalid molecule options");
|
|
166
|
+
}
|
|
167
|
+
const definition = validateShape(value, resolved);
|
|
168
|
+
if (!definition) return invalid();
|
|
169
|
+
const chemistry = await validateChemistry(definition, resolved);
|
|
170
|
+
return chemistry ? {
|
|
171
|
+
ok: true,
|
|
172
|
+
value: chemistry.definition
|
|
173
|
+
} : invalid();
|
|
174
|
+
}
|
|
175
|
+
async function parseMoleculeDefinition(source, options = {}) {
|
|
176
|
+
if (typeof source !== "string") return invalid();
|
|
177
|
+
let value;
|
|
178
|
+
try {
|
|
179
|
+
value = JSON.parse(source);
|
|
180
|
+
} catch {
|
|
181
|
+
return invalid();
|
|
182
|
+
}
|
|
183
|
+
return validateMoleculeDefinition(value, options);
|
|
184
|
+
}
|
|
185
|
+
function loadingOutput() {
|
|
186
|
+
return {
|
|
187
|
+
kind: "html",
|
|
188
|
+
html: "<div data-aigui-molecule-loading aria-label=\"Loading molecule\"></div>"
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function errorOutput() {
|
|
192
|
+
return {
|
|
193
|
+
kind: "html",
|
|
194
|
+
html: "<div data-aigui-molecule-error role=\"img\" aria-label=\"Molecule could not be rendered.\">Molecule could not be rendered.</div>"
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const SAFE_SVG_ELEMENTS = new Set([
|
|
198
|
+
"svg",
|
|
199
|
+
"g",
|
|
200
|
+
"path",
|
|
201
|
+
"line",
|
|
202
|
+
"polyline",
|
|
203
|
+
"polygon",
|
|
204
|
+
"circle",
|
|
205
|
+
"ellipse",
|
|
206
|
+
"rect",
|
|
207
|
+
"text",
|
|
208
|
+
"tspan",
|
|
209
|
+
"defs",
|
|
210
|
+
"clipPath"
|
|
211
|
+
]);
|
|
212
|
+
const SAFE_SVG_ATTRIBUTES = new Set([
|
|
213
|
+
"viewBox",
|
|
214
|
+
"width",
|
|
215
|
+
"height",
|
|
216
|
+
"x",
|
|
217
|
+
"y",
|
|
218
|
+
"x1",
|
|
219
|
+
"x2",
|
|
220
|
+
"y1",
|
|
221
|
+
"y2",
|
|
222
|
+
"cx",
|
|
223
|
+
"cy",
|
|
224
|
+
"r",
|
|
225
|
+
"rx",
|
|
226
|
+
"ry",
|
|
227
|
+
"d",
|
|
228
|
+
"points",
|
|
229
|
+
"fill",
|
|
230
|
+
"fill-opacity",
|
|
231
|
+
"stroke",
|
|
232
|
+
"stroke-width",
|
|
233
|
+
"stroke-linecap",
|
|
234
|
+
"stroke-linejoin",
|
|
235
|
+
"stroke-opacity",
|
|
236
|
+
"font-family",
|
|
237
|
+
"font-size",
|
|
238
|
+
"font-weight",
|
|
239
|
+
"text-anchor",
|
|
240
|
+
"dominant-baseline",
|
|
241
|
+
"transform",
|
|
242
|
+
"clip-path",
|
|
243
|
+
"id",
|
|
244
|
+
"class",
|
|
245
|
+
"style",
|
|
246
|
+
"xmlns"
|
|
247
|
+
]);
|
|
248
|
+
function safeSvgElement(svgSource, width, height) {
|
|
249
|
+
const parsed = new DOMParser().parseFromString(svgSource, "image/svg+xml");
|
|
250
|
+
if (parsed.querySelector("parsererror") || parsed.documentElement.localName !== "svg") return null;
|
|
251
|
+
for (const element of Array.from(parsed.querySelectorAll("*"))) {
|
|
252
|
+
if (!SAFE_SVG_ELEMENTS.has(element.localName)) {
|
|
253
|
+
element.remove();
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
257
|
+
const value = attribute.value;
|
|
258
|
+
if (!SAFE_SVG_ATTRIBUTES.has(attribute.name) || /^on/i.test(attribute.name) || /url\s*\(|javascript:|data:/i.test(value)) element.removeAttribute(attribute.name);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const svg = document.importNode(parsed.documentElement, true);
|
|
262
|
+
svg.setAttribute("width", "100%");
|
|
263
|
+
svg.removeAttribute("height");
|
|
264
|
+
svg.setAttribute("role", "img");
|
|
265
|
+
svg.setAttribute("aria-label", "Molecular structure");
|
|
266
|
+
svg.setAttribute("style", `display:block;width:100%;height:auto;max-width:${width}px;max-height:${height}px;margin:auto`);
|
|
267
|
+
return svg;
|
|
268
|
+
}
|
|
269
|
+
function mount2D(svgSource, options) {
|
|
270
|
+
return {
|
|
271
|
+
kind: "mount",
|
|
272
|
+
mount(host) {
|
|
273
|
+
host.replaceChildren();
|
|
274
|
+
host.setAttribute("data-aigui-molecule", "2d");
|
|
275
|
+
const svg = safeSvgElement(svgSource, options.width, options.height);
|
|
276
|
+
if (!svg) {
|
|
277
|
+
host.appendChild(moleculeErrorElement());
|
|
278
|
+
return () => host.replaceChildren();
|
|
279
|
+
}
|
|
280
|
+
host.appendChild(svg);
|
|
281
|
+
let disposed = false;
|
|
282
|
+
return () => {
|
|
283
|
+
if (disposed) return;
|
|
284
|
+
disposed = true;
|
|
285
|
+
host.replaceChildren();
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const ELEMENT_COLORS = {
|
|
291
|
+
H: 16777215,
|
|
292
|
+
C: 9474192,
|
|
293
|
+
N: 3166456,
|
|
294
|
+
O: 16715021,
|
|
295
|
+
F: 9494608,
|
|
296
|
+
P: 16744448,
|
|
297
|
+
S: 16777008,
|
|
298
|
+
Cl: 2093087,
|
|
299
|
+
Br: 10889513,
|
|
300
|
+
I: 9699476
|
|
301
|
+
};
|
|
302
|
+
function mount3D(definition, options) {
|
|
303
|
+
return {
|
|
304
|
+
kind: "mount",
|
|
305
|
+
mount(host) {
|
|
306
|
+
let disposed = false;
|
|
307
|
+
let viewer;
|
|
308
|
+
let observer;
|
|
309
|
+
let reset;
|
|
310
|
+
host.replaceChildren();
|
|
311
|
+
host.setAttribute("data-aigui-molecule", "3d");
|
|
312
|
+
host.style.position = "relative";
|
|
313
|
+
host.style.width = "100%";
|
|
314
|
+
host.style.maxWidth = `${options.width}px`;
|
|
315
|
+
host.style.height = `${options.height}px`;
|
|
316
|
+
const viewport = document.createElement("div");
|
|
317
|
+
viewport.style.width = "100%";
|
|
318
|
+
viewport.style.height = "100%";
|
|
319
|
+
reset = document.createElement("button");
|
|
320
|
+
reset.type = "button";
|
|
321
|
+
reset.textContent = "Reset";
|
|
322
|
+
reset.setAttribute("data-aigui-molecule-reset", "");
|
|
323
|
+
const onReset = () => {
|
|
324
|
+
if (disposed || !viewer) return;
|
|
325
|
+
viewer.zoomTo();
|
|
326
|
+
viewer.render();
|
|
327
|
+
};
|
|
328
|
+
reset.addEventListener("click", onReset);
|
|
329
|
+
host.append(viewport, reset);
|
|
330
|
+
import("3dmol").then((module$1) => {
|
|
331
|
+
if (disposed) return;
|
|
332
|
+
const createViewer = module$1.createViewer;
|
|
333
|
+
viewer = createViewer(viewport, {
|
|
334
|
+
backgroundColor: "white",
|
|
335
|
+
defaultcolors: ELEMENT_COLORS
|
|
336
|
+
});
|
|
337
|
+
if (disposed) return;
|
|
338
|
+
viewer.addModel(definition.source, "mol");
|
|
339
|
+
const style = definition.style ?? "ball-and-stick";
|
|
340
|
+
viewer.setStyle({}, style === "space-filling" ? { sphere: {
|
|
341
|
+
scale: 1,
|
|
342
|
+
colorscheme: "default"
|
|
343
|
+
} } : {
|
|
344
|
+
stick: {
|
|
345
|
+
radius: .18,
|
|
346
|
+
colorscheme: "default"
|
|
347
|
+
},
|
|
348
|
+
sphere: {
|
|
349
|
+
scale: .28,
|
|
350
|
+
colorscheme: "default"
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
if (definition.highlight?.atoms?.length) viewer.addStyle({ index: definition.highlight.atoms }, { sphere: {
|
|
354
|
+
color: 16761856,
|
|
355
|
+
scale: .48
|
|
356
|
+
} });
|
|
357
|
+
if (definition.highlight?.bonds?.length) validateChemistry(definition, options).then((chemistry) => {
|
|
358
|
+
if (disposed || !viewer || !chemistry) return;
|
|
359
|
+
const atoms = new Set();
|
|
360
|
+
for (const bond of definition.highlight?.bonds ?? []) {
|
|
361
|
+
atoms.add(chemistry.molecule.getBondAtom(0, bond));
|
|
362
|
+
atoms.add(chemistry.molecule.getBondAtom(1, bond));
|
|
363
|
+
}
|
|
364
|
+
viewer.addStyle({ index: [...atoms] }, { stick: {
|
|
365
|
+
color: 16761856,
|
|
366
|
+
radius: .3
|
|
367
|
+
} });
|
|
368
|
+
viewer.render();
|
|
369
|
+
}).catch(() => void 0);
|
|
370
|
+
viewer.zoomTo();
|
|
371
|
+
viewer.render();
|
|
372
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
373
|
+
observer = new ResizeObserver(() => {
|
|
374
|
+
if (!disposed && viewer) viewer.resize();
|
|
375
|
+
});
|
|
376
|
+
observer.observe(host);
|
|
377
|
+
}
|
|
378
|
+
}).catch(() => {
|
|
379
|
+
if (disposed) return;
|
|
380
|
+
host.replaceChildren(moleculeErrorElement());
|
|
381
|
+
});
|
|
382
|
+
return () => {
|
|
383
|
+
if (disposed) return;
|
|
384
|
+
disposed = true;
|
|
385
|
+
reset?.removeEventListener("click", onReset);
|
|
386
|
+
observer?.disconnect();
|
|
387
|
+
if (viewer) {
|
|
388
|
+
let canvas;
|
|
389
|
+
try {
|
|
390
|
+
canvas = viewer.getCanvas();
|
|
391
|
+
} catch {
|
|
392
|
+
canvas = void 0;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
viewer.removeAllLabels();
|
|
396
|
+
} catch {}
|
|
397
|
+
try {
|
|
398
|
+
viewer.removeAllShapes();
|
|
399
|
+
} catch {}
|
|
400
|
+
try {
|
|
401
|
+
viewer.removeAllModels();
|
|
402
|
+
} catch {}
|
|
403
|
+
try {
|
|
404
|
+
viewer.clear();
|
|
405
|
+
} catch {}
|
|
406
|
+
try {
|
|
407
|
+
const gl = canvas?.getContext("webgl2") ?? canvas?.getContext("webgl");
|
|
408
|
+
gl?.getExtension("WEBGL_lose_context")?.loseContext();
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
411
|
+
viewer = void 0;
|
|
412
|
+
host.replaceChildren();
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function createOutput(node, options) {
|
|
418
|
+
if (node.complete !== true) return loadingOutput();
|
|
419
|
+
let value;
|
|
420
|
+
try {
|
|
421
|
+
value = JSON.parse(node.content ?? "");
|
|
422
|
+
} catch {
|
|
423
|
+
return errorOutput();
|
|
424
|
+
}
|
|
425
|
+
const definition = validateShape(value, options);
|
|
426
|
+
if (!definition) return errorOutput();
|
|
427
|
+
const chemistry = await validateChemistry(definition, options);
|
|
428
|
+
if (!chemistry) return errorOutput();
|
|
429
|
+
if (definition.view === "3d") return mount3D(definition, options);
|
|
430
|
+
try {
|
|
431
|
+
for (const atom of definition.highlight?.atoms ?? []) chemistry.molecule.setAtomSelection(atom, true);
|
|
432
|
+
for (const bond of definition.highlight?.bonds ?? []) {
|
|
433
|
+
chemistry.molecule.setAtomSelection(chemistry.molecule.getBondAtom(0, bond), true);
|
|
434
|
+
chemistry.molecule.setAtomSelection(chemistry.molecule.getBondAtom(1, bond), true);
|
|
435
|
+
}
|
|
436
|
+
const svg = chemistry.molecule.toSVG(options.width, options.height, void 0, {
|
|
437
|
+
showAtomNumber: definition.atomLabels === "all",
|
|
438
|
+
autoCrop: false
|
|
439
|
+
});
|
|
440
|
+
return mount2D(svg, options);
|
|
441
|
+
} catch {
|
|
442
|
+
return errorOutput();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function moleculePromptSpec(options = {}) {
|
|
446
|
+
const resolved = resolveOptions(options);
|
|
447
|
+
return [
|
|
448
|
+
"Molecules (one complete-gated fenced block): ```molecule <strict JSON>```.",
|
|
449
|
+
"Exact root fields: {\"version\":1,\"format\":\"smiles|molfile\",\"source\":\"...\",\"view\":\"2d|3d\",\"style\":\"ball-and-stick|space-filling\"?,\"atomLabels\":\"standard|all\"?,\"highlight\":{\"atoms\":[0],\"bonds\":[0]}?}. No unknown fields.",
|
|
450
|
+
"SMILES supports 2d only. Molfile supports 2d and 3d; 3d requires genuine finite non-flat z coordinates.",
|
|
451
|
+
`Source is local text only and must be at most ${resolved.maxSourceBytes} UTF-8 bytes. Atom and bond indexes are zero-based unique nonnegative integers.`,
|
|
452
|
+
"Never emit URLs, scripts, network requests, remote resources, HTML, credentials, download/get/autoload/fetch instructions, or executable content."
|
|
453
|
+
].join("\n");
|
|
454
|
+
}
|
|
455
|
+
const moleculeCss = `
|
|
456
|
+
[data-aigui-molecule]{box-sizing:border-box;max-width:100%;overflow:hidden}
|
|
457
|
+
[data-aigui-molecule="2d"] svg{display:block;max-width:100%;height:auto}
|
|
458
|
+
[data-aigui-molecule="3d"] canvas{display:block;max-width:100%}
|
|
459
|
+
[data-aigui-molecule-reset]{position:absolute;right:.5rem;bottom:.5rem;z-index:1;padding:.35rem .65rem;border:1px solid currentColor;border-radius:.35rem;background:Canvas;color:CanvasText;cursor:pointer}
|
|
460
|
+
[data-aigui-molecule-loading]{min-height:10rem;border-radius:.5rem;background:linear-gradient(90deg,transparent,rgba(127,127,127,.12),transparent);background-size:200% 100%;animation:aigui-molecule-loading 1.2s linear infinite}
|
|
461
|
+
[data-aigui-molecule-error]{padding:1rem;border:1px solid currentColor;border-radius:.5rem}
|
|
462
|
+
@keyframes aigui-molecule-loading{to{background-position:-200% 0}}
|
|
463
|
+
`.trim();
|
|
464
|
+
function molecule(options = {}) {
|
|
465
|
+
const resolved = resolveOptions(options);
|
|
466
|
+
const outputs = new WeakMap();
|
|
467
|
+
const render = (node) => {
|
|
468
|
+
const cached = outputs.get(node);
|
|
469
|
+
if (cached) return cached;
|
|
470
|
+
const output = createOutput(node, resolved).catch(() => errorOutput());
|
|
471
|
+
outputs.set(node, output);
|
|
472
|
+
return output;
|
|
473
|
+
};
|
|
474
|
+
return {
|
|
475
|
+
name: "molecule",
|
|
476
|
+
nodeRenderers: { molecule: render },
|
|
477
|
+
promptSpec: moleculePromptSpec(resolved)
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function moleculeErrorElement() {
|
|
481
|
+
const error = document.createElement("div");
|
|
482
|
+
error.setAttribute("data-aigui-molecule-error", "");
|
|
483
|
+
error.setAttribute("role", "img");
|
|
484
|
+
error.setAttribute("aria-label", "Molecule could not be rendered.");
|
|
485
|
+
error.textContent = "Molecule could not be rendered.";
|
|
486
|
+
return error;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
//#endregion
|
|
490
|
+
exports.molecule = molecule
|
|
491
|
+
exports.moleculeCss = moleculeCss
|
|
492
|
+
exports.moleculePromptSpec = moleculePromptSpec
|
|
493
|
+
exports.parseMoleculeDefinition = parseMoleculeDefinition
|
|
494
|
+
exports.validateMoleculeDefinition = validateMoleculeDefinition
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AIGuiPlugin } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
type MoleculeFormat = "smiles" | "molfile";
|
|
5
|
+
type MoleculeView = "2d" | "3d";
|
|
6
|
+
type MoleculeStyle = "ball-and-stick" | "space-filling";
|
|
7
|
+
type MoleculeAtomLabels = "standard" | "all";
|
|
8
|
+
interface MoleculeHighlight {
|
|
9
|
+
atoms?: number[];
|
|
10
|
+
bonds?: number[];
|
|
11
|
+
}
|
|
12
|
+
interface Molecule2DDefinition {
|
|
13
|
+
version: 1;
|
|
14
|
+
format: MoleculeFormat;
|
|
15
|
+
source: string;
|
|
16
|
+
view: "2d";
|
|
17
|
+
atomLabels?: MoleculeAtomLabels;
|
|
18
|
+
highlight?: MoleculeHighlight;
|
|
19
|
+
}
|
|
20
|
+
interface Molecule3DDefinition {
|
|
21
|
+
version: 1;
|
|
22
|
+
format: "molfile";
|
|
23
|
+
source: string;
|
|
24
|
+
view: "3d";
|
|
25
|
+
style?: MoleculeStyle;
|
|
26
|
+
atomLabels?: MoleculeAtomLabels;
|
|
27
|
+
highlight?: MoleculeHighlight;
|
|
28
|
+
}
|
|
29
|
+
type MoleculeDefinition = Molecule2DDefinition | Molecule3DDefinition;
|
|
30
|
+
interface MoleculeOptions {
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
enable3D?: boolean;
|
|
34
|
+
maxAtoms?: number;
|
|
35
|
+
maxBonds?: number;
|
|
36
|
+
maxSourceBytes?: number;
|
|
37
|
+
}
|
|
38
|
+
type MoleculeErrorCode = "invalid-definition" | "invalid-options";
|
|
39
|
+
interface MoleculeError {
|
|
40
|
+
code: MoleculeErrorCode;
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
type MoleculeResult<T = MoleculeDefinition> = {
|
|
44
|
+
ok: true;
|
|
45
|
+
value: T;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
error: MoleculeError;
|
|
49
|
+
};
|
|
50
|
+
declare function validateMoleculeDefinition(value: unknown, options?: MoleculeOptions): Promise<MoleculeResult>;
|
|
51
|
+
declare function parseMoleculeDefinition(source: string, options?: MoleculeOptions): Promise<MoleculeResult>;
|
|
52
|
+
declare function moleculePromptSpec(options?: MoleculeOptions): string;
|
|
53
|
+
declare const moleculeCss: string;
|
|
54
|
+
declare function molecule(options?: MoleculeOptions): AIGuiPlugin;
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
export { Molecule2DDefinition, Molecule3DDefinition, MoleculeAtomLabels, MoleculeDefinition, MoleculeError, MoleculeErrorCode, MoleculeFormat, MoleculeHighlight, MoleculeOptions, MoleculeResult, MoleculeStyle, MoleculeView, molecule, moleculeCss, moleculePromptSpec, parseMoleculeDefinition, validateMoleculeDefinition };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AIGuiPlugin } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
type MoleculeFormat = "smiles" | "molfile";
|
|
5
|
+
type MoleculeView = "2d" | "3d";
|
|
6
|
+
type MoleculeStyle = "ball-and-stick" | "space-filling";
|
|
7
|
+
type MoleculeAtomLabels = "standard" | "all";
|
|
8
|
+
interface MoleculeHighlight {
|
|
9
|
+
atoms?: number[];
|
|
10
|
+
bonds?: number[];
|
|
11
|
+
}
|
|
12
|
+
interface Molecule2DDefinition {
|
|
13
|
+
version: 1;
|
|
14
|
+
format: MoleculeFormat;
|
|
15
|
+
source: string;
|
|
16
|
+
view: "2d";
|
|
17
|
+
atomLabels?: MoleculeAtomLabels;
|
|
18
|
+
highlight?: MoleculeHighlight;
|
|
19
|
+
}
|
|
20
|
+
interface Molecule3DDefinition {
|
|
21
|
+
version: 1;
|
|
22
|
+
format: "molfile";
|
|
23
|
+
source: string;
|
|
24
|
+
view: "3d";
|
|
25
|
+
style?: MoleculeStyle;
|
|
26
|
+
atomLabels?: MoleculeAtomLabels;
|
|
27
|
+
highlight?: MoleculeHighlight;
|
|
28
|
+
}
|
|
29
|
+
type MoleculeDefinition = Molecule2DDefinition | Molecule3DDefinition;
|
|
30
|
+
interface MoleculeOptions {
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
enable3D?: boolean;
|
|
34
|
+
maxAtoms?: number;
|
|
35
|
+
maxBonds?: number;
|
|
36
|
+
maxSourceBytes?: number;
|
|
37
|
+
}
|
|
38
|
+
type MoleculeErrorCode = "invalid-definition" | "invalid-options";
|
|
39
|
+
interface MoleculeError {
|
|
40
|
+
code: MoleculeErrorCode;
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
type MoleculeResult<T = MoleculeDefinition> = {
|
|
44
|
+
ok: true;
|
|
45
|
+
value: T;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
error: MoleculeError;
|
|
49
|
+
};
|
|
50
|
+
declare function validateMoleculeDefinition(value: unknown, options?: MoleculeOptions): Promise<MoleculeResult>;
|
|
51
|
+
declare function parseMoleculeDefinition(source: string, options?: MoleculeOptions): Promise<MoleculeResult>;
|
|
52
|
+
declare function moleculePromptSpec(options?: MoleculeOptions): string;
|
|
53
|
+
declare const moleculeCss: string;
|
|
54
|
+
declare function molecule(options?: MoleculeOptions): AIGuiPlugin;
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
export { Molecule2DDefinition, Molecule3DDefinition, MoleculeAtomLabels, MoleculeDefinition, MoleculeError, MoleculeErrorCode, MoleculeFormat, MoleculeHighlight, MoleculeOptions, MoleculeResult, MoleculeStyle, MoleculeView, molecule, moleculeCss, moleculePromptSpec, parseMoleculeDefinition, validateMoleculeDefinition };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
width: 600,
|
|
4
|
+
height: 400,
|
|
5
|
+
enable3D: true,
|
|
6
|
+
maxAtoms: 256,
|
|
7
|
+
maxBonds: 512,
|
|
8
|
+
maxSourceBytes: 64 * 1024
|
|
9
|
+
};
|
|
10
|
+
const MAX_SOURCE_BYTES = 256 * 1024;
|
|
11
|
+
const MAX_ATOMS = 1024;
|
|
12
|
+
const MAX_BONDS = 2048;
|
|
13
|
+
const DEFINITION_KEYS = new Set([
|
|
14
|
+
"version",
|
|
15
|
+
"format",
|
|
16
|
+
"source",
|
|
17
|
+
"view",
|
|
18
|
+
"style",
|
|
19
|
+
"atomLabels",
|
|
20
|
+
"highlight"
|
|
21
|
+
]);
|
|
22
|
+
const HIGHLIGHT_KEYS = new Set(["atoms", "bonds"]);
|
|
23
|
+
const OPTION_KEYS = new Set([
|
|
24
|
+
"width",
|
|
25
|
+
"height",
|
|
26
|
+
"enable3D",
|
|
27
|
+
"maxAtoms",
|
|
28
|
+
"maxBonds",
|
|
29
|
+
"maxSourceBytes"
|
|
30
|
+
]);
|
|
31
|
+
const DANGEROUS_KEYS = new Set([
|
|
32
|
+
"__proto__",
|
|
33
|
+
"prototype",
|
|
34
|
+
"constructor"
|
|
35
|
+
]);
|
|
36
|
+
const encoder = new TextEncoder();
|
|
37
|
+
let oclPromise = null;
|
|
38
|
+
const loadOpenChemLib = () => oclPromise ??= import("openchemlib");
|
|
39
|
+
function invalid() {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: {
|
|
43
|
+
code: "invalid-definition",
|
|
44
|
+
message: "Molecule definition is invalid."
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function isPlainObject(value) {
|
|
49
|
+
if (value === null || typeof value !== "object") return false;
|
|
50
|
+
const prototype = Object.getPrototypeOf(value);
|
|
51
|
+
return prototype === Object.prototype || prototype === null;
|
|
52
|
+
}
|
|
53
|
+
function isDenseUniqueIndexes(value) {
|
|
54
|
+
if (!Array.isArray(value)) return false;
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (let index = 0; index < value.length; index++) {
|
|
57
|
+
if (!(index in value)) return false;
|
|
58
|
+
const item = value[index];
|
|
59
|
+
if (!Number.isSafeInteger(item) || item < 0 || seen.has(item)) return false;
|
|
60
|
+
seen.add(item);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
function isStructurallySafe(value, seen = new Set()) {
|
|
65
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
66
|
+
if (value === null || ["string", "boolean"].includes(typeof value)) return true;
|
|
67
|
+
if (typeof value !== "object") return false;
|
|
68
|
+
if (seen.has(value)) return false;
|
|
69
|
+
seen.add(value);
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
for (let i = 0; i < value.length; i++) if (!(i in value) || !isStructurallySafe(value[i], seen)) return false;
|
|
72
|
+
seen.delete(value);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (!isPlainObject(value)) return false;
|
|
76
|
+
for (const key of Object.keys(value)) if (DANGEROUS_KEYS.has(key) || !isStructurallySafe(value[key], seen)) return false;
|
|
77
|
+
seen.delete(value);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
function resolveOptions(options = {}) {
|
|
81
|
+
if (!isPlainObject(options)) throw new TypeError("Molecule options must be a plain object");
|
|
82
|
+
for (const key of Object.keys(options)) if (!OPTION_KEYS.has(key) || DANGEROUS_KEYS.has(key)) throw new TypeError("Unknown molecule option");
|
|
83
|
+
const resolved = {
|
|
84
|
+
...DEFAULTS,
|
|
85
|
+
...options
|
|
86
|
+
};
|
|
87
|
+
const boundedInteger = (name, min, max) => {
|
|
88
|
+
const value = resolved[name];
|
|
89
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new TypeError(`${name} is out of range`);
|
|
90
|
+
};
|
|
91
|
+
boundedInteger("width", 160, 1200);
|
|
92
|
+
boundedInteger("height", 160, 900);
|
|
93
|
+
boundedInteger("maxAtoms", 1, MAX_ATOMS);
|
|
94
|
+
boundedInteger("maxBonds", 0, MAX_BONDS);
|
|
95
|
+
boundedInteger("maxSourceBytes", 1, MAX_SOURCE_BYTES);
|
|
96
|
+
if (typeof resolved.enable3D !== "boolean") throw new TypeError("enable3D must be boolean");
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
99
|
+
function validateShape(value, options) {
|
|
100
|
+
if (!isStructurallySafe(value) || !isPlainObject(value)) return null;
|
|
101
|
+
const keys = Object.keys(value);
|
|
102
|
+
if (keys.some((key) => !DEFINITION_KEYS.has(key) || DANGEROUS_KEYS.has(key))) return null;
|
|
103
|
+
if (value.version !== 1 || value.format !== "smiles" && value.format !== "molfile") return null;
|
|
104
|
+
if (typeof value.source !== "string" || encoder.encode(value.source).byteLength > options.maxSourceBytes) return null;
|
|
105
|
+
if (value.view !== "2d" && value.view !== "3d") return null;
|
|
106
|
+
if (value.atomLabels !== void 0 && value.atomLabels !== "standard" && value.atomLabels !== "all") return null;
|
|
107
|
+
if (value.highlight !== void 0) {
|
|
108
|
+
if (!isPlainObject(value.highlight)) return null;
|
|
109
|
+
if (Object.keys(value.highlight).some((key) => !HIGHLIGHT_KEYS.has(key) || DANGEROUS_KEYS.has(key))) return null;
|
|
110
|
+
if (value.highlight.atoms !== void 0 && !isDenseUniqueIndexes(value.highlight.atoms)) return null;
|
|
111
|
+
if (value.highlight.bonds !== void 0 && !isDenseUniqueIndexes(value.highlight.bonds)) return null;
|
|
112
|
+
}
|
|
113
|
+
if (value.view === "2d") {
|
|
114
|
+
if (value.style !== void 0) return null;
|
|
115
|
+
} else {
|
|
116
|
+
if (value.format !== "molfile" || !options.enable3D) return null;
|
|
117
|
+
if (value.style !== void 0 && value.style !== "ball-and-stick" && value.style !== "space-filling") return null;
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
async function validateChemistry(definition, options) {
|
|
122
|
+
try {
|
|
123
|
+
const OCL = await loadOpenChemLib();
|
|
124
|
+
const parsed = definition.format === "smiles" ? OCL.Molecule.fromSmiles(definition.source) : OCL.Molecule.fromMolfile(definition.source);
|
|
125
|
+
const atomCount = parsed.getAllAtoms();
|
|
126
|
+
const bondCount = parsed.getAllBonds();
|
|
127
|
+
if (!Number.isSafeInteger(atomCount) || atomCount < 1 || atomCount > options.maxAtoms) return null;
|
|
128
|
+
if (!Number.isSafeInteger(bondCount) || bondCount < 0 || bondCount > options.maxBonds) return null;
|
|
129
|
+
if (definition.highlight?.atoms?.some((index) => index >= atomCount)) return null;
|
|
130
|
+
if (definition.highlight?.bonds?.some((index) => index >= bondCount)) return null;
|
|
131
|
+
if (definition.view === "3d") {
|
|
132
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
133
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
134
|
+
for (let atom = 0; atom < atomCount; atom++) {
|
|
135
|
+
const x = parsed.getAtomX(atom);
|
|
136
|
+
const y = parsed.getAtomY(atom);
|
|
137
|
+
const z = parsed.getAtomZ(atom);
|
|
138
|
+
if (![
|
|
139
|
+
x,
|
|
140
|
+
y,
|
|
141
|
+
z
|
|
142
|
+
].every(Number.isFinite)) return null;
|
|
143
|
+
minZ = Math.min(minZ, z);
|
|
144
|
+
maxZ = Math.max(maxZ, z);
|
|
145
|
+
}
|
|
146
|
+
if (maxZ - minZ <= 1e-6) return null;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
definition,
|
|
150
|
+
molecule: parsed,
|
|
151
|
+
atomCount,
|
|
152
|
+
bondCount
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function validateMoleculeDefinition(value, options = {}) {
|
|
159
|
+
let resolved;
|
|
160
|
+
try {
|
|
161
|
+
resolved = resolveOptions(options);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new TypeError("Invalid molecule options");
|
|
164
|
+
}
|
|
165
|
+
const definition = validateShape(value, resolved);
|
|
166
|
+
if (!definition) return invalid();
|
|
167
|
+
const chemistry = await validateChemistry(definition, resolved);
|
|
168
|
+
return chemistry ? {
|
|
169
|
+
ok: true,
|
|
170
|
+
value: chemistry.definition
|
|
171
|
+
} : invalid();
|
|
172
|
+
}
|
|
173
|
+
async function parseMoleculeDefinition(source, options = {}) {
|
|
174
|
+
if (typeof source !== "string") return invalid();
|
|
175
|
+
let value;
|
|
176
|
+
try {
|
|
177
|
+
value = JSON.parse(source);
|
|
178
|
+
} catch {
|
|
179
|
+
return invalid();
|
|
180
|
+
}
|
|
181
|
+
return validateMoleculeDefinition(value, options);
|
|
182
|
+
}
|
|
183
|
+
function loadingOutput() {
|
|
184
|
+
return {
|
|
185
|
+
kind: "html",
|
|
186
|
+
html: "<div data-aigui-molecule-loading aria-label=\"Loading molecule\"></div>"
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function errorOutput() {
|
|
190
|
+
return {
|
|
191
|
+
kind: "html",
|
|
192
|
+
html: "<div data-aigui-molecule-error role=\"img\" aria-label=\"Molecule could not be rendered.\">Molecule could not be rendered.</div>"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const SAFE_SVG_ELEMENTS = new Set([
|
|
196
|
+
"svg",
|
|
197
|
+
"g",
|
|
198
|
+
"path",
|
|
199
|
+
"line",
|
|
200
|
+
"polyline",
|
|
201
|
+
"polygon",
|
|
202
|
+
"circle",
|
|
203
|
+
"ellipse",
|
|
204
|
+
"rect",
|
|
205
|
+
"text",
|
|
206
|
+
"tspan",
|
|
207
|
+
"defs",
|
|
208
|
+
"clipPath"
|
|
209
|
+
]);
|
|
210
|
+
const SAFE_SVG_ATTRIBUTES = new Set([
|
|
211
|
+
"viewBox",
|
|
212
|
+
"width",
|
|
213
|
+
"height",
|
|
214
|
+
"x",
|
|
215
|
+
"y",
|
|
216
|
+
"x1",
|
|
217
|
+
"x2",
|
|
218
|
+
"y1",
|
|
219
|
+
"y2",
|
|
220
|
+
"cx",
|
|
221
|
+
"cy",
|
|
222
|
+
"r",
|
|
223
|
+
"rx",
|
|
224
|
+
"ry",
|
|
225
|
+
"d",
|
|
226
|
+
"points",
|
|
227
|
+
"fill",
|
|
228
|
+
"fill-opacity",
|
|
229
|
+
"stroke",
|
|
230
|
+
"stroke-width",
|
|
231
|
+
"stroke-linecap",
|
|
232
|
+
"stroke-linejoin",
|
|
233
|
+
"stroke-opacity",
|
|
234
|
+
"font-family",
|
|
235
|
+
"font-size",
|
|
236
|
+
"font-weight",
|
|
237
|
+
"text-anchor",
|
|
238
|
+
"dominant-baseline",
|
|
239
|
+
"transform",
|
|
240
|
+
"clip-path",
|
|
241
|
+
"id",
|
|
242
|
+
"class",
|
|
243
|
+
"style",
|
|
244
|
+
"xmlns"
|
|
245
|
+
]);
|
|
246
|
+
function safeSvgElement(svgSource, width, height) {
|
|
247
|
+
const parsed = new DOMParser().parseFromString(svgSource, "image/svg+xml");
|
|
248
|
+
if (parsed.querySelector("parsererror") || parsed.documentElement.localName !== "svg") return null;
|
|
249
|
+
for (const element of Array.from(parsed.querySelectorAll("*"))) {
|
|
250
|
+
if (!SAFE_SVG_ELEMENTS.has(element.localName)) {
|
|
251
|
+
element.remove();
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
255
|
+
const value = attribute.value;
|
|
256
|
+
if (!SAFE_SVG_ATTRIBUTES.has(attribute.name) || /^on/i.test(attribute.name) || /url\s*\(|javascript:|data:/i.test(value)) element.removeAttribute(attribute.name);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const svg = document.importNode(parsed.documentElement, true);
|
|
260
|
+
svg.setAttribute("width", "100%");
|
|
261
|
+
svg.removeAttribute("height");
|
|
262
|
+
svg.setAttribute("role", "img");
|
|
263
|
+
svg.setAttribute("aria-label", "Molecular structure");
|
|
264
|
+
svg.setAttribute("style", `display:block;width:100%;height:auto;max-width:${width}px;max-height:${height}px;margin:auto`);
|
|
265
|
+
return svg;
|
|
266
|
+
}
|
|
267
|
+
function mount2D(svgSource, options) {
|
|
268
|
+
return {
|
|
269
|
+
kind: "mount",
|
|
270
|
+
mount(host) {
|
|
271
|
+
host.replaceChildren();
|
|
272
|
+
host.setAttribute("data-aigui-molecule", "2d");
|
|
273
|
+
const svg = safeSvgElement(svgSource, options.width, options.height);
|
|
274
|
+
if (!svg) {
|
|
275
|
+
host.appendChild(moleculeErrorElement());
|
|
276
|
+
return () => host.replaceChildren();
|
|
277
|
+
}
|
|
278
|
+
host.appendChild(svg);
|
|
279
|
+
let disposed = false;
|
|
280
|
+
return () => {
|
|
281
|
+
if (disposed) return;
|
|
282
|
+
disposed = true;
|
|
283
|
+
host.replaceChildren();
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const ELEMENT_COLORS = {
|
|
289
|
+
H: 16777215,
|
|
290
|
+
C: 9474192,
|
|
291
|
+
N: 3166456,
|
|
292
|
+
O: 16715021,
|
|
293
|
+
F: 9494608,
|
|
294
|
+
P: 16744448,
|
|
295
|
+
S: 16777008,
|
|
296
|
+
Cl: 2093087,
|
|
297
|
+
Br: 10889513,
|
|
298
|
+
I: 9699476
|
|
299
|
+
};
|
|
300
|
+
function mount3D(definition, options) {
|
|
301
|
+
return {
|
|
302
|
+
kind: "mount",
|
|
303
|
+
mount(host) {
|
|
304
|
+
let disposed = false;
|
|
305
|
+
let viewer;
|
|
306
|
+
let observer;
|
|
307
|
+
let reset;
|
|
308
|
+
host.replaceChildren();
|
|
309
|
+
host.setAttribute("data-aigui-molecule", "3d");
|
|
310
|
+
host.style.position = "relative";
|
|
311
|
+
host.style.width = "100%";
|
|
312
|
+
host.style.maxWidth = `${options.width}px`;
|
|
313
|
+
host.style.height = `${options.height}px`;
|
|
314
|
+
const viewport = document.createElement("div");
|
|
315
|
+
viewport.style.width = "100%";
|
|
316
|
+
viewport.style.height = "100%";
|
|
317
|
+
reset = document.createElement("button");
|
|
318
|
+
reset.type = "button";
|
|
319
|
+
reset.textContent = "Reset";
|
|
320
|
+
reset.setAttribute("data-aigui-molecule-reset", "");
|
|
321
|
+
const onReset = () => {
|
|
322
|
+
if (disposed || !viewer) return;
|
|
323
|
+
viewer.zoomTo();
|
|
324
|
+
viewer.render();
|
|
325
|
+
};
|
|
326
|
+
reset.addEventListener("click", onReset);
|
|
327
|
+
host.append(viewport, reset);
|
|
328
|
+
import("3dmol").then((module) => {
|
|
329
|
+
if (disposed) return;
|
|
330
|
+
const createViewer = module.createViewer;
|
|
331
|
+
viewer = createViewer(viewport, {
|
|
332
|
+
backgroundColor: "white",
|
|
333
|
+
defaultcolors: ELEMENT_COLORS
|
|
334
|
+
});
|
|
335
|
+
if (disposed) return;
|
|
336
|
+
viewer.addModel(definition.source, "mol");
|
|
337
|
+
const style = definition.style ?? "ball-and-stick";
|
|
338
|
+
viewer.setStyle({}, style === "space-filling" ? { sphere: {
|
|
339
|
+
scale: 1,
|
|
340
|
+
colorscheme: "default"
|
|
341
|
+
} } : {
|
|
342
|
+
stick: {
|
|
343
|
+
radius: .18,
|
|
344
|
+
colorscheme: "default"
|
|
345
|
+
},
|
|
346
|
+
sphere: {
|
|
347
|
+
scale: .28,
|
|
348
|
+
colorscheme: "default"
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
if (definition.highlight?.atoms?.length) viewer.addStyle({ index: definition.highlight.atoms }, { sphere: {
|
|
352
|
+
color: 16761856,
|
|
353
|
+
scale: .48
|
|
354
|
+
} });
|
|
355
|
+
if (definition.highlight?.bonds?.length) validateChemistry(definition, options).then((chemistry) => {
|
|
356
|
+
if (disposed || !viewer || !chemistry) return;
|
|
357
|
+
const atoms = new Set();
|
|
358
|
+
for (const bond of definition.highlight?.bonds ?? []) {
|
|
359
|
+
atoms.add(chemistry.molecule.getBondAtom(0, bond));
|
|
360
|
+
atoms.add(chemistry.molecule.getBondAtom(1, bond));
|
|
361
|
+
}
|
|
362
|
+
viewer.addStyle({ index: [...atoms] }, { stick: {
|
|
363
|
+
color: 16761856,
|
|
364
|
+
radius: .3
|
|
365
|
+
} });
|
|
366
|
+
viewer.render();
|
|
367
|
+
}).catch(() => void 0);
|
|
368
|
+
viewer.zoomTo();
|
|
369
|
+
viewer.render();
|
|
370
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
371
|
+
observer = new ResizeObserver(() => {
|
|
372
|
+
if (!disposed && viewer) viewer.resize();
|
|
373
|
+
});
|
|
374
|
+
observer.observe(host);
|
|
375
|
+
}
|
|
376
|
+
}).catch(() => {
|
|
377
|
+
if (disposed) return;
|
|
378
|
+
host.replaceChildren(moleculeErrorElement());
|
|
379
|
+
});
|
|
380
|
+
return () => {
|
|
381
|
+
if (disposed) return;
|
|
382
|
+
disposed = true;
|
|
383
|
+
reset?.removeEventListener("click", onReset);
|
|
384
|
+
observer?.disconnect();
|
|
385
|
+
if (viewer) {
|
|
386
|
+
let canvas;
|
|
387
|
+
try {
|
|
388
|
+
canvas = viewer.getCanvas();
|
|
389
|
+
} catch {
|
|
390
|
+
canvas = void 0;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
viewer.removeAllLabels();
|
|
394
|
+
} catch {}
|
|
395
|
+
try {
|
|
396
|
+
viewer.removeAllShapes();
|
|
397
|
+
} catch {}
|
|
398
|
+
try {
|
|
399
|
+
viewer.removeAllModels();
|
|
400
|
+
} catch {}
|
|
401
|
+
try {
|
|
402
|
+
viewer.clear();
|
|
403
|
+
} catch {}
|
|
404
|
+
try {
|
|
405
|
+
const gl = canvas?.getContext("webgl2") ?? canvas?.getContext("webgl");
|
|
406
|
+
gl?.getExtension("WEBGL_lose_context")?.loseContext();
|
|
407
|
+
} catch {}
|
|
408
|
+
}
|
|
409
|
+
viewer = void 0;
|
|
410
|
+
host.replaceChildren();
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
async function createOutput(node, options) {
|
|
416
|
+
if (node.complete !== true) return loadingOutput();
|
|
417
|
+
let value;
|
|
418
|
+
try {
|
|
419
|
+
value = JSON.parse(node.content ?? "");
|
|
420
|
+
} catch {
|
|
421
|
+
return errorOutput();
|
|
422
|
+
}
|
|
423
|
+
const definition = validateShape(value, options);
|
|
424
|
+
if (!definition) return errorOutput();
|
|
425
|
+
const chemistry = await validateChemistry(definition, options);
|
|
426
|
+
if (!chemistry) return errorOutput();
|
|
427
|
+
if (definition.view === "3d") return mount3D(definition, options);
|
|
428
|
+
try {
|
|
429
|
+
for (const atom of definition.highlight?.atoms ?? []) chemistry.molecule.setAtomSelection(atom, true);
|
|
430
|
+
for (const bond of definition.highlight?.bonds ?? []) {
|
|
431
|
+
chemistry.molecule.setAtomSelection(chemistry.molecule.getBondAtom(0, bond), true);
|
|
432
|
+
chemistry.molecule.setAtomSelection(chemistry.molecule.getBondAtom(1, bond), true);
|
|
433
|
+
}
|
|
434
|
+
const svg = chemistry.molecule.toSVG(options.width, options.height, void 0, {
|
|
435
|
+
showAtomNumber: definition.atomLabels === "all",
|
|
436
|
+
autoCrop: false
|
|
437
|
+
});
|
|
438
|
+
return mount2D(svg, options);
|
|
439
|
+
} catch {
|
|
440
|
+
return errorOutput();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function moleculePromptSpec(options = {}) {
|
|
444
|
+
const resolved = resolveOptions(options);
|
|
445
|
+
return [
|
|
446
|
+
"Molecules (one complete-gated fenced block): ```molecule <strict JSON>```.",
|
|
447
|
+
"Exact root fields: {\"version\":1,\"format\":\"smiles|molfile\",\"source\":\"...\",\"view\":\"2d|3d\",\"style\":\"ball-and-stick|space-filling\"?,\"atomLabels\":\"standard|all\"?,\"highlight\":{\"atoms\":[0],\"bonds\":[0]}?}. No unknown fields.",
|
|
448
|
+
"SMILES supports 2d only. Molfile supports 2d and 3d; 3d requires genuine finite non-flat z coordinates.",
|
|
449
|
+
`Source is local text only and must be at most ${resolved.maxSourceBytes} UTF-8 bytes. Atom and bond indexes are zero-based unique nonnegative integers.`,
|
|
450
|
+
"Never emit URLs, scripts, network requests, remote resources, HTML, credentials, download/get/autoload/fetch instructions, or executable content."
|
|
451
|
+
].join("\n");
|
|
452
|
+
}
|
|
453
|
+
const moleculeCss = `
|
|
454
|
+
[data-aigui-molecule]{box-sizing:border-box;max-width:100%;overflow:hidden}
|
|
455
|
+
[data-aigui-molecule="2d"] svg{display:block;max-width:100%;height:auto}
|
|
456
|
+
[data-aigui-molecule="3d"] canvas{display:block;max-width:100%}
|
|
457
|
+
[data-aigui-molecule-reset]{position:absolute;right:.5rem;bottom:.5rem;z-index:1;padding:.35rem .65rem;border:1px solid currentColor;border-radius:.35rem;background:Canvas;color:CanvasText;cursor:pointer}
|
|
458
|
+
[data-aigui-molecule-loading]{min-height:10rem;border-radius:.5rem;background:linear-gradient(90deg,transparent,rgba(127,127,127,.12),transparent);background-size:200% 100%;animation:aigui-molecule-loading 1.2s linear infinite}
|
|
459
|
+
[data-aigui-molecule-error]{padding:1rem;border:1px solid currentColor;border-radius:.5rem}
|
|
460
|
+
@keyframes aigui-molecule-loading{to{background-position:-200% 0}}
|
|
461
|
+
`.trim();
|
|
462
|
+
function molecule(options = {}) {
|
|
463
|
+
const resolved = resolveOptions(options);
|
|
464
|
+
const outputs = new WeakMap();
|
|
465
|
+
const render = (node) => {
|
|
466
|
+
const cached = outputs.get(node);
|
|
467
|
+
if (cached) return cached;
|
|
468
|
+
const output = createOutput(node, resolved).catch(() => errorOutput());
|
|
469
|
+
outputs.set(node, output);
|
|
470
|
+
return output;
|
|
471
|
+
};
|
|
472
|
+
return {
|
|
473
|
+
name: "molecule",
|
|
474
|
+
nodeRenderers: { molecule: render },
|
|
475
|
+
promptSpec: moleculePromptSpec(resolved)
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function moleculeErrorElement() {
|
|
479
|
+
const error = document.createElement("div");
|
|
480
|
+
error.setAttribute("data-aigui-molecule-error", "");
|
|
481
|
+
error.setAttribute("role", "img");
|
|
482
|
+
error.setAttribute("aria-label", "Molecule could not be rendered.");
|
|
483
|
+
error.textContent = "Molecule could not be rendered.";
|
|
484
|
+
return error;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
//#endregion
|
|
488
|
+
export { molecule, moleculeCss, moleculePromptSpec, parseMoleculeDefinition, validateMoleculeDefinition };
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-gui/plugin-molecule",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Safe 2D and 3D molecular structure rendering plugin for AIGUI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"llm",
|
|
7
|
+
"ai",
|
|
8
|
+
"chemistry",
|
|
9
|
+
"molecule",
|
|
10
|
+
"smiles",
|
|
11
|
+
"molfile",
|
|
12
|
+
"3d",
|
|
13
|
+
"plugin",
|
|
14
|
+
"aigui"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Liang Li <ll_faw@hotmail.com>",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/liliang-cn/aigui.git",
|
|
21
|
+
"directory": "packages/plugin-molecule"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/liliang-cn/aigui#readme",
|
|
24
|
+
"bugs": "https://github.com/liliang-cn/aigui/issues",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"import": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"require": {
|
|
40
|
+
"types": "./dist/index.d.cts",
|
|
41
|
+
"default": "./dist/index.cjs"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"CHANGELOG.md"
|
|
50
|
+
],
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"openchemlib": "^9.24.0",
|
|
56
|
+
"3dmol": "^2.5.5",
|
|
57
|
+
"@ai-gui/core": "0.4.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsdown",
|
|
61
|
+
"test": "pnpm --dir ../.. exec vitest run --project plugin-molecule",
|
|
62
|
+
"typecheck": "tsc --noEmit"
|
|
63
|
+
}
|
|
64
|
+
}
|