@emblemvault/hustle-react 1.1.0 → 1.1.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/dist/browser/hustle-react.js +602 -75
- package/dist/browser/hustle-react.js.map +1 -1
- package/dist/components/index.cjs +553 -75
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +553 -75
- package/dist/components/index.js.map +1 -1
- package/dist/hooks/index.cjs +80 -14
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +3 -2
- package/dist/hooks/index.d.ts +3 -2
- package/dist/hooks/index.js +80 -14
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +602 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +602 -75
- package/dist/index.js.map +1 -1
- package/dist/{plugin-BUg7vMxe.d.cts → plugin-COr42J6-.d.cts} +3 -1
- package/dist/{plugin-BUg7vMxe.d.ts → plugin-COr42J6-.d.ts} +3 -1
- package/dist/plugins/index.cjs +522 -61
- package/dist/plugins/index.cjs.map +1 -1
- package/dist/plugins/index.d.cts +1 -1
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +522 -61
- package/dist/plugins/index.js.map +1 -1
- package/dist/providers/index.cjs +80 -14
- package/dist/providers/index.cjs.map +1 -1
- package/dist/providers/index.js +80 -14
- package/dist/providers/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -302,35 +302,52 @@ var pluginRegistry = new PluginRegistry();
|
|
|
302
302
|
function getStorageKey(instanceId) {
|
|
303
303
|
return `hustle-plugins-${instanceId}`;
|
|
304
304
|
}
|
|
305
|
-
function
|
|
305
|
+
function getInstanceId(providedId) {
|
|
306
|
+
if (providedId) return providedId;
|
|
307
|
+
if (typeof window !== "undefined") {
|
|
308
|
+
const globalId = window.__hustleInstanceId;
|
|
309
|
+
if (globalId) return globalId;
|
|
310
|
+
}
|
|
311
|
+
return "default";
|
|
312
|
+
}
|
|
313
|
+
function usePlugins(instanceId) {
|
|
314
|
+
const [resolvedInstanceId] = react.useState(() => getInstanceId(instanceId));
|
|
306
315
|
const [plugins, setPlugins] = react.useState([]);
|
|
307
316
|
react.useEffect(() => {
|
|
308
|
-
setPlugins(pluginRegistry.loadFromStorage(
|
|
309
|
-
const unsubscribe = pluginRegistry.onChange(setPlugins,
|
|
310
|
-
const storageKey = getStorageKey(
|
|
317
|
+
setPlugins(pluginRegistry.loadFromStorage(resolvedInstanceId));
|
|
318
|
+
const unsubscribe = pluginRegistry.onChange(setPlugins, resolvedInstanceId);
|
|
319
|
+
const storageKey = getStorageKey(resolvedInstanceId);
|
|
311
320
|
const handleStorage = (e) => {
|
|
312
321
|
if (e.key === storageKey) {
|
|
313
|
-
setPlugins(pluginRegistry.loadFromStorage(
|
|
322
|
+
setPlugins(pluginRegistry.loadFromStorage(resolvedInstanceId));
|
|
314
323
|
}
|
|
315
324
|
};
|
|
316
325
|
window.addEventListener("storage", handleStorage);
|
|
326
|
+
const handlePluginInstalled = (e) => {
|
|
327
|
+
const customEvent = e;
|
|
328
|
+
if (customEvent.detail.instanceId === resolvedInstanceId) {
|
|
329
|
+
setPlugins(pluginRegistry.loadFromStorage(resolvedInstanceId));
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
window.addEventListener("hustle-plugin-installed", handlePluginInstalled);
|
|
317
333
|
return () => {
|
|
318
334
|
unsubscribe();
|
|
319
335
|
window.removeEventListener("storage", handleStorage);
|
|
336
|
+
window.removeEventListener("hustle-plugin-installed", handlePluginInstalled);
|
|
320
337
|
};
|
|
321
|
-
}, [
|
|
338
|
+
}, [resolvedInstanceId]);
|
|
322
339
|
const registerPlugin = react.useCallback((plugin) => {
|
|
323
|
-
pluginRegistry.register(plugin, true,
|
|
324
|
-
}, [
|
|
340
|
+
pluginRegistry.register(plugin, true, resolvedInstanceId);
|
|
341
|
+
}, [resolvedInstanceId]);
|
|
325
342
|
const unregisterPlugin = react.useCallback((name) => {
|
|
326
|
-
pluginRegistry.unregister(name,
|
|
327
|
-
}, [
|
|
343
|
+
pluginRegistry.unregister(name, resolvedInstanceId);
|
|
344
|
+
}, [resolvedInstanceId]);
|
|
328
345
|
const enablePlugin = react.useCallback((name) => {
|
|
329
|
-
pluginRegistry.setEnabled(name, true,
|
|
330
|
-
}, [
|
|
346
|
+
pluginRegistry.setEnabled(name, true, resolvedInstanceId);
|
|
347
|
+
}, [resolvedInstanceId]);
|
|
331
348
|
const disablePlugin = react.useCallback((name) => {
|
|
332
|
-
pluginRegistry.setEnabled(name, false,
|
|
333
|
-
}, [
|
|
349
|
+
pluginRegistry.setEnabled(name, false, resolvedInstanceId);
|
|
350
|
+
}, [resolvedInstanceId]);
|
|
334
351
|
const isRegistered = react.useCallback(
|
|
335
352
|
(name) => plugins.some((p) => p.name === name),
|
|
336
353
|
[plugins]
|
|
@@ -1038,12 +1055,16 @@ function ensureModalStyles() {
|
|
|
1038
1055
|
left: 0;
|
|
1039
1056
|
right: 0;
|
|
1040
1057
|
bottom: 0;
|
|
1041
|
-
background:
|
|
1058
|
+
background: transparent;
|
|
1042
1059
|
display: flex;
|
|
1043
1060
|
align-items: center;
|
|
1044
1061
|
justify-content: center;
|
|
1045
1062
|
z-index: 10000;
|
|
1046
|
-
|
|
1063
|
+
pointer-events: none;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.user-question-modal {
|
|
1067
|
+
pointer-events: auto;
|
|
1047
1068
|
}
|
|
1048
1069
|
|
|
1049
1070
|
@keyframes uqFadeIn {
|
|
@@ -1156,6 +1177,27 @@ function ensureModalStyles() {
|
|
|
1156
1177
|
color: #666;
|
|
1157
1178
|
cursor: not-allowed;
|
|
1158
1179
|
}
|
|
1180
|
+
|
|
1181
|
+
.user-question-custom-input {
|
|
1182
|
+
width: 100%;
|
|
1183
|
+
padding: 8px 12px;
|
|
1184
|
+
margin-top: 8px;
|
|
1185
|
+
background: #1a1a2e;
|
|
1186
|
+
border: 1px solid #444;
|
|
1187
|
+
border-radius: 6px;
|
|
1188
|
+
color: #e0e0e0;
|
|
1189
|
+
font-size: 14px;
|
|
1190
|
+
box-sizing: border-box;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
.user-question-custom-input:focus {
|
|
1194
|
+
outline: none;
|
|
1195
|
+
border-color: #4a7aff;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
.user-question-custom-input::placeholder {
|
|
1199
|
+
color: #666;
|
|
1200
|
+
}
|
|
1159
1201
|
`;
|
|
1160
1202
|
document.head.appendChild(styles2);
|
|
1161
1203
|
}
|
|
@@ -1177,13 +1219,17 @@ var askUserTool = {
|
|
|
1177
1219
|
allowMultiple: {
|
|
1178
1220
|
type: "boolean",
|
|
1179
1221
|
description: "If true, user can select multiple choices. Default: false"
|
|
1222
|
+
},
|
|
1223
|
+
allowCustom: {
|
|
1224
|
+
type: "boolean",
|
|
1225
|
+
description: 'If true, adds an "Other" option where user can type a custom response. Default: false'
|
|
1180
1226
|
}
|
|
1181
1227
|
},
|
|
1182
1228
|
required: ["question", "choices"]
|
|
1183
1229
|
}
|
|
1184
1230
|
};
|
|
1185
1231
|
var askUserExecutor = async (args2) => {
|
|
1186
|
-
const { question, choices, allowMultiple = false } = args2;
|
|
1232
|
+
const { question, choices, allowMultiple = false, allowCustom = false } = args2;
|
|
1187
1233
|
if (!question || !choices || !Array.isArray(choices) || choices.length === 0) {
|
|
1188
1234
|
return {
|
|
1189
1235
|
question: question || "",
|
|
@@ -1215,6 +1261,17 @@ var askUserExecutor = async (args2) => {
|
|
|
1215
1261
|
choicesDiv.className = "user-question-choices";
|
|
1216
1262
|
const inputType = allowMultiple ? "checkbox" : "radio";
|
|
1217
1263
|
const inputName = `uq-${Date.now()}`;
|
|
1264
|
+
let customInput = null;
|
|
1265
|
+
let isCustomSelected = false;
|
|
1266
|
+
const submitBtn = document.createElement("button");
|
|
1267
|
+
submitBtn.className = "user-question-btn user-question-btn-submit";
|
|
1268
|
+
submitBtn.textContent = "Submit";
|
|
1269
|
+
submitBtn.disabled = true;
|
|
1270
|
+
const updateSubmitButton = () => {
|
|
1271
|
+
const hasSelection = selected.size > 0;
|
|
1272
|
+
const hasCustomValue = isCustomSelected && customInput && customInput.value.trim().length > 0;
|
|
1273
|
+
submitBtn.disabled = !hasSelection && !hasCustomValue;
|
|
1274
|
+
};
|
|
1218
1275
|
choices.forEach((choice, index) => {
|
|
1219
1276
|
const choiceDiv = document.createElement("div");
|
|
1220
1277
|
choiceDiv.className = "user-question-choice";
|
|
@@ -1239,6 +1296,8 @@ var askUserExecutor = async (args2) => {
|
|
|
1239
1296
|
}
|
|
1240
1297
|
} else {
|
|
1241
1298
|
selected.clear();
|
|
1299
|
+
isCustomSelected = false;
|
|
1300
|
+
if (customInput) customInput.value = "";
|
|
1242
1301
|
selected.add(choice);
|
|
1243
1302
|
choicesDiv.querySelectorAll(".user-question-choice").forEach((c) => c.classList.remove("selected"));
|
|
1244
1303
|
choiceDiv.classList.add("selected");
|
|
@@ -1254,9 +1313,62 @@ var askUserExecutor = async (args2) => {
|
|
|
1254
1313
|
});
|
|
1255
1314
|
choicesDiv.appendChild(choiceDiv);
|
|
1256
1315
|
});
|
|
1316
|
+
if (allowCustom) {
|
|
1317
|
+
const customChoiceDiv = document.createElement("div");
|
|
1318
|
+
customChoiceDiv.className = "user-question-choice";
|
|
1319
|
+
const customRadio = document.createElement("input");
|
|
1320
|
+
customRadio.type = inputType;
|
|
1321
|
+
customRadio.name = inputName;
|
|
1322
|
+
customRadio.id = `${inputName}-custom`;
|
|
1323
|
+
customRadio.value = "__custom__";
|
|
1324
|
+
const customLabel = document.createElement("label");
|
|
1325
|
+
customLabel.htmlFor = customRadio.id;
|
|
1326
|
+
customLabel.textContent = "Other:";
|
|
1327
|
+
customLabel.style.flexShrink = "0";
|
|
1328
|
+
customInput = document.createElement("input");
|
|
1329
|
+
customInput.type = "text";
|
|
1330
|
+
customInput.className = "user-question-custom-input";
|
|
1331
|
+
customInput.placeholder = "Type your answer...";
|
|
1332
|
+
customInput.style.marginTop = "0";
|
|
1333
|
+
customInput.style.marginLeft = "8px";
|
|
1334
|
+
customInput.style.flex = "1";
|
|
1335
|
+
customChoiceDiv.appendChild(customRadio);
|
|
1336
|
+
customChoiceDiv.appendChild(customLabel);
|
|
1337
|
+
customChoiceDiv.appendChild(customInput);
|
|
1338
|
+
const handleCustomSelect = () => {
|
|
1339
|
+
if (!allowMultiple) {
|
|
1340
|
+
selected.clear();
|
|
1341
|
+
choicesDiv.querySelectorAll(".user-question-choice").forEach((c) => c.classList.remove("selected"));
|
|
1342
|
+
}
|
|
1343
|
+
isCustomSelected = true;
|
|
1344
|
+
customChoiceDiv.classList.add("selected");
|
|
1345
|
+
customInput?.focus();
|
|
1346
|
+
updateSubmitButton();
|
|
1347
|
+
};
|
|
1348
|
+
customRadio.addEventListener("change", handleCustomSelect);
|
|
1349
|
+
customChoiceDiv.addEventListener("click", (e) => {
|
|
1350
|
+
if (e.target !== customRadio && e.target !== customInput) {
|
|
1351
|
+
customRadio.checked = true;
|
|
1352
|
+
handleCustomSelect();
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
customInput.addEventListener("focus", () => {
|
|
1356
|
+
if (!customRadio.checked) {
|
|
1357
|
+
customRadio.checked = true;
|
|
1358
|
+
handleCustomSelect();
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
customInput.addEventListener("input", updateSubmitButton);
|
|
1362
|
+
choicesDiv.appendChild(customChoiceDiv);
|
|
1363
|
+
}
|
|
1257
1364
|
modal.appendChild(choicesDiv);
|
|
1258
1365
|
const actions = document.createElement("div");
|
|
1259
1366
|
actions.className = "user-question-actions";
|
|
1367
|
+
overlay.appendChild(modal);
|
|
1368
|
+
document.body.appendChild(overlay);
|
|
1369
|
+
const cleanup = () => {
|
|
1370
|
+
overlay.remove();
|
|
1371
|
+
};
|
|
1260
1372
|
const cancelBtn = document.createElement("button");
|
|
1261
1373
|
cancelBtn.className = "user-question-btn user-question-btn-cancel";
|
|
1262
1374
|
cancelBtn.textContent = "Skip";
|
|
@@ -1268,29 +1380,21 @@ var askUserExecutor = async (args2) => {
|
|
|
1268
1380
|
answered: false
|
|
1269
1381
|
});
|
|
1270
1382
|
};
|
|
1271
|
-
const submitBtn = document.createElement("button");
|
|
1272
|
-
submitBtn.className = "user-question-btn user-question-btn-submit";
|
|
1273
|
-
submitBtn.textContent = "Submit";
|
|
1274
|
-
submitBtn.disabled = true;
|
|
1275
1383
|
submitBtn.onclick = () => {
|
|
1276
1384
|
cleanup();
|
|
1385
|
+
const results = Array.from(selected);
|
|
1386
|
+
if (isCustomSelected && customInput && customInput.value.trim()) {
|
|
1387
|
+
results.push(customInput.value.trim());
|
|
1388
|
+
}
|
|
1277
1389
|
resolve({
|
|
1278
1390
|
question,
|
|
1279
|
-
selectedChoices:
|
|
1391
|
+
selectedChoices: results,
|
|
1280
1392
|
answered: true
|
|
1281
1393
|
});
|
|
1282
1394
|
};
|
|
1283
|
-
const updateSubmitButton = () => {
|
|
1284
|
-
submitBtn.disabled = selected.size === 0;
|
|
1285
|
-
};
|
|
1286
1395
|
actions.appendChild(cancelBtn);
|
|
1287
1396
|
actions.appendChild(submitBtn);
|
|
1288
1397
|
modal.appendChild(actions);
|
|
1289
|
-
overlay.appendChild(modal);
|
|
1290
|
-
document.body.appendChild(overlay);
|
|
1291
|
-
const cleanup = () => {
|
|
1292
|
-
overlay.remove();
|
|
1293
|
-
};
|
|
1294
1398
|
const handleEscape = (e) => {
|
|
1295
1399
|
if (e.key === "Escape") {
|
|
1296
1400
|
document.removeEventListener("keydown", handleEscape);
|
|
@@ -1459,6 +1563,11 @@ var screenshotTool = {
|
|
|
1459
1563
|
|
|
1460
1564
|
The screenshot captures the visible viewport of the page. The image is uploaded to the server and a permanent URL is returned.
|
|
1461
1565
|
|
|
1566
|
+
IMPORTANT: Before taking a screenshot, use the ask_user tool (if available) to ask which size they prefer:
|
|
1567
|
+
- "full" (100%) - highest quality, larger file
|
|
1568
|
+
- "half" (50%) - good balance of quality and size
|
|
1569
|
+
- "quarter" (25%) - smallest file, faster upload
|
|
1570
|
+
|
|
1462
1571
|
Use this when:
|
|
1463
1572
|
- User asks to see what's on their screen
|
|
1464
1573
|
- You need to analyze the current page visually
|
|
@@ -1469,12 +1578,24 @@ Use this when:
|
|
|
1469
1578
|
selector: {
|
|
1470
1579
|
type: "string",
|
|
1471
1580
|
description: "Optional CSS selector to capture a specific element instead of the full page. Leave empty for full page screenshot."
|
|
1581
|
+
},
|
|
1582
|
+
size: {
|
|
1583
|
+
type: "string",
|
|
1584
|
+
enum: ["full", "half", "quarter"],
|
|
1585
|
+
description: 'Image size: "full" (100%), "half" (50%), or "quarter" (25%). Ask the user which size they prefer before capturing.'
|
|
1472
1586
|
}
|
|
1473
1587
|
}
|
|
1474
1588
|
}
|
|
1475
1589
|
};
|
|
1476
1590
|
var screenshotExecutor = async (args2) => {
|
|
1477
1591
|
const selector = args2.selector;
|
|
1592
|
+
const size = args2.size || "full";
|
|
1593
|
+
const scaleMap = {
|
|
1594
|
+
full: 1,
|
|
1595
|
+
half: 0.5,
|
|
1596
|
+
quarter: 0.25
|
|
1597
|
+
};
|
|
1598
|
+
const scale = scaleMap[size] || 1;
|
|
1478
1599
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1479
1600
|
return {
|
|
1480
1601
|
success: false,
|
|
@@ -1495,34 +1616,41 @@ var screenshotExecutor = async (args2) => {
|
|
|
1495
1616
|
if (!target) {
|
|
1496
1617
|
return { success: false, error: "Element not found: " + selector };
|
|
1497
1618
|
}
|
|
1498
|
-
const
|
|
1619
|
+
const fullCanvas = await window.html2canvas(target, {
|
|
1499
1620
|
useCORS: true,
|
|
1500
1621
|
allowTaint: true,
|
|
1501
1622
|
backgroundColor: "#000000",
|
|
1502
1623
|
scale: 1
|
|
1503
1624
|
});
|
|
1625
|
+
let finalCanvas = fullCanvas;
|
|
1626
|
+
if (scale < 1) {
|
|
1627
|
+
const resizedCanvas = document.createElement("canvas");
|
|
1628
|
+
resizedCanvas.width = Math.round(fullCanvas.width * scale);
|
|
1629
|
+
resizedCanvas.height = Math.round(fullCanvas.height * scale);
|
|
1630
|
+
const ctx = resizedCanvas.getContext("2d");
|
|
1631
|
+
if (ctx) {
|
|
1632
|
+
ctx.drawImage(fullCanvas, 0, 0, resizedCanvas.width, resizedCanvas.height);
|
|
1633
|
+
finalCanvas = resizedCanvas;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1504
1636
|
const blob = await new Promise((resolve) => {
|
|
1505
|
-
|
|
1637
|
+
finalCanvas.toBlob(resolve, "image/png", 0.9);
|
|
1506
1638
|
});
|
|
1507
1639
|
if (!blob) {
|
|
1508
1640
|
return { success: false, error: "Failed to create image blob" };
|
|
1509
1641
|
}
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
method: "POST",
|
|
1514
|
-
body: formData
|
|
1515
|
-
});
|
|
1516
|
-
if (!response.ok) {
|
|
1517
|
-
const errorData = await response.json().catch(() => ({}));
|
|
1518
|
-
return { success: false, error: errorData.error || "Upload failed" };
|
|
1642
|
+
const uploadFn = window.__hustleUploadFile;
|
|
1643
|
+
if (!uploadFn) {
|
|
1644
|
+
return { success: false, error: "Upload not available. Make sure HustleProvider is mounted and client is ready." };
|
|
1519
1645
|
}
|
|
1520
|
-
const
|
|
1646
|
+
const fileName = "screenshot-" + Date.now() + ".png";
|
|
1647
|
+
const attachment = await uploadFn(blob, fileName);
|
|
1648
|
+
const sizeLabel = size === "full" ? "100%" : size === "half" ? "50%" : "25%";
|
|
1521
1649
|
return {
|
|
1522
1650
|
success: true,
|
|
1523
|
-
url:
|
|
1524
|
-
contentType:
|
|
1525
|
-
message:
|
|
1651
|
+
url: attachment.url,
|
|
1652
|
+
contentType: attachment.contentType || "image/png",
|
|
1653
|
+
message: `Screenshot captured at ${sizeLabel} size (${finalCanvas.width}x${finalCanvas.height}) and uploaded successfully`
|
|
1526
1654
|
};
|
|
1527
1655
|
} catch (e) {
|
|
1528
1656
|
const err = e;
|
|
@@ -1590,21 +1718,89 @@ Write them as arrow function strings that will be eval'd:
|
|
|
1590
1718
|
"async (args) => { const { city } = args; return { weather: 'sunny', city }; }"
|
|
1591
1719
|
|
|
1592
1720
|
The function receives args as Record<string, unknown> and should return the result.
|
|
1593
|
-
|
|
1721
|
+
|
|
1722
|
+
## Available in Executor Scope
|
|
1723
|
+
|
|
1724
|
+
Executors run in the browser context with full access to:
|
|
1725
|
+
|
|
1726
|
+
### Browser APIs
|
|
1727
|
+
- **fetch(url, options)** - HTTP requests (subject to CORS)
|
|
1728
|
+
- **localStorage / sessionStorage** - Persistent storage
|
|
1729
|
+
- **document** - Full DOM access (create elements, modals, forms, etc.)
|
|
1730
|
+
- **window** - Global window object
|
|
1731
|
+
- **console** - Logging (log, warn, error, etc.)
|
|
1732
|
+
- **setTimeout / setInterval / clearTimeout / clearInterval** - Timers
|
|
1733
|
+
- **JSON** - Parse and stringify
|
|
1734
|
+
- **Date** - Date/time operations
|
|
1735
|
+
- **URL / URLSearchParams** - URL manipulation
|
|
1736
|
+
- **FormData / Blob / File / FileReader** - File handling
|
|
1737
|
+
- **crypto** - Cryptographic operations (crypto.randomUUID(), etc.)
|
|
1738
|
+
- **navigator** - Browser info, clipboard, geolocation, etc.
|
|
1739
|
+
- **location** - Current URL info
|
|
1740
|
+
- **history** - Browser history navigation
|
|
1741
|
+
- **WebSocket** - Real-time bidirectional communication
|
|
1742
|
+
- **EventSource** - Server-sent events
|
|
1743
|
+
- **indexedDB** - Client-side database for large data
|
|
1744
|
+
- **Notification** - Browser notifications (requires permission)
|
|
1745
|
+
- **performance** - Performance timing
|
|
1746
|
+
- **atob / btoa** - Base64 encoding/decoding
|
|
1747
|
+
- **TextEncoder / TextDecoder** - Text encoding
|
|
1748
|
+
- **AbortController** - Cancel fetch requests
|
|
1749
|
+
- **IntersectionObserver / MutationObserver / ResizeObserver** - DOM observers
|
|
1750
|
+
- **requestAnimationFrame** - Animation timing
|
|
1751
|
+
- **speechSynthesis** - Text-to-speech
|
|
1752
|
+
- **Audio / Image / Canvas** - Media APIs
|
|
1753
|
+
|
|
1754
|
+
### Hustle Plugin System Globals
|
|
1755
|
+
- **window.__hustleInstanceId** - Current Hustle instance ID
|
|
1756
|
+
- **window.__hustleRegisterPlugin(plugin, enabled)** - Install another plugin dynamically
|
|
1757
|
+
- **window.__hustleUnregisterPlugin(name)** - Uninstall a plugin by name
|
|
1758
|
+
- **window.__hustleUploadFile(file, fileName?)** - Upload a File/Blob to the server, returns { url, contentType }
|
|
1759
|
+
- **window.__hustleListPlugins()** - List all installed plugins
|
|
1760
|
+
- **window.__hustleGetPlugin(name)** - Get a specific plugin by name
|
|
1761
|
+
|
|
1762
|
+
### DOM Manipulation Examples
|
|
1763
|
+
Create a modal: document.createElement('div'), style it, append to document.body
|
|
1764
|
+
Add event listeners: element.addEventListener('click', handler)
|
|
1765
|
+
Query elements: document.querySelector(), document.querySelectorAll()
|
|
1766
|
+
|
|
1767
|
+
### Storage Patterns
|
|
1768
|
+
Store data: localStorage.setItem('key', JSON.stringify(data))
|
|
1769
|
+
Retrieve data: JSON.parse(localStorage.getItem('key') || '{}')
|
|
1770
|
+
Namespace your keys: Use plugin name prefix like "myplugin-settings"
|
|
1771
|
+
|
|
1772
|
+
### Async Patterns
|
|
1773
|
+
All executors should be async. Use await for promises:
|
|
1774
|
+
"async (args) => { const res = await fetch(url); return await res.json(); }"
|
|
1594
1775
|
|
|
1595
1776
|
## Lifecycle Hooks (Optional)
|
|
1596
1777
|
|
|
1597
|
-
|
|
1778
|
+
Hooks also have full access to the browser scope described above.
|
|
1779
|
+
|
|
1780
|
+
- **onRegisterCode**: Called once when plugin is registered/enabled. Good for initialization.
|
|
1781
|
+
**IMPORTANT: Always log when your plugin registers so users know it's active!**
|
|
1782
|
+
Example: "async () => { console.log('[MyPlugin] v1.0.0 registered'); }"
|
|
1783
|
+
|
|
1784
|
+
- **beforeRequestCode**: Modify messages before sending. Receives request object with { messages, model, ... }. Must return the modified request.
|
|
1598
1785
|
Example: "async (req) => { req.messages = req.messages.map(m => ({...m, content: m.content.toUpperCase()})); return req; }"
|
|
1599
1786
|
|
|
1600
|
-
- **afterResponseCode**: Process response after receiving. Receives response object.
|
|
1601
|
-
Example: "async (res) => { console.log('Response:', res.content); }"
|
|
1787
|
+
- **afterResponseCode**: Process/modify response after receiving. Receives response object with { content, ... }.
|
|
1788
|
+
Example: "async (res) => { console.log('Response received:', res.content.substring(0, 100)); }"
|
|
1602
1789
|
|
|
1603
|
-
- **
|
|
1604
|
-
Example: "async () => { console.
|
|
1790
|
+
- **onErrorCode**: Called on errors. Receives (error, context) where context has { phase: 'beforeRequest'|'execute'|'afterResponse' }.
|
|
1791
|
+
Example: "async (error, ctx) => { console.error('[MyPlugin] Error in', ctx.phase, ':', error.message); }"
|
|
1605
1792
|
|
|
1606
|
-
|
|
1607
|
-
|
|
1793
|
+
## Best Practices
|
|
1794
|
+
|
|
1795
|
+
1. **Always add onRegisterCode** that logs the plugin name and version
|
|
1796
|
+
2. **Namespace console logs** with [PluginName] prefix for easy identification
|
|
1797
|
+
3. **Handle errors gracefully** in executors - return { error: message } instead of throwing
|
|
1798
|
+
|
|
1799
|
+
## Security Notes
|
|
1800
|
+
- Code runs in browser sandbox with same-origin policy
|
|
1801
|
+
- fetch() is subject to CORS restrictions
|
|
1802
|
+
- No direct filesystem access (use File API with user interaction)
|
|
1803
|
+
- Be careful with eval() on user input`,
|
|
1608
1804
|
parameters: {
|
|
1609
1805
|
type: "object",
|
|
1610
1806
|
properties: {
|
|
@@ -1693,6 +1889,104 @@ var installPluginTool = {
|
|
|
1693
1889
|
required: ["plugin"]
|
|
1694
1890
|
}
|
|
1695
1891
|
};
|
|
1892
|
+
var uninstallPluginTool = {
|
|
1893
|
+
name: "uninstall_plugin",
|
|
1894
|
+
description: "Uninstall a plugin by name, removing it from browser storage.",
|
|
1895
|
+
parameters: {
|
|
1896
|
+
type: "object",
|
|
1897
|
+
properties: {
|
|
1898
|
+
name: {
|
|
1899
|
+
type: "string",
|
|
1900
|
+
description: "The name of the plugin to uninstall"
|
|
1901
|
+
}
|
|
1902
|
+
},
|
|
1903
|
+
required: ["name"]
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
var listPluginsTool = {
|
|
1907
|
+
name: "list_plugins",
|
|
1908
|
+
description: "List all installed plugins with their enabled/disabled status.",
|
|
1909
|
+
parameters: {
|
|
1910
|
+
type: "object",
|
|
1911
|
+
properties: {},
|
|
1912
|
+
required: []
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
1915
|
+
var modifyPluginTool = {
|
|
1916
|
+
name: "modify_plugin",
|
|
1917
|
+
description: `Modify an existing installed plugin. Can update version, description, tools, executors, and hooks.
|
|
1918
|
+
|
|
1919
|
+
Use list_plugins first to see installed plugins, then modify by name.
|
|
1920
|
+
|
|
1921
|
+
You can:
|
|
1922
|
+
- Add new tools (provide tools array with new tools to add)
|
|
1923
|
+
- Update existing tools (provide tool with same name)
|
|
1924
|
+
- Remove tools (set removeTool to the tool name)
|
|
1925
|
+
- Update hooks (provide hook code)
|
|
1926
|
+
- Update version/description
|
|
1927
|
+
|
|
1928
|
+
Example: Add a new tool to existing plugin:
|
|
1929
|
+
{
|
|
1930
|
+
"name": "my-plugin",
|
|
1931
|
+
"addTools": [{ "name": "new_tool", "description": "...", "parameters": {...} }],
|
|
1932
|
+
"addExecutorCode": { "new_tool": "async (args) => { ... }" }
|
|
1933
|
+
}`,
|
|
1934
|
+
parameters: {
|
|
1935
|
+
type: "object",
|
|
1936
|
+
properties: {
|
|
1937
|
+
name: {
|
|
1938
|
+
type: "string",
|
|
1939
|
+
description: "Name of the plugin to modify (required)"
|
|
1940
|
+
},
|
|
1941
|
+
version: {
|
|
1942
|
+
type: "string",
|
|
1943
|
+
description: "New version string"
|
|
1944
|
+
},
|
|
1945
|
+
description: {
|
|
1946
|
+
type: "string",
|
|
1947
|
+
description: "New description"
|
|
1948
|
+
},
|
|
1949
|
+
addTools: {
|
|
1950
|
+
type: "array",
|
|
1951
|
+
description: "Tools to add or update",
|
|
1952
|
+
items: {
|
|
1953
|
+
type: "object",
|
|
1954
|
+
properties: {
|
|
1955
|
+
name: { type: "string" },
|
|
1956
|
+
description: { type: "string" },
|
|
1957
|
+
parameters: { type: "object" }
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
addExecutorCode: {
|
|
1962
|
+
type: "object",
|
|
1963
|
+
description: "Executor code to add/update (tool name -> code string)"
|
|
1964
|
+
},
|
|
1965
|
+
removeTools: {
|
|
1966
|
+
type: "array",
|
|
1967
|
+
description: "Names of tools to remove",
|
|
1968
|
+
items: { type: "string" }
|
|
1969
|
+
},
|
|
1970
|
+
onRegisterCode: {
|
|
1971
|
+
type: "string",
|
|
1972
|
+
description: "New onRegister hook code"
|
|
1973
|
+
},
|
|
1974
|
+
beforeRequestCode: {
|
|
1975
|
+
type: "string",
|
|
1976
|
+
description: "New beforeRequest hook code"
|
|
1977
|
+
},
|
|
1978
|
+
afterResponseCode: {
|
|
1979
|
+
type: "string",
|
|
1980
|
+
description: "New afterResponse hook code"
|
|
1981
|
+
},
|
|
1982
|
+
onErrorCode: {
|
|
1983
|
+
type: "string",
|
|
1984
|
+
description: "New onError hook code"
|
|
1985
|
+
}
|
|
1986
|
+
},
|
|
1987
|
+
required: ["name"]
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1696
1990
|
var buildPluginExecutor = async (args2) => {
|
|
1697
1991
|
const {
|
|
1698
1992
|
name,
|
|
@@ -1749,12 +2043,51 @@ var buildPluginExecutor = async (args2) => {
|
|
|
1749
2043
|
message: `Plugin "${name}" v${version} built successfully with ${tools.length} tool(s). Use save_plugin to download or install_plugin to add to browser storage.`
|
|
1750
2044
|
};
|
|
1751
2045
|
};
|
|
2046
|
+
function normalizePlugin(input) {
|
|
2047
|
+
const plugin = input;
|
|
2048
|
+
const tools = plugin.tools;
|
|
2049
|
+
const hasEmbeddedExecutors = tools?.[0]?.executorCode !== void 0;
|
|
2050
|
+
if (hasEmbeddedExecutors) {
|
|
2051
|
+
return {
|
|
2052
|
+
...plugin,
|
|
2053
|
+
enabled: plugin.enabled ?? true,
|
|
2054
|
+
installedAt: plugin.installedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
const executorCode = plugin.executorCode;
|
|
2058
|
+
const rawTools = tools || [];
|
|
2059
|
+
const hookKeys = ["onRegisterCode", "beforeRequestCode", "afterResponseCode", "onErrorCode"];
|
|
2060
|
+
const hooksCode = {};
|
|
2061
|
+
for (const key of hookKeys) {
|
|
2062
|
+
if (executorCode?.[key]) {
|
|
2063
|
+
hooksCode[key] = executorCode[key];
|
|
2064
|
+
}
|
|
2065
|
+
if (plugin[key]) {
|
|
2066
|
+
hooksCode[key] = plugin[key];
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return {
|
|
2070
|
+
name: plugin.name,
|
|
2071
|
+
version: plugin.version,
|
|
2072
|
+
description: plugin.description,
|
|
2073
|
+
tools: rawTools.map((tool) => ({
|
|
2074
|
+
name: tool.name,
|
|
2075
|
+
description: tool.description,
|
|
2076
|
+
parameters: tool.parameters,
|
|
2077
|
+
executorCode: executorCode?.[tool.name]
|
|
2078
|
+
})),
|
|
2079
|
+
hooksCode: Object.keys(hooksCode).length > 0 ? hooksCode : void 0,
|
|
2080
|
+
enabled: true,
|
|
2081
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
1752
2084
|
var savePluginExecutor = async (args2) => {
|
|
1753
|
-
const { plugin, filename } = args2;
|
|
2085
|
+
const { plugin: rawPlugin, filename } = args2;
|
|
1754
2086
|
if (typeof window === "undefined") {
|
|
1755
2087
|
return { success: false, error: "Cannot save files in server environment" };
|
|
1756
2088
|
}
|
|
1757
2089
|
try {
|
|
2090
|
+
const plugin = normalizePlugin(rawPlugin);
|
|
1758
2091
|
const json2 = JSON.stringify(plugin, null, 2);
|
|
1759
2092
|
const blob = new Blob([json2], { type: "application/json" });
|
|
1760
2093
|
const url = URL.createObjectURL(blob);
|
|
@@ -1777,31 +2110,23 @@ var savePluginExecutor = async (args2) => {
|
|
|
1777
2110
|
}
|
|
1778
2111
|
};
|
|
1779
2112
|
var installPluginExecutor = async (args2) => {
|
|
1780
|
-
const { plugin, enabled = true } = args2;
|
|
2113
|
+
const { plugin: rawPlugin, enabled = true } = args2;
|
|
1781
2114
|
if (typeof window === "undefined") {
|
|
1782
2115
|
return { success: false, error: "Cannot install plugins in server environment" };
|
|
1783
2116
|
}
|
|
1784
2117
|
try {
|
|
1785
|
-
const
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
enabled,
|
|
1793
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1794
|
-
};
|
|
1795
|
-
if (existingIndex >= 0) {
|
|
1796
|
-
plugins[existingIndex] = pluginToStore;
|
|
1797
|
-
} else {
|
|
1798
|
-
plugins.push(pluginToStore);
|
|
2118
|
+
const plugin = normalizePlugin(rawPlugin);
|
|
2119
|
+
const win = window;
|
|
2120
|
+
if (!win.__hustleRegisterPlugin) {
|
|
2121
|
+
return {
|
|
2122
|
+
success: false,
|
|
2123
|
+
error: "Plugin registration not available. Make sure HustleProvider is mounted."
|
|
2124
|
+
};
|
|
1799
2125
|
}
|
|
1800
|
-
|
|
2126
|
+
await win.__hustleRegisterPlugin(plugin, enabled);
|
|
1801
2127
|
return {
|
|
1802
2128
|
success: true,
|
|
1803
|
-
message: `Plugin "${plugin.name}" installed${enabled ? " and enabled" : ""}
|
|
1804
|
-
action: existingIndex >= 0 ? "updated" : "installed"
|
|
2129
|
+
message: `Plugin "${plugin.name}" installed${enabled ? " and enabled" : ""}.`
|
|
1805
2130
|
};
|
|
1806
2131
|
} catch (error2) {
|
|
1807
2132
|
return {
|
|
@@ -1810,15 +2135,168 @@ var installPluginExecutor = async (args2) => {
|
|
|
1810
2135
|
};
|
|
1811
2136
|
}
|
|
1812
2137
|
};
|
|
2138
|
+
var uninstallPluginExecutor = async (args2) => {
|
|
2139
|
+
const { name } = args2;
|
|
2140
|
+
if (typeof window === "undefined") {
|
|
2141
|
+
return { success: false, error: "Cannot uninstall plugins in server environment" };
|
|
2142
|
+
}
|
|
2143
|
+
try {
|
|
2144
|
+
const win = window;
|
|
2145
|
+
if (!win.__hustleUnregisterPlugin) {
|
|
2146
|
+
return {
|
|
2147
|
+
success: false,
|
|
2148
|
+
error: "Plugin unregistration not available. Make sure HustleProvider is mounted."
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
await win.__hustleUnregisterPlugin(name);
|
|
2152
|
+
return {
|
|
2153
|
+
success: true,
|
|
2154
|
+
message: `Plugin "${name}" has been uninstalled.`
|
|
2155
|
+
};
|
|
2156
|
+
} catch (error2) {
|
|
2157
|
+
return {
|
|
2158
|
+
success: false,
|
|
2159
|
+
error: `Failed to uninstall: ${error2 instanceof Error ? error2.message : "Unknown error"}`
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
var listPluginsExecutor = async () => {
|
|
2164
|
+
if (typeof window === "undefined") {
|
|
2165
|
+
return { success: false, error: "Cannot list plugins in server environment" };
|
|
2166
|
+
}
|
|
2167
|
+
try {
|
|
2168
|
+
const win = window;
|
|
2169
|
+
if (!win.__hustleListPlugins) {
|
|
2170
|
+
return {
|
|
2171
|
+
success: false,
|
|
2172
|
+
error: "Plugin listing not available. Make sure HustleProvider is mounted."
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
const plugins = win.__hustleListPlugins();
|
|
2176
|
+
return {
|
|
2177
|
+
success: true,
|
|
2178
|
+
plugins,
|
|
2179
|
+
summary: plugins.length === 0 ? "No plugins installed." : `${plugins.length} plugin(s) installed: ${plugins.map((p) => `${p.name} v${p.version} (${p.enabled ? "enabled" : "disabled"})`).join(", ")}`
|
|
2180
|
+
};
|
|
2181
|
+
} catch (error2) {
|
|
2182
|
+
return {
|
|
2183
|
+
success: false,
|
|
2184
|
+
error: `Failed to list plugins: ${error2 instanceof Error ? error2.message : "Unknown error"}`
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
2188
|
+
var modifyPluginExecutor = async (args2) => {
|
|
2189
|
+
const {
|
|
2190
|
+
name,
|
|
2191
|
+
version,
|
|
2192
|
+
description,
|
|
2193
|
+
addTools,
|
|
2194
|
+
addExecutorCode,
|
|
2195
|
+
removeTools,
|
|
2196
|
+
onRegisterCode,
|
|
2197
|
+
beforeRequestCode,
|
|
2198
|
+
afterResponseCode,
|
|
2199
|
+
onErrorCode
|
|
2200
|
+
} = args2;
|
|
2201
|
+
if (typeof window === "undefined") {
|
|
2202
|
+
return { success: false, error: "Cannot modify plugins in server environment" };
|
|
2203
|
+
}
|
|
2204
|
+
try {
|
|
2205
|
+
const win = window;
|
|
2206
|
+
if (!win.__hustleGetPlugin || !win.__hustleRegisterPlugin) {
|
|
2207
|
+
return {
|
|
2208
|
+
success: false,
|
|
2209
|
+
error: "Plugin modification not available. Make sure HustleProvider is mounted."
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
const existing = win.__hustleGetPlugin(name);
|
|
2213
|
+
if (!existing) {
|
|
2214
|
+
return {
|
|
2215
|
+
success: false,
|
|
2216
|
+
error: `Plugin "${name}" not found. Use list_plugins to see installed plugins.`
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
let tools = existing.tools ? [...existing.tools] : [];
|
|
2220
|
+
if (removeTools && removeTools.length > 0) {
|
|
2221
|
+
tools = tools.filter((t) => !removeTools.includes(t.name));
|
|
2222
|
+
}
|
|
2223
|
+
if (addTools && addTools.length > 0) {
|
|
2224
|
+
for (const newTool of addTools) {
|
|
2225
|
+
const existingIndex = tools.findIndex((t) => t.name === newTool.name);
|
|
2226
|
+
const toolWithExecutor = {
|
|
2227
|
+
...newTool,
|
|
2228
|
+
executorCode: addExecutorCode?.[newTool.name] || tools.find((t) => t.name === newTool.name)?.executorCode
|
|
2229
|
+
};
|
|
2230
|
+
if (existingIndex >= 0) {
|
|
2231
|
+
tools[existingIndex] = toolWithExecutor;
|
|
2232
|
+
} else {
|
|
2233
|
+
tools.push(toolWithExecutor);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
if (addExecutorCode) {
|
|
2238
|
+
for (const [toolName, code2] of Object.entries(addExecutorCode)) {
|
|
2239
|
+
const tool = tools.find((t) => t.name === toolName);
|
|
2240
|
+
if (tool) {
|
|
2241
|
+
tool.executorCode = code2;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
const modified = {
|
|
2246
|
+
...existing,
|
|
2247
|
+
tools
|
|
2248
|
+
};
|
|
2249
|
+
if (version) modified.version = version;
|
|
2250
|
+
if (description) modified.description = description;
|
|
2251
|
+
if (onRegisterCode || beforeRequestCode || afterResponseCode || onErrorCode) {
|
|
2252
|
+
modified.hooksCode = modified.hooksCode || {};
|
|
2253
|
+
if (onRegisterCode) modified.hooksCode.onRegisterCode = onRegisterCode;
|
|
2254
|
+
if (beforeRequestCode) modified.hooksCode.beforeRequestCode = beforeRequestCode;
|
|
2255
|
+
if (afterResponseCode) modified.hooksCode.afterResponseCode = afterResponseCode;
|
|
2256
|
+
if (onErrorCode) modified.hooksCode.onErrorCode = onErrorCode;
|
|
2257
|
+
}
|
|
2258
|
+
const unregisterFn = window.__hustleUnregisterPlugin;
|
|
2259
|
+
if (unregisterFn) {
|
|
2260
|
+
await unregisterFn(name);
|
|
2261
|
+
}
|
|
2262
|
+
await win.__hustleRegisterPlugin(modified, existing.enabled);
|
|
2263
|
+
const changes = [];
|
|
2264
|
+
if (version) changes.push(`version \u2192 ${version}`);
|
|
2265
|
+
if (description) changes.push("description updated");
|
|
2266
|
+
if (removeTools?.length) changes.push(`removed ${removeTools.length} tool(s)`);
|
|
2267
|
+
if (addTools?.length) changes.push(`added/updated ${addTools.length} tool(s)`);
|
|
2268
|
+
if (onRegisterCode) changes.push("onRegister hook updated");
|
|
2269
|
+
if (beforeRequestCode) changes.push("beforeRequest hook updated");
|
|
2270
|
+
if (afterResponseCode) changes.push("afterResponse hook updated");
|
|
2271
|
+
if (onErrorCode) changes.push("onError hook updated");
|
|
2272
|
+
return {
|
|
2273
|
+
success: true,
|
|
2274
|
+
message: `Plugin "${name}" modified: ${changes.join(", ")}`,
|
|
2275
|
+
plugin: {
|
|
2276
|
+
name: modified.name,
|
|
2277
|
+
version: modified.version,
|
|
2278
|
+
toolCount: tools.length
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
} catch (error2) {
|
|
2282
|
+
return {
|
|
2283
|
+
success: false,
|
|
2284
|
+
error: `Failed to modify plugin: ${error2 instanceof Error ? error2.message : "Unknown error"}`
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
};
|
|
1813
2288
|
var pluginBuilderPlugin = {
|
|
1814
2289
|
name: "plugin-builder",
|
|
1815
|
-
version: "1.
|
|
2290
|
+
version: "1.2.0",
|
|
1816
2291
|
description: "Build custom plugins through conversation",
|
|
1817
|
-
tools: [buildPluginTool, savePluginTool, installPluginTool],
|
|
2292
|
+
tools: [buildPluginTool, savePluginTool, installPluginTool, uninstallPluginTool, listPluginsTool, modifyPluginTool],
|
|
1818
2293
|
executors: {
|
|
1819
2294
|
build_plugin: buildPluginExecutor,
|
|
1820
2295
|
save_plugin: savePluginExecutor,
|
|
1821
|
-
install_plugin: installPluginExecutor
|
|
2296
|
+
install_plugin: installPluginExecutor,
|
|
2297
|
+
uninstall_plugin: uninstallPluginExecutor,
|
|
2298
|
+
list_plugins: listPluginsExecutor,
|
|
2299
|
+
modify_plugin: modifyPluginExecutor
|
|
1822
2300
|
},
|
|
1823
2301
|
hooks: {
|
|
1824
2302
|
onRegister: () => {
|