dsh-offpeak-guard 0.3.0 → 0.3.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/CHANGELOG.md +7 -0
- package/README.en.md +3 -3
- package/README.md +1 -0
- package/client.js +238 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1 — 2026-11-06
|
|
4
|
+
|
|
5
|
+
- 左侧会话行悬停操作新增盾牌按钮:直接在对话索引里豁免/取消豁免,豁免中的会话行保留盾牌标记。
|
|
6
|
+
- 输入框状态条新增「豁免本会话 / 取消豁免」按钮,不用打开设置面板即可切换当前对话。
|
|
7
|
+
- 设置面板的会话豁免列表新增搜索过滤,会话多时可按标题或 session id 查找。
|
|
8
|
+
- 设置接口的浏览器请求补 `credentials: same-origin`,确保桌面 Web 主机下开关生效。
|
|
9
|
+
|
|
3
10
|
## 0.3.0 — 2026-11-06
|
|
4
11
|
|
|
5
12
|
- 新增峰时策略 `pauseMode`:`pause-now` 立即暂停,`finish-turn` 完成当前轮后暂停;设置面板可切换。
|
package/README.en.md
CHANGED
|
@@ -16,7 +16,7 @@ Schedule work around **DeepSeek peak/off-peak pricing** inside DeepSeek Harness:
|
|
|
16
16
|
- a task started during peak → stopped immediately, no peak-priced tokens spent;
|
|
17
17
|
- off-peak starts → interrupted tasks are handed back to their session automatically;
|
|
18
18
|
- the `offpeak_plan` tool queues "run this at noon" work for the next off-peak window.
|
|
19
|
-
- **UI**: a status strip under the composer (state, countdown, model, paused/planned counts), plus a compact clock at the sidebar foot that blinks red before peak starts and green before off-peak starts.
|
|
19
|
+
- **UI**: a status strip under the composer (state, countdown, model, paused/planned counts), plus a compact clock at the sidebar foot that blinks red before peak starts and green before off-peak starts. Click the clock to open settings. Hover a session row in the left sidebar for a shield button to exempt/un-exempt it; exempt rows keep a shield marker, and the composer strip has an **Exempt this session** toggle.
|
|
20
20
|
|
|
21
21
|

|
|
22
22
|
|
|
@@ -103,11 +103,11 @@ Deployments can inject settings with environment variables. Priority: **env > co
|
|
|
103
103
|
|
|
104
104
|
## Settings panel
|
|
105
105
|
|
|
106
|
-
Click the sidebar clock:
|
|
106
|
+
Click the sidebar clock to open it:
|
|
107
107
|
|
|
108
108
|
- **Peak strategy**: pause immediately or finish the current turn first;
|
|
109
109
|
- **Models**: provider-grouped catalog with a checkbox per model (persisted as `modelRules`, overriding `modelPattern`);
|
|
110
|
-
- **Session exemptions**:
|
|
110
|
+
- **Session exemptions**: searchable list of live root sessions; hover a session row in the left sidebar for a shield button, or use the **Exempt this session** button in the composer strip to toggle the current conversation directly;
|
|
111
111
|
- **Holidays**: show the dynamic sync state.
|
|
112
112
|
|
|
113
113
|
## Subagents and waiting
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- 谷价开始 → 自动把被打断的任务交还会话继续执行;
|
|
18
18
|
- 另提供 `offpeak_plan` 工具,把「12 点再跑」这类事排进下一个谷价窗口。
|
|
19
19
|
- **界面**:输入框下方一条状态条,显示当前峰/谷、距下次切换的倒计时、当前模型,以及暂停/规划数量;左侧栏底部另有一枚常驻计时,临近切换时闪色提醒(峰价快开始闪红、谷价快开始闪绿)。**点击左栏计时打开设置面板**:切换峰时策略、逐模型管理、会话豁免、查看节假日同步状态。
|
|
20
|
+
- **就地豁免**:把鼠标移到左侧会话行,行尾会出现盾牌按钮,点一下即可豁免/取消该会话;豁免中的会话行会显示盾牌标记。输入框状态条右侧也有「豁免本会话」按钮,直接在当前对话里切换。
|
|
20
21
|
|
|
21
22
|

|
|
22
23
|
|
package/client.js
CHANGED
|
@@ -131,6 +131,8 @@ window.__ModuleLoader__.load({
|
|
|
131
131
|
if (mounted.current && parsed !== null && typeof parsed === 'object') {
|
|
132
132
|
setSnapshot(parsed);
|
|
133
133
|
setError(null);
|
|
134
|
+
sharedStatus.snapshot = parsed;
|
|
135
|
+
for (const listener of sharedStatus.listeners) listener();
|
|
134
136
|
}
|
|
135
137
|
} catch (caught) {
|
|
136
138
|
if (mounted.current) setError(String(caught?.message ?? caught));
|
|
@@ -152,12 +154,96 @@ window.__ModuleLoader__.load({
|
|
|
152
154
|
return { snapshot, error, refresh: load };
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
const sharedStatus = { snapshot: null, listeners: new Set(), timer: null, loading: false, ctx: null };
|
|
158
|
+
|
|
159
|
+
async function readSharedStatus(ctx) {
|
|
160
|
+
const remote = ctx.remote;
|
|
161
|
+
if (!remote || typeof remote.session?.list !== 'function' || typeof remote.workspaceFiles?.read !== 'function') return null;
|
|
162
|
+
const listed = await remote.session.list({});
|
|
163
|
+
const items = listed?.value?.items ?? listed?.items ?? [];
|
|
164
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
165
|
+
const sorted = [...items].sort(
|
|
166
|
+
(left, right) => Number(right.updatedAt ?? right.lastActivityAt ?? 0) - Number(left.updatedAt ?? left.lastActivityAt ?? 0),
|
|
167
|
+
);
|
|
168
|
+
const home = remote.$host?.home;
|
|
169
|
+
if (typeof home !== 'string' || home.length === 0) return null;
|
|
170
|
+
const candidates = sorted
|
|
171
|
+
.filter((item) => item?.parentSession === undefined && item?.parentSessionId === undefined)
|
|
172
|
+
.map((item) => item?.sessionId ?? item?.id)
|
|
173
|
+
.filter((id) => typeof id === 'string' && id.length > 0)
|
|
174
|
+
.slice(0, 5);
|
|
175
|
+
for (const sessionId of candidates) {
|
|
176
|
+
try {
|
|
177
|
+
const result = await remote.workspaceFiles.read(sessionId, `${home}/.dsh/offpeak-guard/status.json`, {});
|
|
178
|
+
const parsed = JSON.parse(snapshotTextOf(result));
|
|
179
|
+
if (parsed !== null && typeof parsed === 'object') return parsed;
|
|
180
|
+
} catch {
|
|
181
|
+
/* 换下一个最近会话再试 */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function loadSharedStatus(ctx) {
|
|
188
|
+
if (sharedStatus.loading) return;
|
|
189
|
+
sharedStatus.loading = true;
|
|
190
|
+
try {
|
|
191
|
+
const snapshot = await readSharedStatus(ctx);
|
|
192
|
+
if (snapshot !== null) {
|
|
193
|
+
sharedStatus.snapshot = snapshot;
|
|
194
|
+
for (const listener of sharedStatus.listeners) listener();
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
/* 状态文件尚未生成时保持旧值 */
|
|
198
|
+
} finally {
|
|
199
|
+
sharedStatus.loading = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ensureSharedStatus(ctx) {
|
|
204
|
+
sharedStatus.ctx = ctx;
|
|
205
|
+
if (sharedStatus.timer !== null) return;
|
|
206
|
+
void loadSharedStatus(ctx);
|
|
207
|
+
sharedStatus.timer = setInterval(() => void loadSharedStatus(ctx), 30000);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function useSharedStatus(ctx) {
|
|
211
|
+
const [, force] = React.useState(0);
|
|
212
|
+
React.useEffect(() => {
|
|
213
|
+
const listener = () => force((value) => value + 1);
|
|
214
|
+
sharedStatus.listeners.add(listener);
|
|
215
|
+
ensureSharedStatus(ctx);
|
|
216
|
+
return () => {
|
|
217
|
+
sharedStatus.listeners.delete(listener);
|
|
218
|
+
if (sharedStatus.listeners.size === 0 && sharedStatus.timer !== null) {
|
|
219
|
+
clearInterval(sharedStatus.timer);
|
|
220
|
+
sharedStatus.timer = null;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}, []);
|
|
224
|
+
return sharedStatus.snapshot;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function postAction(body) {
|
|
228
|
+
const response = await fetch('/plugins/dsh-offpeak-guard/action', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
credentials: 'same-origin',
|
|
231
|
+
headers: { 'content-type': 'application/json' },
|
|
232
|
+
body: JSON.stringify(body),
|
|
233
|
+
});
|
|
234
|
+
const json = await response.json();
|
|
235
|
+
if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
|
|
236
|
+
if (sharedStatus.ctx !== null) await loadSharedStatus(sharedStatus.ctx);
|
|
237
|
+
return json;
|
|
238
|
+
}
|
|
239
|
+
|
|
155
240
|
function makeStrip(pluginCtx) {
|
|
156
241
|
return function Strip(props) {
|
|
157
242
|
const ctx = pluginCtx;
|
|
158
243
|
const t = useLocale(ctx);
|
|
159
|
-
|
|
160
|
-
|
|
244
|
+
const { snapshot, error, refresh } = useSnapshot(ctx, props?.sessionId);
|
|
245
|
+
const [busy, setBusy] = React.useState(false);
|
|
246
|
+
const [, force] = React.useState(0);
|
|
161
247
|
|
|
162
248
|
React.useEffect(() => {
|
|
163
249
|
const timer = setInterval(() => force((value) => value + 1), 1000);
|
|
@@ -218,6 +304,38 @@ window.__ModuleLoader__.load({
|
|
|
218
304
|
if (modelId !== '') children.push(h('span', { key: 'model', style: { opacity: 0.75 } }, `· ${modelId}`));
|
|
219
305
|
if (paused > 0) children.push(h('span', { key: 'paused', style: { color: '#e0a33e' } }, `· ${t('paused')} ${paused}`));
|
|
220
306
|
if (plans > 0) children.push(h('span', { key: 'plans' }, `· ${t('plans')} ${plans}`));
|
|
307
|
+
const currentSessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
|
|
308
|
+
const currentExempt =
|
|
309
|
+
currentSessionId !== '' &&
|
|
310
|
+
(snapshot.exemptSessions ?? []).some((item) => item.sessionId === currentSessionId);
|
|
311
|
+
if (currentSessionId !== '') {
|
|
312
|
+
children.push(
|
|
313
|
+
h(
|
|
314
|
+
'button',
|
|
315
|
+
{
|
|
316
|
+
key: 'exempt',
|
|
317
|
+
type: 'button',
|
|
318
|
+
className: 'opg-exempt',
|
|
319
|
+
'data-exempt': currentExempt ? 'true' : undefined,
|
|
320
|
+
disabled: busy,
|
|
321
|
+
title: currentExempt ? '取消本会话的峰谷豁免' : '让本会话豁免峰谷守卫(不暂停/不恢复)',
|
|
322
|
+
onClick: async (event) => {
|
|
323
|
+
event.stopPropagation();
|
|
324
|
+
setBusy(true);
|
|
325
|
+
try {
|
|
326
|
+
await postAction({ action: 'exempt', sessionId: currentSessionId, exempt: !currentExempt });
|
|
327
|
+
await refresh();
|
|
328
|
+
} catch {
|
|
329
|
+
/* 下一次点击重试 */
|
|
330
|
+
} finally {
|
|
331
|
+
setBusy(false);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
[h(ShieldIcon, { key: 'icon', active: currentExempt }), currentExempt ? '取消豁免' : '豁免本会话'],
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
221
339
|
|
|
222
340
|
return h(
|
|
223
341
|
'div',
|
|
@@ -299,6 +417,13 @@ window.__ModuleLoader__.load({
|
|
|
299
417
|
.opg-seg button[data-active="true"]{background:var(--dsw-alias-state-business-primary,#4d6bfe);color:#fff;}
|
|
300
418
|
.opg-note{color:var(--dsw-alias-label-secondary,#8a8d94);font-size:11px;line-height:1.6;}
|
|
301
419
|
.opg-error{color:#e0554e;font-size:12px;margin-top:8px;}
|
|
420
|
+
.opg-row-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:26px;height:26px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#6c7769);cursor:pointer;padding:0;}
|
|
421
|
+
.opg-row-action:hover{background:var(--dsw-alias-interactive-bg-hover,#4d6bfe12);color:var(--dsw-alias-label-primary,#252936);}
|
|
422
|
+
.opg-row-action[data-exempt="true"]{color:#e0554e;}
|
|
423
|
+
.opg-trail{display:inline-flex;align-items:center;justify-content:center;flex:none;color:#e0554e;opacity:.85;}
|
|
424
|
+
.opg-exempt{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l1,#dde0e6);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#6c7769);font:11px/16px var(--dsw-font-family,system-ui,sans-serif);padding:1px 6px;cursor:pointer;white-space:nowrap;}
|
|
425
|
+
.opg-exempt[data-exempt="true"]{color:#e0554e;border-color:#e0554e66;}
|
|
426
|
+
.opg-exempt:hover{border-color:var(--dsw-alias-label-secondary,#8a8d94);}
|
|
302
427
|
`;
|
|
303
428
|
|
|
304
429
|
function ClockIcon() {
|
|
@@ -344,12 +469,14 @@ window.__ModuleLoader__.load({
|
|
|
344
469
|
function SettingsPanel({ ctx, snapshot, refresh, groups, onClose }) {
|
|
345
470
|
const [busy, setBusy] = React.useState('');
|
|
346
471
|
const [error, setError] = React.useState('');
|
|
472
|
+
const [query, setQuery] = React.useState('');
|
|
347
473
|
const post = async (body) => {
|
|
348
474
|
setBusy(`${body.action}:${body.sessionId ?? body.provider ?? body.mode ?? ''}`);
|
|
349
475
|
setError('');
|
|
350
476
|
try {
|
|
351
477
|
const response = await fetch('/plugins/dsh-offpeak-guard/action', {
|
|
352
478
|
method: 'POST',
|
|
479
|
+
credentials: 'same-origin',
|
|
353
480
|
headers: { 'content-type': 'application/json' },
|
|
354
481
|
body: JSON.stringify(body),
|
|
355
482
|
});
|
|
@@ -372,7 +499,11 @@ window.__ModuleLoader__.load({
|
|
|
372
499
|
const needle = String(snapshot.modelPattern ?? 'deepseek').toLowerCase();
|
|
373
500
|
return model.toLowerCase().includes(needle) || provider.toLowerCase().includes(needle);
|
|
374
501
|
};
|
|
375
|
-
const sessions = Object.values(snapshot.sessions ?? {})
|
|
502
|
+
const sessions = Object.values(snapshot.sessions ?? {}).filter((session) => {
|
|
503
|
+
if (query.trim() === '') return true;
|
|
504
|
+
const needle = query.trim().toLowerCase();
|
|
505
|
+
return String(session.title ?? '').toLowerCase().includes(needle) || String(session.id).toLowerCase().includes(needle);
|
|
506
|
+
});
|
|
376
507
|
return h('div', { className: 'opg-panel-shell' }, [
|
|
377
508
|
h('div', { className: 'opg-panel-head', key: 'head' }, [
|
|
378
509
|
h('strong', { key: 'title' }, '峰谷守卫设置'),
|
|
@@ -443,8 +574,26 @@ window.__ModuleLoader__.load({
|
|
|
443
574
|
]),
|
|
444
575
|
h('div', { className: 'opg-section', key: 'sessions' }, [
|
|
445
576
|
h('h4', { key: 'h' }, '会话豁免(不参与暂停/恢复)'),
|
|
577
|
+
h('input', {
|
|
578
|
+
key: 'search',
|
|
579
|
+
type: 'search',
|
|
580
|
+
value: query,
|
|
581
|
+
placeholder: '按标题或 session id 过滤…',
|
|
582
|
+
onChange: (event) => setQuery(event.target.value),
|
|
583
|
+
style: {
|
|
584
|
+
width: '100%',
|
|
585
|
+
boxSizing: 'border-box',
|
|
586
|
+
margin: '0 0 6px',
|
|
587
|
+
padding: '5px 8px',
|
|
588
|
+
border: '1px solid var(--dsw-alias-border-l1,#dde0e6)',
|
|
589
|
+
borderRadius: '7px',
|
|
590
|
+
background: 'transparent',
|
|
591
|
+
color: 'inherit',
|
|
592
|
+
font: 'inherit',
|
|
593
|
+
},
|
|
594
|
+
}),
|
|
446
595
|
sessions.length === 0
|
|
447
|
-
? h('div', { className: 'opg-note', key: 'empty' }, '
|
|
596
|
+
? h('div', { className: 'opg-note', key: 'empty' }, '没有匹配的运行中根会话。')
|
|
448
597
|
: sessions.map((session) =>
|
|
449
598
|
h('label', { className: 'opg-row', key: `s:${session.id}` }, [
|
|
450
599
|
h('input', {
|
|
@@ -477,6 +626,69 @@ window.__ModuleLoader__.load({
|
|
|
477
626
|
]);
|
|
478
627
|
}
|
|
479
628
|
|
|
629
|
+
function ShieldIcon({ active }) {
|
|
630
|
+
return h(
|
|
631
|
+
'svg',
|
|
632
|
+
{
|
|
633
|
+
width: 15,
|
|
634
|
+
height: 15,
|
|
635
|
+
viewBox: '0 0 24 24',
|
|
636
|
+
fill: active ? 'currentColor' : 'none',
|
|
637
|
+
stroke: 'currentColor',
|
|
638
|
+
strokeWidth: 1.8,
|
|
639
|
+
strokeLinecap: 'round',
|
|
640
|
+
strokeLinejoin: 'round',
|
|
641
|
+
'aria-hidden': true,
|
|
642
|
+
},
|
|
643
|
+
[h('path', { key: 'p', d: 'M12 3l7 3v5c0 4.4-2.9 8.1-7 10-4.1-1.9-7-5.6-7-10V6z' })],
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function makeRowAction(pluginCtx) {
|
|
648
|
+
return function RowAction(props) {
|
|
649
|
+
const snapshot = useSharedStatus(pluginCtx);
|
|
650
|
+
const [busy, setBusy] = React.useState(false);
|
|
651
|
+
const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
|
|
652
|
+
if (sessionId === '') return null;
|
|
653
|
+
const exempt = (snapshot?.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
|
|
654
|
+
return h(
|
|
655
|
+
'button',
|
|
656
|
+
{
|
|
657
|
+
type: 'button',
|
|
658
|
+
className: 'opg-row-action',
|
|
659
|
+
title: exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
|
|
660
|
+
'aria-label': exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
|
|
661
|
+
'aria-pressed': exempt,
|
|
662
|
+
'data-exempt': exempt ? 'true' : undefined,
|
|
663
|
+
disabled: busy,
|
|
664
|
+
onClick: async (event) => {
|
|
665
|
+
event.stopPropagation();
|
|
666
|
+
setBusy(true);
|
|
667
|
+
try {
|
|
668
|
+
await postAction({ action: 'exempt', sessionId, exempt: !exempt });
|
|
669
|
+
} catch {
|
|
670
|
+
/* 状态文件/路由暂不可用时静默,下一次点击重试 */
|
|
671
|
+
} finally {
|
|
672
|
+
setBusy(false);
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
h(ShieldIcon, { active: exempt }),
|
|
677
|
+
);
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function makeTrailing(pluginCtx) {
|
|
682
|
+
return function Trailing(props) {
|
|
683
|
+
const snapshot = useSharedStatus(pluginCtx);
|
|
684
|
+
const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
|
|
685
|
+
if (snapshot === null || sessionId === '') return null;
|
|
686
|
+
const exempt = (snapshot.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
|
|
687
|
+
if (!exempt) return null;
|
|
688
|
+
return h('span', { className: 'opg-trail', title: '峰谷豁免中', 'aria-hidden': true }, h(ShieldIcon, { active: true }));
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
480
692
|
function makeClock(pluginCtx) {
|
|
481
693
|
return function Clock(props) {
|
|
482
694
|
const t = useLocale(pluginCtx);
|
|
@@ -606,6 +818,28 @@ window.__ModuleLoader__.load({
|
|
|
606
818
|
Clock,
|
|
607
819
|
),
|
|
608
820
|
);
|
|
821
|
+
const RowAction = makeRowAction(ctx);
|
|
822
|
+
const Trail = makeTrailing(ctx);
|
|
823
|
+
ctx.slots.inject('sidebar.workspaces.session.row.action', () =>
|
|
824
|
+
ctx.slots.register(
|
|
825
|
+
{
|
|
826
|
+
name: 'sidebar.workspaces.session.row.action',
|
|
827
|
+
id: 'offpeak-guard-exempt',
|
|
828
|
+
order: 150,
|
|
829
|
+
},
|
|
830
|
+
RowAction,
|
|
831
|
+
),
|
|
832
|
+
);
|
|
833
|
+
ctx.slots.inject('sidebar.session.row.trailing', () =>
|
|
834
|
+
ctx.slots.register(
|
|
835
|
+
{
|
|
836
|
+
name: 'sidebar.session.row.trailing',
|
|
837
|
+
id: 'offpeak-guard-exempt-trail',
|
|
838
|
+
order: 20,
|
|
839
|
+
},
|
|
840
|
+
Trail,
|
|
841
|
+
),
|
|
842
|
+
);
|
|
609
843
|
},
|
|
610
844
|
};
|
|
611
845
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-offpeak-guard",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "DeepSeek peak/off-peak pricing guard for DSH: detects the price window and current model, pauses running tasks at peak, resumes them automatically when off-peak begins, and schedules work for the next off-peak window.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|