dsh-qoder-connect 0.1.5 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -213,9 +213,9 @@ window.__ModuleLoader__.load({
213
213
  * The shared quota-settings card: one card above the two variant cards that
214
214
  * configures both sidebar quota widgets.
215
215
  *
216
- * Like the built-in plugin cards, it registers into `settings.plugin.item`
217
- * keyed by its own settings namespace (`qoder-quota`), binds that
218
- * namespace through the client settings scope, and writes through the scope's
216
+ * Like the built-in plugin cards, it registers into the Plugins tab's card
217
+ * list — `settings.plugin.item` on DSH 0.1.5, the shared 《插件设置》 block on
218
+ * 0.1.7, where that list is gone — and writes through the settings face's
219
219
  * revision-fenced `set` — the same durable-write path every preference row
220
220
  * uses. A toggle commits on click: each click is one explicit user choice,
221
221
  * and the scope's ordering makes the last one win, so no staged-draft form is
@@ -230,6 +230,17 @@ window.__ModuleLoader__.load({
230
230
  const POLL_DEFAULT_MS = 3e5;
231
231
  /** Floor the schema also enforces; mirrored here for immediate UI feedback. */
232
232
  const POLL_MIN_MS = 6e4;
233
+ /** 10:00 UTC+8, the moment the upstream resets the daily campaign. */
234
+ const CHECK_IN_MINUTE_DEFAULT = 600;
235
+ /** Minutes past midnight (UTC+8), split for the two number fields. */
236
+ function splitMinutes(minutes) {
237
+ const safe = Number.isFinite(minutes) ? Math.trunc(minutes) : CHECK_IN_MINUTE_DEFAULT;
238
+ const clamped = safe < 0 || safe > 1439 ? CHECK_IN_MINUTE_DEFAULT : safe;
239
+ return {
240
+ hours: Math.floor(clamped / 60),
241
+ minutes: clamped % 60
242
+ };
243
+ }
233
244
  /** Read the section values out of a scope snapshot (defaults when absent). */
234
245
  function project(scope) {
235
246
  if (scope === void 0) return {
@@ -240,6 +251,8 @@ window.__ModuleLoader__.load({
240
251
  sidebarQuotaGlobal: false,
241
252
  autoCheckInCN: false,
242
253
  autoCheckInGlobal: false,
254
+ checkInMinuteCN: CHECK_IN_MINUTE_DEFAULT,
255
+ checkInMinuteGlobal: CHECK_IN_MINUTE_DEFAULT,
243
256
  quotaPollMs: POLL_DEFAULT_MS
244
257
  }
245
258
  };
@@ -253,6 +266,8 @@ window.__ModuleLoader__.load({
253
266
  sidebarQuotaGlobal: value.sidebarQuotaGlobal === true,
254
267
  autoCheckInCN: value.autoCheckInCN === true,
255
268
  autoCheckInGlobal: value.autoCheckInGlobal === true,
269
+ checkInMinuteCN: typeof value.checkInMinuteCN === "number" ? value.checkInMinuteCN : CHECK_IN_MINUTE_DEFAULT,
270
+ checkInMinuteGlobal: typeof value.checkInMinuteGlobal === "number" ? value.checkInMinuteGlobal : CHECK_IN_MINUTE_DEFAULT,
256
271
  quotaPollMs: typeof value.quotaPollMs === "number" ? value.quotaPollMs : POLL_DEFAULT_MS
257
272
  }
258
273
  };
@@ -278,13 +293,15 @@ window.__ModuleLoader__.load({
278
293
  sidebarQuotaGlobal: false,
279
294
  autoCheckInCN: false,
280
295
  autoCheckInGlobal: false,
296
+ checkInMinuteCN: CHECK_IN_MINUTE_DEFAULT,
297
+ checkInMinuteGlobal: CHECK_IN_MINUTE_DEFAULT,
281
298
  quotaPollMs: POLL_DEFAULT_MS
282
299
  }
283
300
  };
284
301
  function stableProject(scope) {
285
302
  if (scope === void 0) return UNAVAILABLE;
286
303
  const next = project(scope);
287
- if (cachedProjection === void 0 || cachedScope !== scope || cachedProjection.status !== next.status || cachedProjection.writable !== next.writable || cachedProjection.values.sidebarQuotaCN !== next.values.sidebarQuotaCN || cachedProjection.values.sidebarQuotaGlobal !== next.values.sidebarQuotaGlobal || cachedProjection.values.autoCheckInCN !== next.values.autoCheckInCN || cachedProjection.values.autoCheckInGlobal !== next.values.autoCheckInGlobal || cachedProjection.values.quotaPollMs !== next.values.quotaPollMs) {
304
+ if (cachedProjection === void 0 || cachedScope !== scope || cachedProjection.status !== next.status || cachedProjection.writable !== next.writable || cachedProjection.values.sidebarQuotaCN !== next.values.sidebarQuotaCN || cachedProjection.values.sidebarQuotaGlobal !== next.values.sidebarQuotaGlobal || cachedProjection.values.autoCheckInCN !== next.values.autoCheckInCN || cachedProjection.values.autoCheckInGlobal !== next.values.autoCheckInGlobal || cachedProjection.values.checkInMinuteCN !== next.values.checkInMinuteCN || cachedProjection.values.checkInMinuteGlobal !== next.values.checkInMinuteGlobal || cachedProjection.values.quotaPollMs !== next.values.quotaPollMs) {
288
305
  cachedScope = scope;
289
306
  cachedProjection = next;
290
307
  }
@@ -324,6 +341,106 @@ window.__ModuleLoader__.load({
324
341
  })]
325
342
  });
326
343
  }
344
+ /**
345
+ * One time row: the moment a variant checks in, as two typeable number fields.
346
+ *
347
+ * Deliberately NOT `<input type="time">`: that control's segments are spinners,
348
+ * and with an IME active (every Chinese/Japanese keyboard) the segments refuse
349
+ * typed digits, leaving only the tiny stepper arrows — unusable for the one
350
+ * thing this row exists to do. Two plain number fields accept typing under any
351
+ * input method; the value is committed on blur or Enter, so a half-typed
352
+ * field never writes a wrong moment to the host.
353
+ */
354
+ function TimeRow({ label, hint, value, disabled, onPick }) {
355
+ const split = splitMinutes(value);
356
+ const [hourDraft, setHourDraft] = (0, react.useState)(String(split.hours));
357
+ const [minuteDraft, setMinuteDraft] = (0, react.useState)(String(split.minutes).padStart(2, "0"));
358
+ (0, react.useEffect)(() => {
359
+ const next = splitMinutes(value);
360
+ setHourDraft(String(next.hours));
361
+ setMinuteDraft(String(next.minutes).padStart(2, "0"));
362
+ }, [value]);
363
+ const commit = () => {
364
+ const parsedHours = Number.parseInt(hourDraft, 10);
365
+ const parsedMinutes = Number.parseInt(minuteDraft, 10);
366
+ const hours = Number.isFinite(parsedHours) ? Math.min(23, Math.max(0, parsedHours)) : split.hours;
367
+ const minutes = Number.isFinite(parsedMinutes) ? Math.min(59, Math.max(0, parsedMinutes)) : split.minutes;
368
+ const next = hours * 60 + minutes;
369
+ if (next === value) {
370
+ setHourDraft(String(hours));
371
+ setMinuteDraft(String(minutes).padStart(2, "0"));
372
+ return;
373
+ }
374
+ onPick(next);
375
+ };
376
+ const onKeyDown = (event) => {
377
+ if (event.key === "Enter") {
378
+ event.preventDefault();
379
+ commit();
380
+ }
381
+ };
382
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
383
+ style: rowStyle$1,
384
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
385
+ style: rowTextStyle,
386
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
387
+ style: labelStyle$1,
388
+ children: label
389
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
390
+ style: hintStyle,
391
+ children: hint
392
+ })]
393
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
394
+ style: pollFieldStyle,
395
+ children: [
396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
397
+ type: "number",
398
+ min: 0,
399
+ max: 23,
400
+ value: hourDraft,
401
+ disabled,
402
+ "aria-label": `${label} — hour`,
403
+ "data-checkin-part": "hour",
404
+ onChange: (event) => {
405
+ setHourDraft(event.target.value);
406
+ },
407
+ onBlur: commit,
408
+ onKeyDown,
409
+ style: {
410
+ ...timePartStyle,
411
+ opacity: disabled === true ? .45 : 1
412
+ }
413
+ }),
414
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
415
+ style: labelStyle$1,
416
+ children: ":"
417
+ }),
418
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
419
+ type: "number",
420
+ min: 0,
421
+ max: 59,
422
+ value: minuteDraft,
423
+ disabled,
424
+ "aria-label": `${label} — minute`,
425
+ "data-checkin-part": "minute",
426
+ onChange: (event) => {
427
+ setMinuteDraft(event.target.value);
428
+ },
429
+ onBlur: commit,
430
+ onKeyDown,
431
+ style: {
432
+ ...timePartStyle,
433
+ opacity: disabled === true ? .45 : 1
434
+ }
435
+ }),
436
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
437
+ style: hintStyle,
438
+ children: "UTC+8"
439
+ })
440
+ ]
441
+ })]
442
+ });
443
+ }
327
444
  /** The inner controls for sidebar quota settings. */
328
445
  function QuotaSettingsContent({ t = (key) => key, scope, signedIn }) {
329
446
  const subscribe = (0, react.useCallback)((onStoreChange) => {
@@ -411,6 +528,13 @@ window.__ModuleLoader__.load({
411
528
  disabledHint: t("quotaSignInRequired"),
412
529
  onToggle: (next) => write("autoCheckInCN", next)
413
530
  }),
531
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimeRow, {
532
+ label: t("checkInTimeCN"),
533
+ hint: t("checkInTimeHint"),
534
+ value: projection.values.checkInMinuteCN,
535
+ disabled: !signed.cn,
536
+ onPick: (next) => write("checkInMinuteCN", next)
537
+ }),
414
538
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
415
539
  label: t("autoCheckInGlobal"),
416
540
  hint: t("autoCheckInHintGlobal"),
@@ -419,6 +543,13 @@ window.__ModuleLoader__.load({
419
543
  disabledHint: t("quotaSignInRequired"),
420
544
  onToggle: (next) => write("autoCheckInGlobal", next)
421
545
  }),
546
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimeRow, {
547
+ label: t("checkInTimeGlobal"),
548
+ hint: t("checkInTimeHint"),
549
+ value: projection.values.checkInMinuteGlobal,
550
+ disabled: !signed.global,
551
+ onPick: (next) => write("checkInMinuteGlobal", next)
552
+ }),
422
553
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
423
554
  style: {
424
555
  ...rowStyle$1,
@@ -518,6 +649,20 @@ window.__ModuleLoader__.load({
518
649
  gap: 6,
519
650
  flex: "none"
520
651
  };
652
+ const timePartStyle = {
653
+ boxSizing: "border-box",
654
+ width: 56,
655
+ padding: "5px 8px",
656
+ borderWidth: "1px",
657
+ borderStyle: "solid",
658
+ borderColor: "var(--dsw-alias-border-l2)",
659
+ borderRadius: 8,
660
+ background: "var(--dsw-alias-bg-layer-2)",
661
+ color: "var(--dsw-alias-label-primary)",
662
+ font: "inherit",
663
+ fontSize: 13,
664
+ textAlign: "center"
665
+ };
521
666
  const inputStyle = {
522
667
  boxSizing: "border-box",
523
668
  width: 55,
@@ -1064,7 +1209,246 @@ window.__ModuleLoader__.load({
1064
1209
  if (tokens >= 1e3 && tokens % 1e3 === 0) return `${tokens / 1e3}K`;
1065
1210
  return String(tokens);
1066
1211
  }
1067
- function CheckInLogTable({ logs = [], t, onCheckIn, onRefresh, onClear, busy, checkingIn, clearing, disabled, notice }) {
1212
+ /**
1213
+ * Model visibility table with individual toggle switches and batch enable/disable controls.
1214
+ */
1215
+ function ModelSwitchTable({ models, disabledModels = [], t, disabled, onSetModelsEnabled }) {
1216
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
1217
+ const [searchQuery, setSearchQuery] = (0, react.useState)("");
1218
+ const disabledSet = (0, react.useMemo)(() => new Set(disabledModels), [disabledModels]);
1219
+ const list = (0, react.useMemo)(() => models ?? [], [models]);
1220
+ const filteredList = (0, react.useMemo)(() => {
1221
+ const q = searchQuery.trim().toLowerCase();
1222
+ if (!q) return list;
1223
+ return list.filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q));
1224
+ }, [list, searchQuery]);
1225
+ const allFilteredSelected = filteredList.length > 0 && filteredList.every((m) => selected.has(m.id));
1226
+ const toggleSelectAll = () => {
1227
+ if (allFilteredSelected) {
1228
+ const next = new Set(selected);
1229
+ for (const m of filteredList) next.delete(m.id);
1230
+ setSelected(next);
1231
+ } else {
1232
+ const next = new Set(selected);
1233
+ for (const m of filteredList) next.add(m.id);
1234
+ setSelected(next);
1235
+ }
1236
+ };
1237
+ const toggleSelectOne = (id) => {
1238
+ const next = new Set(selected);
1239
+ if (next.has(id)) next.delete(id);
1240
+ else next.add(id);
1241
+ setSelected(next);
1242
+ };
1243
+ const handleBatch = (enabled) => {
1244
+ const targets = Array.from(selected);
1245
+ if (targets.length === 0) return;
1246
+ onSetModelsEnabled(targets, enabled);
1247
+ setSelected(/* @__PURE__ */ new Set());
1248
+ };
1249
+ if (list.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1250
+ style: quotaListStyle,
1251
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1252
+ style: quotaTitleStyle,
1253
+ children: t("modelsHeading")
1254
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1255
+ style: descriptionStyle,
1256
+ children: t("modelsNoModels")
1257
+ })]
1258
+ });
1259
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1260
+ style: quotaListStyle,
1261
+ children: [
1262
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1263
+ style: {
1264
+ display: "flex",
1265
+ flexDirection: "column",
1266
+ gap: 6
1267
+ },
1268
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1269
+ style: quotaTitleStyle,
1270
+ children: t("modelsHeading")
1271
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1272
+ style: descriptionStyle,
1273
+ children: t("modelsSubtitle")
1274
+ })]
1275
+ }),
1276
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1277
+ style: {
1278
+ display: "flex",
1279
+ alignItems: "center",
1280
+ gap: 8
1281
+ },
1282
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1283
+ type: "text",
1284
+ value: searchQuery,
1285
+ placeholder: t("modelsSearchPlaceholder"),
1286
+ onChange: (e) => {
1287
+ setSearchQuery(e.currentTarget.value);
1288
+ },
1289
+ style: patInputStyle
1290
+ }), searchQuery ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1291
+ type: "button",
1292
+ style: buttonStyle$1,
1293
+ onClick: () => {
1294
+ setSearchQuery("");
1295
+ },
1296
+ children: t("cancel")
1297
+ }) : null]
1298
+ }),
1299
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1300
+ style: {
1301
+ display: "flex",
1302
+ alignItems: "center",
1303
+ justifyContent: "space-between",
1304
+ flexWrap: "wrap",
1305
+ gap: 8,
1306
+ padding: "6px 0",
1307
+ borderBottom: "1px solid var(--dsw-alias-border-l2)"
1308
+ },
1309
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1310
+ style: {
1311
+ display: "inline-flex",
1312
+ alignItems: "center",
1313
+ gap: 8,
1314
+ cursor: "pointer",
1315
+ fontSize: 13,
1316
+ userSelect: "none"
1317
+ },
1318
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1319
+ type: "checkbox",
1320
+ checked: allFilteredSelected,
1321
+ disabled: disabled || filteredList.length === 0,
1322
+ onChange: toggleSelectAll
1323
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: allFilteredSelected ? t("modelsDeselectAll") : t("modelsSelectAll") })]
1324
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1325
+ style: {
1326
+ display: "flex",
1327
+ alignItems: "center",
1328
+ gap: 8
1329
+ },
1330
+ children: [
1331
+ selected.size > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1332
+ style: modelRateStyle,
1333
+ children: t("modelsSelectedCount", { count: selected.size })
1334
+ }) : null,
1335
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1336
+ type: "button",
1337
+ style: buttonStyle$1,
1338
+ disabled: disabled || selected.size === 0,
1339
+ onClick: () => {
1340
+ handleBatch(true);
1341
+ },
1342
+ children: t("modelsEnableSelected")
1343
+ }),
1344
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1345
+ type: "button",
1346
+ style: buttonStyle$1,
1347
+ disabled: disabled || selected.size === 0,
1348
+ onClick: () => {
1349
+ handleBatch(false);
1350
+ },
1351
+ children: t("modelsDisableSelected")
1352
+ })
1353
+ ]
1354
+ })]
1355
+ }),
1356
+ filteredList.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1357
+ style: descriptionStyle,
1358
+ children: t("modelsNoMatch", { query: searchQuery.trim() })
1359
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1360
+ style: {
1361
+ display: "flex",
1362
+ flexDirection: "column",
1363
+ gap: 8
1364
+ },
1365
+ children: filteredList.map((model) => {
1366
+ const isModelEnabled = !disabledSet.has(model.id);
1367
+ const isChecked = selected.has(model.id);
1368
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1369
+ style: {
1370
+ display: "flex",
1371
+ alignItems: "center",
1372
+ justifyContent: "space-between",
1373
+ padding: "8px 10px",
1374
+ borderRadius: 6,
1375
+ background: "var(--dsw-alias-bg-layer-2)",
1376
+ gap: 12,
1377
+ opacity: isModelEnabled ? 1 : .65
1378
+ },
1379
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1380
+ style: {
1381
+ display: "inline-flex",
1382
+ alignItems: "center",
1383
+ gap: 10,
1384
+ minWidth: 0,
1385
+ flex: 1
1386
+ },
1387
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1388
+ type: "checkbox",
1389
+ checked: isChecked,
1390
+ disabled,
1391
+ onChange: () => {
1392
+ toggleSelectOne(model.id);
1393
+ }
1394
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1395
+ style: {
1396
+ display: "flex",
1397
+ flexDirection: "column",
1398
+ minWidth: 0
1399
+ },
1400
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1401
+ style: {
1402
+ fontWeight: 500,
1403
+ fontSize: 13,
1404
+ color: "var(--dsw-alias-label-primary)"
1405
+ },
1406
+ children: model.name
1407
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1408
+ style: {
1409
+ fontSize: 11,
1410
+ color: "var(--dsw-alias-label-tertiary)"
1411
+ },
1412
+ children: model.id
1413
+ })]
1414
+ })]
1415
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1416
+ style: {
1417
+ display: "flex",
1418
+ alignItems: "center",
1419
+ gap: 10
1420
+ },
1421
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1422
+ style: {
1423
+ display: "inline-flex",
1424
+ alignItems: "center",
1425
+ gap: 6,
1426
+ cursor: disabled ? "not-allowed" : "pointer",
1427
+ userSelect: "none"
1428
+ },
1429
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1430
+ type: "checkbox",
1431
+ checked: isModelEnabled,
1432
+ disabled,
1433
+ onChange: (event) => {
1434
+ onSetModelsEnabled([model.id], event.currentTarget.checked);
1435
+ }
1436
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1437
+ style: {
1438
+ fontSize: 12,
1439
+ color: isModelEnabled ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)"
1440
+ },
1441
+ children: isModelEnabled ? t("modelsEnabled") : t("modelsDisabled")
1442
+ })]
1443
+ })
1444
+ })]
1445
+ }, model.id);
1446
+ })
1447
+ })
1448
+ ]
1449
+ });
1450
+ }
1451
+ function CheckInLogTable({ logs = [], t, onCheckIn, onRefresh, onClear, busy, checkingIn, clearing, disabled, notice, nextRun }) {
1068
1452
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1069
1453
  style: quotaListStyle,
1070
1454
  children: [
@@ -1114,6 +1498,10 @@ window.__ModuleLoader__.load({
1114
1498
  style: bodyStyle,
1115
1499
  children: notice
1116
1500
  }),
1501
+ nextRun === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1502
+ style: descriptionStyle,
1503
+ children: t("checkInNextRun", { time: formatTime(nextRun) })
1504
+ }),
1117
1505
  !logs || logs.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1118
1506
  style: descriptionStyle,
1119
1507
  children: t("checkInLogEmpty")
@@ -1464,7 +1852,7 @@ window.__ModuleLoader__.load({
1464
1852
  const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
1465
1853
  throw new Error(message);
1466
1854
  }
1467
- if (action.action === "set-maximum-context-window" && (typeof value !== "object" || value === null || value["state"] !== "updated")) {
1855
+ if ((action.action === "set-maximum-context-window" || action.action === "set-models-enabled") && (typeof value !== "object" || value === null || value["state"] !== "updated")) {
1468
1856
  const reason = typeof value === "object" && value !== null && "reason" in value ? String(value["reason"]) : t("requestFailed");
1469
1857
  throw new Error(reason);
1470
1858
  }
@@ -1856,6 +2244,7 @@ window.__ModuleLoader__.load({
1856
2244
  children: [
1857
2245
  "status",
1858
2246
  "context",
2247
+ "models",
1859
2248
  "details",
1860
2249
  "checkin"
1861
2250
  ].map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -1869,7 +2258,7 @@ window.__ModuleLoader__.load({
1869
2258
  ...tabStyle,
1870
2259
  ...tab === id ? tabActiveStyle : {}
1871
2260
  },
1872
- children: t(id === "status" ? "tabStatus" : id === "context" ? "tabContext" : id === "details" ? "tabDetails" : "tabCheckIn")
2261
+ children: t(id === "status" ? "tabStatus" : id === "context" ? "tabContext" : id === "models" ? "tabModels" : id === "details" ? "tabDetails" : "tabCheckIn")
1873
2262
  }, id))
1874
2263
  }),
1875
2264
  tab === "status" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1907,6 +2296,21 @@ window.__ModuleLoader__.load({
1907
2296
  });
1908
2297
  }
1909
2298
  })
2299
+ }) : tab === "models" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2300
+ style: tabPanelStyle,
2301
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelSwitchTable, {
2302
+ models: status.models,
2303
+ disabledModels: status.disabledModels,
2304
+ t,
2305
+ disabled: busy,
2306
+ onSetModelsEnabled: (models, enabled) => {
2307
+ control({
2308
+ action: "set-models-enabled",
2309
+ models,
2310
+ enabled
2311
+ });
2312
+ }
2313
+ })
1910
2314
  }) : tab === "details" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1911
2315
  style: tabPanelStyle,
1912
2316
  children: status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1933,6 +2337,7 @@ window.__ModuleLoader__.load({
1933
2337
  clearing: clearingLogs,
1934
2338
  disabled: status.status !== "signed-in",
1935
2339
  ...checkInNotice === void 0 ? {} : { notice: checkInNotice },
2340
+ ...status.checkIn?.nextRunAt === void 0 ? {} : { nextRun: status.checkIn.nextRunAt },
1936
2341
  onCheckIn: () => {
1937
2342
  manualCheckIn();
1938
2343
  },
@@ -2388,6 +2793,115 @@ window.__ModuleLoader__.load({
2388
2793
  return t("probeNoteUnknown");
2389
2794
  }
2390
2795
  //#endregion
2796
+ //#region src/client/http-settings-scope.ts
2797
+ /** Base path of the host half's settings face. */
2798
+ const ROUTE_BASE = "/plugins/dsh-qoder-connect";
2799
+ /**
2800
+ * Fetch one settings face document.
2801
+ * @param init - the request init (method, headers, body).
2802
+ * @returns the parsed document.
2803
+ * @throws when the transport or the host refuses the request.
2804
+ */
2805
+ async function request(init) {
2806
+ const response = await fetch(`${ROUTE_BASE}/settings`, {
2807
+ credentials: "same-origin",
2808
+ ...init
2809
+ });
2810
+ const value = await response.json().catch(() => void 0);
2811
+ if (!response.ok) {
2812
+ const error = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
2813
+ throw new Error(error);
2814
+ }
2815
+ if (typeof value !== "object" || value === null || !("value" in value)) throw new Error("qoder: settings face answered an invalid document");
2816
+ return value;
2817
+ }
2818
+ /**
2819
+ * The scope the card was written against, backed by this plugin's own file.
2820
+ *
2821
+ * A write optimistically adopts the value it just sent, then confirms with the
2822
+ * host's answer (which re-reads the file it wrote), so the card never renders a
2823
+ * value the host does not hold.
2824
+ */
2825
+ var OwnQuotaSettingsScope = class {
2826
+ document;
2827
+ listeners = /* @__PURE__ */ new Set();
2828
+ snapshot = {
2829
+ status: "loading",
2830
+ value: void 0,
2831
+ writable: true
2832
+ };
2833
+ /**
2834
+ * Load the current document. Call once before the card binds; repeated calls
2835
+ * are harmless and re-read the host.
2836
+ * @returns the effective values.
2837
+ */
2838
+ async load() {
2839
+ try {
2840
+ this.document = await request({ headers: { accept: "application/json" } });
2841
+ } catch {
2842
+ return;
2843
+ }
2844
+ this.publish();
2845
+ return this.document.value;
2846
+ }
2847
+ /** @returns the stable snapshot the card reads (ready once loaded). */
2848
+ getSnapshot() {
2849
+ return this.snapshot;
2850
+ }
2851
+ /** @param listener - invoked after every snapshot change. @returns the disposer. */
2852
+ subscribe(listener) {
2853
+ this.listeners.add(listener);
2854
+ return () => {
2855
+ this.listeners.delete(listener);
2856
+ };
2857
+ }
2858
+ /**
2859
+ * Write one field.
2860
+ * @param field - field name inside the settings document.
2861
+ * @param value - the JSON-shaped value to store.
2862
+ * @returns whether the host accepted the write.
2863
+ */
2864
+ async set(field, value) {
2865
+ return this.patch({ [field]: value });
2866
+ }
2867
+ /**
2868
+ * Clear one field: the value falls back to the schema default.
2869
+ * @param field - field name inside the settings document.
2870
+ * @returns whether the host accepted the clear.
2871
+ */
2872
+ async unset(field) {
2873
+ return this.patch({ [field]: null });
2874
+ }
2875
+ /** Send one patch and adopt the host's answer. */
2876
+ async patch(patch) {
2877
+ const key = this.document?.key;
2878
+ if (key === void 0) return false;
2879
+ try {
2880
+ this.document = await request({
2881
+ method: "POST",
2882
+ headers: {
2883
+ "Content-Type": "application/json",
2884
+ "X-Qoder-Settings-Key": key
2885
+ },
2886
+ body: JSON.stringify(patch)
2887
+ });
2888
+ this.publish();
2889
+ return true;
2890
+ } catch {
2891
+ return false;
2892
+ }
2893
+ }
2894
+ /** Rebuild the cached snapshot and notify the subscribers. */
2895
+ publish() {
2896
+ this.snapshot = {
2897
+ status: this.document === void 0 ? "loading" : "ready",
2898
+ value: this.document?.value,
2899
+ writable: true
2900
+ };
2901
+ for (const listener of this.listeners) listener();
2902
+ }
2903
+ };
2904
+ //#endregion
2391
2905
  //#region src/client/quota-merge.ts
2392
2906
  /**
2393
2907
  * Group credit accounts by (packageName, packageEndTime) and sum each group.
@@ -3212,6 +3726,7 @@ window.__ModuleLoader__.load({
3212
3726
  creditsHeading: "Remaining credit",
3213
3727
  tabStatus: "Status",
3214
3728
  tabContext: "Context window",
3729
+ tabModels: "Model visibility",
3215
3730
  tabDetails: "Credit details",
3216
3731
  tabCheckIn: "Check-in log",
3217
3732
  checkInLogTime: "Check-in time",
@@ -3224,6 +3739,7 @@ window.__ModuleLoader__.load({
3224
3739
  checkInRefreshing: "Refreshing…",
3225
3740
  checkInClear: "Clear logs",
3226
3741
  checkInClearing: "Clearing…",
3742
+ checkInNextRun: "Next automatic check-in: {time}",
3227
3743
  creditsDetailHeading: "By package",
3228
3744
  creditsUsed: "Used this cycle: {percent}%",
3229
3745
  creditsTotalUnlimited: "Total: Unlimited",
@@ -3247,6 +3763,19 @@ window.__ModuleLoader__.load({
3247
3763
  statusRefreshFailed: "Refresh failed: {message} — showing the last known state",
3248
3764
  statusResponseInvalid: "Qoder returned an unreadable status reply",
3249
3765
  accountHeading: "Account",
3766
+ modelsHeading: "Model visibility",
3767
+ modelsSubtitle: "Choose which models are exposed to the DeepSeek Harness model selector.",
3768
+ modelsSearchPlaceholder: "Search by model name or ID…",
3769
+ modelsSelectAll: "Select all",
3770
+ modelsDeselectAll: "Deselect all",
3771
+ modelsEnableSelected: "Enable selected",
3772
+ modelsDisableSelected: "Disable selected",
3773
+ modelsEnabled: "Enabled",
3774
+ modelsDisabled: "Disabled",
3775
+ modelsBatchActions: "Batch actions",
3776
+ modelsSelectedCount: "{count} selected",
3777
+ modelsNoModels: "No models available.",
3778
+ modelsNoMatch: "No models matching \"{query}\".",
3250
3779
  contextHeading: "Context window",
3251
3780
  contextUpTo: "up to {size}",
3252
3781
  contextDefault: "default {size}",
@@ -3288,8 +3817,11 @@ window.__ModuleLoader__.load({
3288
3817
  quotaToggleHint: "Show this account’s remaining credit in the sidebar footer.",
3289
3818
  autoCheckInCN: "Qoder (China) daily auto check-in (100 Credits)",
3290
3819
  autoCheckInGlobal: "Qoder Global (International) daily auto check-in",
3291
- autoCheckInHintCN: "Automatically check in daily at 10:00 (UTC+8) to claim 100 Credits for China account.",
3292
- autoCheckInHintGlobal: "Automatically check in daily at 10:00 (UTC+8) to claim available perks for Global account.",
3820
+ autoCheckInHintCN: "Automatically check in daily to claim 100 Credits for the China account.",
3821
+ autoCheckInHintGlobal: "Automatically check in daily to claim available perks for the Global account.",
3822
+ checkInTimeCN: "Check-in time (China)",
3823
+ checkInTimeGlobal: "Check-in time (Global)",
3824
+ checkInTimeHint: "The moment the automatic check-in runs each day, in UTC+8.",
3293
3825
  autoCheckInStatusClaimed: "Checked in today (+{amount} Credits)",
3294
3826
  autoCheckInStatusAlready: "Already checked in today",
3295
3827
  autoCheckInStatusNoCampaign: "No active benefit campaign today",
@@ -3365,6 +3897,7 @@ window.__ModuleLoader__.load({
3365
3897
  creditsHeading: "剩余额度",
3366
3898
  tabStatus: "状态",
3367
3899
  tabContext: "上下文窗口",
3900
+ tabModels: "模型开关",
3368
3901
  tabDetails: "额度明细",
3369
3902
  tabCheckIn: "签到日志",
3370
3903
  checkInLogTime: "签到时间",
@@ -3377,6 +3910,7 @@ window.__ModuleLoader__.load({
3377
3910
  checkInRefreshing: "正在刷新…",
3378
3911
  checkInClear: "清空日志",
3379
3912
  checkInClearing: "正在清空…",
3913
+ checkInNextRun: "下次自动签到:{time}",
3380
3914
  creditsDetailHeading: "按套餐",
3381
3915
  creditsUsed: "本轮已用 {percent}%",
3382
3916
  creditsTotalUnlimited: "合计:不限额",
@@ -3400,6 +3934,19 @@ window.__ModuleLoader__.load({
3400
3934
  statusRefreshFailed: "刷新失败:{message} — 当前显示的是上次成功获取的状态",
3401
3935
  statusResponseInvalid: "Qoder 返回的状态数据无法识别",
3402
3936
  accountHeading: "账号",
3937
+ modelsHeading: "模型开关",
3938
+ modelsSubtitle: "控制哪些模型在 DeepSeek Harness 模型选择器中可见并可供调用。",
3939
+ modelsSearchPlaceholder: "按模型名称或 ID 搜索…",
3940
+ modelsSelectAll: "全选",
3941
+ modelsDeselectAll: "取消全选",
3942
+ modelsEnableSelected: "批量开启",
3943
+ modelsDisableSelected: "批量关闭",
3944
+ modelsEnabled: "已开启",
3945
+ modelsDisabled: "已关闭",
3946
+ modelsBatchActions: "批量操作",
3947
+ modelsSelectedCount: "已选 {count} 项",
3948
+ modelsNoModels: "暂无可配置的模型。",
3949
+ modelsNoMatch: "未找到匹配“{query}”的模型。",
3403
3950
  contextHeading: "上下文窗口",
3404
3951
  contextUpTo: "最高 {size}",
3405
3952
  contextDefault: "默认 {size}",
@@ -3441,8 +3988,11 @@ window.__ModuleLoader__.load({
3441
3988
  quotaToggleHint: "在侧栏底部展示该账号的剩余额度。",
3442
3989
  autoCheckInCN: "Qoder(国内版)每日自动签到",
3443
3990
  autoCheckInGlobal: "Qoder Global(国际版)每日自动签到",
3444
- autoCheckInHintCN: "每天 10:00 (UTC+8) 自动签到领取 100 Credits 算力额度。",
3445
- autoCheckInHintGlobal: "每天 10:00 (UTC+8) 自动检测并领取国际版可用福利额度。",
3991
+ autoCheckInHintCN: "每天自动签到,为国内版账号领取 100 Credits 算力额度。",
3992
+ autoCheckInHintGlobal: "每天自动签到,为国际版账号领取可用福利额度。",
3993
+ checkInTimeCN: "签到时间(国内版)",
3994
+ checkInTimeGlobal: "签到时间(国际版)",
3995
+ checkInTimeHint: "每天执行自动签到的时刻,按北京时间(UTC+8)。",
3446
3996
  autoCheckInStatusClaimed: "今日已自动签到(+{amount} Credits)",
3447
3997
  autoCheckInStatusAlready: "今日已完成签到",
3448
3998
  autoCheckInStatusNoCampaign: "今日无可用签到福利活动",
@@ -3487,23 +4037,29 @@ window.__ModuleLoader__.load({
3487
4037
  /** Stable browser-plugin name. */
3488
4038
  const name = "dsh-qoder-connect-client";
3489
4039
  /**
3490
- * Client services required by the Plugin configuration contribution.
4040
+ * Client services this bundle requires BEFORE it activates.
3491
4041
  *
3492
4042
  * DSH 0.1.2 removed `@deepseek-ai/dsh-client-runtime` (the package that used to
3493
4043
  * hold the browser `ClientContext` alias and the `slots` service). The services
3494
4044
  * this card relies on now come from narrower packages: the `slots` registry
3495
4045
  * moved to `@deepseek-ai/dsh-client-ui-renderer`, `locale` stayed in
3496
- * `@deepseek-ai/dsh-client-locale`, and the `settings.plugin.item` slot is
3497
- * declared by `@deepseek-ai/dsh-client-ui-settings-plugins`. All three are
3498
- * named in the package's `dsh.client.inject` list, so cordis has activated
3499
- * them before this plugin's fiber starts.
4046
+ * `@deepseek-ai/dsh-client-locale`, and the Plugins tab's card list
4047
+ * (`settings.plugin.item`) is declared by
4048
+ * `@deepseek-ai/dsh-client-ui-settings-plugins`. Those packages are named in
4049
+ * the package's `dsh.client.inject` list.
4050
+ *
4051
+ * NOT declared here: the settings services. `settingsScope` (0.1.5) does not
4052
+ * exist at all on 0.1.7 — a static service dependency on it is what left this
4053
+ * plugin's client activation pending forever — and `configForms` (0.1.7) does
4054
+ * not exist on 0.1.5. Both are waited for with `ctx.inject([...], callback)`
4055
+ * below, where a missing service simply never calls back instead of holding
4056
+ * activation.
3500
4057
  */
3501
4058
  const inject = [
3502
4059
  "slots",
3503
4060
  "locale",
3504
4061
  "remote",
3505
- "remote.session",
3506
- "settingsScope"
4062
+ "remote.session"
3507
4063
  ];
3508
4064
  /** The status routes per variant id (mirrors the host's locked route table). */
3509
4065
  const VARIANT_STATUS = {
@@ -3511,6 +4067,47 @@ window.__ModuleLoader__.load({
3511
4067
  "qoder-global": QODER_GLOBAL_STATUS_PATH
3512
4068
  };
3513
4069
  /**
4070
+ * The shared 《插件设置》 block's slot and entry ids.
4071
+ *
4072
+ * The three connect plugins must agree on these exactly: `settings.section` is
4073
+ * a `list` slot, so many blocks may coexist, but a child slot name may be
4074
+ * declared once and a list entry id may be registered once. The first plugin to
4075
+ * arrive registers the container (declaring the child slot); the others detect
4076
+ * it and attach their own entry, which is why the ids are fixed constants
4077
+ * rather than per-plugin names.
4078
+ */
4079
+ const SHARED_SECTION_SLOT = "settings.section";
4080
+ const SHARED_SECTION_ID = "plugin-settings";
4081
+ const SHARED_SECTION_ORDER = 900;
4082
+ const SHARED_ITEM_SLOT = "plugin-settings.item";
4083
+ /** This plugin's entry id inside the shared block: its package name (unique). */
4084
+ const SHARED_ITEM_ID = "dsh-qoder-connect";
4085
+ /** The shared block's title, fixed by the contract so all three plugins agree. */
4086
+ const SHARED_SECTION_LABEL = "插件设置";
4087
+ /**
4088
+ * The shared 《插件设置》 section the 0.1.7 card list lives in.
4089
+ *
4090
+ * It renders nothing but its child slot: every connect plugin contributes its
4091
+ * own card as one `plugin-settings.item` entry. The list wrapper mirrors the
4092
+ * list the removed Plugins tab used for the same cards (`gap: 10`, no markers),
4093
+ * so the cards keep the spacing they had on 0.1.5.
4094
+ *
4095
+ * @param props - the section's slot props (owner share plus `renderSlot`).
4096
+ * @returns the ordered card list.
4097
+ */
4098
+ function PluginSettingsSection(props) {
4099
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
4100
+ style: {
4101
+ display: "flex",
4102
+ flexDirection: "column",
4103
+ gap: 6,
4104
+ padding: 0,
4105
+ listStyle: "none"
4106
+ },
4107
+ children: props.renderSlot(SHARED_ITEM_SLOT, {})
4108
+ });
4109
+ }
4110
+ /**
3514
4111
  * Register card copy, the shared quota-settings card, the two variant cards,
3515
4112
  * and the sidebar quota cards.
3516
4113
  *
@@ -3542,9 +4139,22 @@ window.__ModuleLoader__.load({
3542
4139
  en
3543
4140
  }), "dsh-qoder-connect: settings copy");
3544
4141
  const t = ctx.locale.bind(namespace);
4142
+ /**
4143
+ * The bound quota settings face, filled in as soon as a host configuration
4144
+ * service becomes available (see the two `ctx.inject` callbacks below).
4145
+ *
4146
+ * It is bound as a SERVICE ARRIVES rather than when the settings card's
4147
+ * inject factory first runs: the factory only executes while the settings
4148
+ * page renders, so a fresh page load read no toggles and rendered no sidebar
4149
+ * card until the user opened settings — the exact regression the
4150
+ * commandcode card avoids by reading its STORED fact independently of the
4151
+ * settings page. The face's subscription mirrors every accepted snapshot
4152
+ * (toggles + interval) into the shared store the sidebar cards and the
4153
+ * dashboard read; a deployment with no configuration service never binds,
4154
+ * and the sidebar cards stay hidden.
4155
+ */
3545
4156
  let quotaScope;
3546
- try {
3547
- const scope = ctx.settingsScope.bind({ namespace: "qoder-quota" });
4157
+ const adoptQuotaScope = (scope) => {
3548
4158
  quotaScope = scope;
3549
4159
  const applySnapshot = () => {
3550
4160
  const value = scope.getSnapshot().value;
@@ -3553,20 +4163,76 @@ window.__ModuleLoader__.load({
3553
4163
  };
3554
4164
  applySnapshot();
3555
4165
  scope.subscribe(applySnapshot);
3556
- } catch (error) {
3557
- console.error("[dsh-qoder-connect] quota settings scope unavailable (sidebar cards stay hidden):", error);
3558
- }
3559
- ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
3560
- name: "settings.plugin.item",
3561
- key: "qoder",
3562
- priority: 50,
3563
- inject: () => ({
4166
+ };
4167
+ const registerUnifiedCard = (slot) => {
4168
+ const inject = () => ({
3564
4169
  t,
3565
4170
  scope: quotaScope,
3566
4171
  signedIn: () => quotaSignInState(),
3567
4172
  unified: true
3568
- })
3569
- }, QoderPluginCard));
4173
+ });
4174
+ if (slot === "settings.plugin.item") return ctx.slots.inject(slot, () => ctx.slots.register({
4175
+ name: slot,
4176
+ key: "qoder",
4177
+ priority: 50,
4178
+ inject
4179
+ }, QoderPluginCard));
4180
+ return ctx.slots.inject(slot, () => ctx.slots.register({
4181
+ name: slot,
4182
+ id: SHARED_ITEM_ID,
4183
+ order: 30,
4184
+ inject
4185
+ }, QoderPluginCard));
4186
+ };
4187
+ /**
4188
+ * Attach this plugin's card to the shared 《插件设置》 block, building the
4189
+ * block first if nobody has.
4190
+ *
4191
+ * Backoff protocol (identical in all three connect plugins, on purpose: a
4192
+ * `list` slot takes many entries but only ONE registration per id, and a
4193
+ * child slot name may be declared once, so the container is a rendezvous
4194
+ * rather than a race to be won):
4195
+ * 1. wait for the host to declare `settings.section`;
4196
+ * 2. if an entry with the shared id is already there, only attach;
4197
+ * 3. otherwise register the container — and if a sibling won the race and
4198
+ * the registration throws, fall back to attaching to theirs.
4199
+ */
4200
+ const joinSharedSettingsBlock = () => {
4201
+ ctx.slots.inject(SHARED_SECTION_SLOT, () => {
4202
+ let disposeContainer;
4203
+ if (!ctx.slots.entries(SHARED_SECTION_SLOT).some((entry) => entry.options?.id === SHARED_SECTION_ID)) try {
4204
+ disposeContainer = ctx.slots.register({
4205
+ name: SHARED_SECTION_SLOT,
4206
+ id: SHARED_SECTION_ID,
4207
+ order: SHARED_SECTION_ORDER,
4208
+ label: () => SHARED_SECTION_LABEL,
4209
+ children: { [SHARED_ITEM_SLOT]: {
4210
+ kind: "list",
4211
+ scope: "root"
4212
+ } }
4213
+ }, PluginSettingsSection);
4214
+ } catch (error) {
4215
+ console.error("[dsh-qoder-connect] shared plugin-settings block lost the race; attaching to the winner:", error);
4216
+ }
4217
+ const disposeItem = registerUnifiedCard(SHARED_ITEM_SLOT);
4218
+ return () => {
4219
+ disposeItem();
4220
+ disposeContainer?.();
4221
+ };
4222
+ });
4223
+ };
4224
+ /**
4225
+ * 插件自有配置(`<profile>/.dsh-qoder-connect/settings.json`):两条宿主线的
4226
+ * 读写都走宿主半的 settings face,不再经过 settingsScope / configForms。
4227
+ *
4228
+ * 0.1.7 的 configForms 写入会整树 reconcile + fiber 热重载(每次约
4229
+ * 1~1.5 秒,且每次保存都刷新所有客户端镜像);自有文件写入是本地毫秒级
4230
+ * 原子写。卡片注册无条件进行:scope 在启动时载入,迟到也不会漏掉 UI。
4231
+ */
4232
+ const ownQuotaScope = new OwnQuotaSettingsScope();
4233
+ ownQuotaScope.load();
4234
+ adoptQuotaScope(ownQuotaScope);
4235
+ joinSharedSettingsBlock();
3570
4236
  const QUOTA_PANEL_ID = "qoder-quota-panel";
3571
4237
  const CONVERSATION_PANEL_ID = "conversation";
3572
4238
  const dashboardDocuments = {