dsh-custom-mode 1.9.14 → 1.9.16

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/README.md CHANGED
@@ -47,7 +47,7 @@ The mode is **web-profile only**: `agent-presets`, the service that mounts prese
47
47
 
48
48
  ## Requirements
49
49
 
50
- - **dsh `>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0`**(latest stable `0.1.5-rc.2` and the preview line `0.1.6-alpha.2` / `0.1.7-alpha.*`)** — declared in `engines.dsh` and as an *optional* peer range. This package never
50
+ - **dsh `>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0`**(两条线:最新正式版 `0.1.5-rc.3` 与最新预览版 `0.1.7-rc.2`;更早的 `0.1.6-alpha.*` 与正式版同一套机制,仍在范围内但不单独测)** — declared in `engines.dsh` and as an *optional* peer range. This package never
51
51
  imports `@deepseek-ai/dsh`; it only reads the host services dsh injects.
52
52
  - **No build step, no dependencies**: the source is published as-is and uses only Node's standard library.
53
53
  - The package version is its own line (`1.0.0`, `1.0.1`, …). Which dsh it supports is declared in
package/client.js CHANGED
@@ -148,6 +148,7 @@ try {
148
148
  "api.metaWriteFailed": "写入 preset.yml 失败:{detail}",
149
149
  "api.promptReadFailed": "读取提示词失败:{detail}",
150
150
  "api.noRemoveApi": "当前 DSH 版本没有 agentPresets.remove(),无法删除。",
151
+ "api.noDirectory": "删除失败:本机找不到这个助手的目录,页面上的列表可能是旧的。刷新一次再试。",
151
152
  "api.deleteFailed": "删除失败:{detail}",
152
153
  "api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
153
154
  "api.badJson": "请求体不是合法 JSON",
@@ -361,6 +362,7 @@ try {
361
362
  "api.metaWriteFailed": "Writing preset.yml failed: {detail}",
362
363
  "api.promptReadFailed": "Reading the prompt failed: {detail}",
363
364
  "api.noRemoveApi": "This DSH build has no agentPresets.remove(), so deleting is unavailable.",
365
+ "api.noDirectory": "Delete failed: this assistant's directory is gone from disk, so the list on screen is stale. Refresh and try again.",
364
366
  "api.deleteFailed": "Delete failed: {detail}",
365
367
  "api.versionMissing": "That version is gone (the history is capped).",
366
368
  "api.badJson": "The request body is not valid JSON",
@@ -608,11 +610,57 @@ try {
608
610
  return { Button, Input, Switch, Tag, Pill }
609
611
  }
610
612
 
613
+ /**
614
+ * Whether an atom can be handed to `createElement`.
615
+ *
616
+ * ★ **This is where the page's native look was lost.** The probe used to be
617
+ * `typeof atoms.Button === "function"`, but the shell's components are `forwardRef(...)` /
618
+ * `memo(...)` **objects** (`Button = forwardRef(function Button(){…})`, measured), so the check
619
+ * failed and *every* control silently fell back to the plain stand-ins — hand-painted buttons,
620
+ * `input[type=checkbox]` instead of the shell's switch, a native `window.confirm` instead of its
621
+ * risk dialog. Measured on 0.1.7-rc.2 (2026-09-25): `require(ATOMS_MODULE)` returns **279 exports**
622
+ * including `Button`, `RiskConfirmation`, `Modal`, `Menu`, `SegmentedControl` and the icon set —
623
+ * the module was in the platform seed table the whole time.
624
+ * React renders any object carrying `$$typeof`, so that is what "usable" means here.
625
+ */
626
+ const renderable = (atom) =>
627
+ typeof atom === "function" || (atom !== null && typeof atom === "object" && atom.$$typeof !== undefined)
628
+
629
+ /**
630
+ * The page asks for icons under short names (`IconPlusOutline16`); the shell exports
631
+ * `IconPlusOutlineRegular`. Without this map the calls were `undefined` and **no icon ever
632
+ * rendered** — which is part of why the header row looked hand-made.
633
+ */
634
+ const ICON_ALIAS = {
635
+ IconChevronDownOutline14: "IconChevronDownOutlineRegular",
636
+ IconChevronUpOutline14: "IconChevronUpOutlineRegular",
637
+ IconChevronLeftOutline14: "IconChevronLeftOutlineRegular",
638
+ IconPlusOutline16: "IconPlusOutlineRegular",
639
+ IconCheckOutline14: "IconCheckOutlineRegular",
640
+ IconCopyOutline16: "IconCopyOutlineRegular",
641
+ IconDownloadOutline16: "IconDownloadOutlineRegular",
642
+ IconRefreshOutline16: "IconRefreshOutlineRegular",
643
+ IconTrashOutline16: "IconTrashOutlineRegular",
644
+ }
645
+
646
+ function withIconAliases(atoms) {
647
+ const out = Object.assign({}, atoms)
648
+ for (const short of Object.keys(ICON_ALIAS)) {
649
+ if (out[short] === undefined && renderable(atoms[ICON_ALIAS[short]])) out[short] = atoms[ICON_ALIAS[short]]
650
+ }
651
+ return out
652
+ }
653
+
611
654
  /** Resolve the atom set once, at factory time. */
612
655
  function loadAtoms() {
613
656
  try {
614
657
  const atoms = require(ATOMS_MODULE)
615
- if (atoms !== null && typeof atoms === "object" && typeof atoms.Button === "function") return atoms
658
+ if (atoms !== null && typeof atoms === "object" && renderable(atoms.Button)) return withIconAliases(atoms)
659
+ console.info(
660
+ "dsh-custom-mode: 壳提供了 " +
661
+ ATOMS_MODULE +
662
+ ",但里面没有可用的 Button,改用内置的朴素控件(功能一致,外观更简)。",
663
+ )
616
664
  } catch (error) {
617
665
  console.info(
618
666
  "dsh-custom-mode: 当前壳没有在种子表里提供 " +
@@ -636,44 +684,54 @@ try {
636
684
  */
637
685
  const CSS = [
638
686
  ".cpfe{--g:8px;display:flex;flex-direction:column;gap:24px;width:100%;max-width:900px;box-sizing:border-box;padding-bottom:16px}",
639
- ".cpfe-h{margin:0 0 4px;font-size:15px;line-height:22px;color:var(--dsw-alias-label-primary)}",
640
- ".cpfe-sub{margin:0 0 12px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}",
641
- // 一行提示 + 详情下拉(与插件行同一套语言)
642
- ".cpfe-hint{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 10px}",
643
- ".cpfe-hint-line{flex:1;min-width:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
644
- ".cpfe-hint-toggle{flex:0 0 auto;width:20px;height:20px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:11px;line-height:1}",
645
- ".cpfe-hint-toggle:hover{background:var(--dsw-alias-bg-layer-2)}",
646
- ".cpfe-hint-detail{flex:1 0 100%;margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}",
687
+ // ★ 区块标题对齐官方设置页的度量(实测 0.1.7-rc.2:区块标题 14px/22px w500,引言 12px/18px
688
+ // tertiary)。原来是 15px w700 —— 在官方页面里那看起来就是"另一个产品"。
689
+ ".cpfe-h{margin:0 0 2px;font-size:14px;line-height:22px;font-weight:500;color:var(--dsw-alias-label-primary)}",
690
+ ".cpfe-sub{margin:0 0 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
691
+ // 区块引言:官方是标题下的一行 12px 灰字 —— 没有折叠按钮,也没有那个蓝色圆点。
692
+ ".cpfe-hint{display:flex;flex-direction:column;gap:2px;margin:0 0 4px}",
693
+ ".cpfe-hint-line{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
694
+ ".cpfe-hint-detail{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
647
695
  // 描述:多行、自适应高度(没有多行输入组件,所以用 textarea + 同一批语义变量)
648
696
  ".cpfe-desc{box-sizing:border-box;min-height:56px;max-height:200px;resize:vertical;padding:8px 12px;border-radius:10px;border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;margin-bottom:8px}",
649
697
  ".cpfe-base-pending{color:var(--dsw-alias-state-warn-primary)}",
650
698
  ".cpfe-note{display:block;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}",
651
699
  ".cpfe-mono{display:block;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
652
700
  ".cpfe-pills{display:flex;flex-wrap:wrap;gap:6px;align-items:center}",
701
+ ".cpfe-picker{display:flex;align-items:center;gap:8px;margin:2px 0 4px}",
702
+ // 壳的菜单浮层靠一个独立的 backing 元素上色(实测在 0.1.7-rc.2 + headless 下那块是透明的,
703
+ // 文字会"压"在下面的输入框上)。用官方 token 显式补一层底色,真实浏览器与 headless 一致。
704
+ ".cpfe-menu{background:var(--dsw-menu-surface-fill,var(--dsw-alias-bg-layer-3));border-radius:var(--dsw-radius-lg);box-shadow:var(--dsw-elevation-soft)}",
705
+ ".cpfe-history{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}",
706
+ ".cpfe-history-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
707
+ ".cpfe-history-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
653
708
  ".cpfe-pill-wrap{display:inline-flex;align-items:center;gap:4px}",
654
709
  ".cpfe-actions{display:flex;flex-wrap:wrap;gap:var(--g);align-items:center;margin-top:4px}",
655
710
  ".cpfe-newrow{display:flex;gap:var(--g);align-items:center;flex-wrap:wrap;margin-top:10px}",
656
711
  ".cpfe-field{display:flex;width:100%;margin-bottom:8px}",
657
- ".cpfe-row-head{font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary)}",
712
+ ".cpfe-row-head{font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}",
658
713
  ".cpfe-row-switch{flex:0 0 auto}",
659
714
  ".cpfe-row-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}",
660
- ".cpfe-row-note{min-height:16px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
715
+ ".cpfe-row-note{min-height:18px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis}",
661
716
  ".cpfe-row-toggle{flex:0 0 auto;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:11px;line-height:1}",
662
717
  ".cpfe-row-toggle:hover{background:var(--dsw-alias-bg-layer-2)}",
663
718
  ".cpfe-row-open{border-color:var(--dsw-alias-border-l2)}",
664
- ".cpfe-row-detail{display:flex;flex-direction:column;gap:4px;margin:0 0 2px 14px;padding:8px 12px;border-left:.5px solid var(--dsw-alias-border-l1);font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary)}",
719
+ ".cpfe-row-detail{display:flex;flex-direction:column;gap:4px;margin:0 0 12px;padding:0 0 0 16px;border:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
665
720
  ".cpfe-detail-line{display:flex;gap:8px;min-width:0}",
666
721
  ".cpfe-detail-key{flex:0 0 62px;color:var(--dsw-alias-label-secondary)}",
667
722
  ".cpfe-detail-value{min-width:0;overflow-wrap:anywhere}",
668
723
  ".cpfe-sec-head{display:flex;align-items:center;justify-content:space-between;gap:var(--g);flex-wrap:wrap}",
669
724
  ".cpfe-newinput{flex:1;min-width:200px}",
670
725
  // 单列、行高统一(对齐官方插件页的形态);之前的自适应多列网格会让行高参差不齐。
671
- ".cpfe-rows{display:flex;flex-direction:column;gap:6px;margin-top:4px}",
672
- ".cpfe-line{display:flex;flex-direction:column;gap:6px;min-width:0}",
673
- ".cpfe-group{display:flex;flex-direction:column;gap:6px;margin-top:8px;min-width:0}",
674
- ".cpfe-kids{display:flex;flex-direction:column;gap:6px;margin-left:14px;padding-left:14px;border-left:.5px solid var(--dsw-alias-border-l1)}",
675
- ".cpfe-row{display:flex;gap:10px;align-items:center;box-sizing:border-box;min-height:52px;padding:8px 12px;border-radius:10px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}",
676
- ".cpfe-row-meta{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}",
726
+ // ★ 对齐官方设置行(实测 0.1.7-rc.2 的行:padding 16px 0、无背景、无圆角,行间只有一条 1px
727
+ // 分隔线)。原来是"圆角卡片 + 描边 + 灰底" —— 那正是廉价感最重的部分。
728
+ ".cpfe-rows{display:flex;flex-direction:column;gap:0;margin-top:0}",
729
+ ".cpfe-line{display:flex;flex-direction:column;min-width:0;border-bottom:1px solid var(--dsw-alias-border-l1)}",
730
+ ".cpfe-line:last-child{border-bottom:0}",
731
+ ".cpfe-group{display:flex;flex-direction:column;min-width:0}",
732
+ ".cpfe-kids{display:flex;flex-direction:column;margin-left:0;padding-left:16px}",
733
+ ".cpfe-row{display:flex;gap:12px;align-items:center;box-sizing:border-box;min-height:0;padding:16px 0;border:0;border-radius:0;background:none}",
734
+ ".cpfe-row-meta{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1}",
677
735
  ".cpfe-row-badges{display:flex;align-items:center;gap:6px;flex-wrap:wrap}",
678
736
  ".cpfe-editor{box-sizing:border-box;width:100%;min-height:240px;resize:vertical;padding:12px;border-radius:10px;border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:20px}",
679
737
  ".cpfe-bar{display:flex;align-items:center;gap:var(--g);flex-wrap:wrap}",
@@ -823,30 +881,14 @@ try {
823
881
  * the page scannable without dropping a word of the explanation.
824
882
  */
825
883
  function SectionHint(props) {
826
- const { id, hint, detail, expanded, onToggleExpand, t } = props
827
- const open = expanded[id] === true
828
- const collapsible = typeof detail === "string" && detail !== "" && detail !== hint
884
+ const { hint, detail } = props
885
+ // 官方设置页的区块引言就是标题下的一段 12px 灰字:没有圆点、没有折叠按钮,也**不重复**。
886
+ // 有完整说明就用完整说明(它是短句的超集),否则用短句 —— 两个都印会在屏幕上出现两遍。
887
+ const text = typeof detail === "string" && detail !== "" ? detail : hint
829
888
  return react.createElement(
830
889
  "div",
831
890
  { className: "cpfe-hint" },
832
- react.createElement("span", { className: "cpfe-hint-line", title: hint }, hint),
833
- collapsible === false
834
- ? null
835
- : react.createElement(
836
- "button",
837
- {
838
- type: "button",
839
- className: "cpfe-hint-toggle",
840
- "aria-expanded": open,
841
- "aria-label": t(open ? "aria.collapse" : "aria.expandHint"),
842
- title: t(open ? "aria.collapse" : "aria.expandHint"),
843
- onClick: () => onToggleExpand(id),
844
- },
845
- open ? "▾" : "▸",
846
- ),
847
- open === true && collapsible === true
848
- ? react.createElement("p", { className: "cpfe-hint-detail" }, detail)
849
- : null,
891
+ react.createElement("span", { className: "cpfe-hint-line" }, text),
850
892
  )
851
893
  }
852
894
 
@@ -1051,6 +1093,9 @@ try {
1051
1093
  * differs per user, so nothing is expanded by default and nothing is persisted.
1052
1094
  */
1053
1095
  const [expandedRows, setExpandedRows] = react.useState({})
1096
+ // 两个官方风格的下拉(壳的 `Menu`):助手切换与改动历史。壳不提供 Menu 时退回原生控件。
1097
+ const [assistantOpen, setAssistantOpen] = react.useState(false)
1098
+ const [historyOpen, setHistoryOpen] = react.useState(false)
1054
1099
  /** 本机这条线上有无法解析的行时,一键把它们关掉(服务端复用保存同一条排版手术)。 */
1055
1100
  const repairRowsNow = async () => {
1056
1101
  if (draft === null) return
@@ -1533,30 +1578,70 @@ try {
1533
1578
  : assistants.length === 0
1534
1579
  ? react.createElement("p", { className: "cpfe-sub" }, t("assistant.empty"))
1535
1580
  : react.createElement(
1581
+ // 官方的"选择"就是壳的 `Menu`:一个灰底按钮 + chevron,浮层里是选项
1582
+ // (通用设置页的「权限 / 语言 / 工作步骤展示」都是这个形态)。原来是一排 pill
1583
+ // 药丸 + 描边,是页面上最"不像官方"的一块。
1536
1584
  "div",
1537
- { className: "cpfe-pills cpfe-assistants" },
1538
- assistants.map((item) =>
1539
- react.createElement(
1540
- "span",
1541
- { key: item.id, className: "cpfe-pill-wrap" },
1542
- react.createElement(
1543
- A.Pill,
1544
- {
1545
- active: item.id === selected,
1546
- disabled: busy,
1547
- title: item.id,
1548
- onClick: () => pick(item.id),
1585
+ { className: "cpfe-picker" },
1586
+ renderable(A.Menu)
1587
+ ? react.createElement(A.Menu, {
1588
+ open: assistantOpen,
1589
+ // ★ portal:不把浮层渲染在我们的容器里。设置面板有裁剪与叠层,非 portal 的
1590
+ // 菜单会被裁掉表面,只剩文字"压"在下面的输入框上(实测截图如此)。
1591
+ portal: true,
1592
+ listClassName: "cpfe-menu",
1593
+ align: "start",
1594
+ side: "bottom",
1595
+ selectedId: selected,
1596
+ anchor: react.createElement(
1597
+ A.Button,
1598
+ {
1599
+ variant: "ghost",
1600
+ size: "sm",
1601
+ disabled: busy,
1602
+ icon: renderable(A.IconChevronDownOutline14)
1603
+ ? react.createElement(A.IconChevronDownOutline14, { size: 14 })
1604
+ : null,
1605
+ onClick: () => setAssistantOpen((open) => open !== true),
1606
+ },
1607
+ (assistants.find((item) => item.id === selected) || {}).name || selected || t("assistant.heading"),
1608
+ ),
1609
+ items: assistants.map((item) => ({
1610
+ id: item.id,
1611
+ label:
1612
+ (item.name || item.id) +
1613
+ (dirtyIds.has(item.id) ? " · " + t("msg.unsaved") : "") +
1614
+ (typeof item.broken === "string" && item.broken !== "" ? " · " + t("assistant.broken") : ""),
1615
+ })),
1616
+ onSelect: (id) => {
1617
+ setAssistantOpen(false)
1618
+ pick(id)
1549
1619
  },
1550
- item.name || item.id,
1620
+ onClose: () => setAssistantOpen(false),
1621
+ })
1622
+ : react.createElement(
1623
+ "div",
1624
+ { className: "cpfe-pills cpfe-assistants" },
1625
+ assistants.map((item) =>
1626
+ react.createElement(
1627
+ "span",
1628
+ { key: item.id, className: "cpfe-pill-wrap" },
1629
+ react.createElement(
1630
+ A.Pill,
1631
+ { active: item.id === selected, disabled: busy, title: item.id, onClick: () => pick(item.id) },
1632
+ item.name || item.id,
1633
+ ),
1634
+ dirtyIds.has(item.id) ? react.createElement(A.Tag, { tone: "warning" }, t("msg.unsaved")) : null,
1635
+ typeof item.broken === "string" && item.broken !== ""
1636
+ ? react.createElement(A.Tag, { tone: "danger" }, t("assistant.broken"))
1637
+ : null,
1638
+ ),
1639
+ ),
1551
1640
  ),
1552
- dirtyIds.has(item.id)
1553
- ? react.createElement(A.Tag, { tone: "warning" }, t("msg.unsaved"))
1554
- : null,
1555
- typeof item.broken === "string" && item.broken !== ""
1556
- ? react.createElement(A.Tag, { tone: "danger" }, t("assistant.broken"))
1557
- : null,
1558
- ),
1559
- ),
1641
+ dirtyIds.has(selected) ? react.createElement(A.Tag, { tone: "warning" }, t("msg.unsaved")) : null,
1642
+ typeof (assistants.find((item) => item.id === selected) || {}).broken === "string"
1643
+ ? react.createElement(A.Tag, { tone: "danger" }, t("assistant.broken"))
1644
+ : null,
1560
1645
  ),
1561
1646
  react.createElement(
1562
1647
  "div",
@@ -1577,7 +1662,7 @@ try {
1577
1662
  react.createElement(
1578
1663
  A.Button,
1579
1664
  {
1580
- variant: "primary",
1665
+ variant: "outline",
1581
1666
  icon: A.IconPlusOutline16 === undefined ? null : react.createElement(A.IconPlusOutline16),
1582
1667
  disabled: busy,
1583
1668
  onClick: () => create(),
@@ -1832,22 +1917,62 @@ try {
1832
1917
  "div",
1833
1918
  { className: "cpfe-history" },
1834
1919
  react.createElement("span", { className: "cpfe-history-label" }, t("history.label")),
1835
- react.createElement(
1836
- "select",
1837
- {
1838
- value: draft.historyPick,
1839
- "aria-label": t("history.label"),
1840
- onChange: (event) => update({ historyPick: event.target.value }),
1841
- },
1842
- react.createElement("option", { value: "" }, t("history.pick")),
1843
- ...draft.history.map((entry) =>
1844
- react.createElement(
1845
- "option",
1846
- { key: String(entry.n), value: String(entry.n) },
1847
- formatWhen(entry.at) + " · " + t("history.by." + entry.by) + " · " + String(entry.bytes) + " B",
1920
+ // 与助手切换同一个形态:官方的下拉是壳的 Menu(灰底按钮 + chevron + 浮层),
1921
+ // 而不是浏览器原生 `<select>`(后者的外观由浏览器决定,与官方界面完全不同)。
1922
+ renderable(A.Menu)
1923
+ ? react.createElement(A.Menu, {
1924
+ open: historyOpen,
1925
+ portal: true,
1926
+ listClassName: "cpfe-menu",
1927
+ align: "start",
1928
+ side: "bottom",
1929
+ selectedId: draft.historyPick,
1930
+ anchor: react.createElement(
1931
+ A.Button,
1932
+ {
1933
+ variant: "ghost",
1934
+ size: "sm",
1935
+ disabled: busy,
1936
+ icon: renderable(A.IconChevronDownOutline14)
1937
+ ? react.createElement(A.IconChevronDownOutline14, { size: 14 })
1938
+ : null,
1939
+ onClick: () => setHistoryOpen((open) => open !== true),
1940
+ },
1941
+ draft.historyPick === ""
1942
+ ? t("history.pick")
1943
+ : (() => {
1944
+ const entry = draft.history.find((item) => String(item.n) === draft.historyPick)
1945
+ return entry === undefined
1946
+ ? t("history.pick")
1947
+ : formatWhen(entry.at) + " · " + t("history.by." + entry.by)
1948
+ })(),
1949
+ ),
1950
+ items: draft.history.map((entry) => ({
1951
+ id: String(entry.n),
1952
+ label: formatWhen(entry.at) + " · " + t("history.by." + entry.by) + " · " + String(entry.bytes) + " B",
1953
+ })),
1954
+ onSelect: (id) => {
1955
+ setHistoryOpen(false)
1956
+ update({ historyPick: id })
1957
+ },
1958
+ onClose: () => setHistoryOpen(false),
1959
+ })
1960
+ : react.createElement(
1961
+ "select",
1962
+ {
1963
+ value: draft.historyPick,
1964
+ "aria-label": t("history.label"),
1965
+ onChange: (event) => update({ historyPick: event.target.value }),
1966
+ },
1967
+ react.createElement("option", { value: "" }, t("history.pick")),
1968
+ ...draft.history.map((entry) =>
1969
+ react.createElement(
1970
+ "option",
1971
+ { key: String(entry.n), value: String(entry.n) },
1972
+ formatWhen(entry.at) + " · " + t("history.by." + entry.by) + " · " + String(entry.bytes) + " B",
1973
+ ),
1974
+ ),
1848
1975
  ),
1849
- ),
1850
- ),
1851
1976
  react.createElement(
1852
1977
  A.Button,
1853
1978
  {
package/index.mjs CHANGED
@@ -733,11 +733,14 @@ export function repairComposition(rows, input) {
733
733
  }
734
734
  }
735
735
 
736
- export async function deleteAssistant(rows, input, agentPresets) {
736
+ export async function deleteAssistant(rows, input, remover) {
737
737
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
738
- if (assistantDir(rows, id) === undefined) return unknownAssistant(id)
739
- if (typeof agentPresets?.remove !== 'function') {
740
- return { ok: false, code: 'noRemoveApi', error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
738
+ const directory = assistantDir(rows, id)
739
+ if (directory === undefined) return unknownAssistant(id)
740
+ // `remover` 是**按后端选出来的**删除入口(见 {@link presetRemover}):旧线是平台的
741
+ // `agentPresets.remove()`,新线是声明式后端的 dispose + 删目录。两边都没有时才是真的做不到。
742
+ if (remover === undefined || remover === null || typeof remover.remove !== 'function') {
743
+ return { ok: false, code: 'noRemoveApi', error: '当前 DSH 版本没有可用的删除入口(agentPresets.remove() 与声明式后端都不可用),无法删除。' }
741
744
  }
742
745
  // 文案用**显示名**,不用内部目录 id(复制/删除的状态行曾把 id 暴露给用户,审阅点名)。
743
746
  const displayName = (() => {
@@ -745,9 +748,17 @@ export async function deleteAssistant(rows, input, agentPresets) {
745
748
  return typeof row?.name === 'string' && row.name.trim() !== '' ? row.name : id
746
749
  })()
747
750
  try {
748
- await agentPresets.remove(id)
751
+ const outcome = await remover.remove(id, directory)
752
+ if (outcome !== null && typeof outcome === 'object' && outcome.ok === false) {
753
+ // 字面量 code(不用三元表达式):test/editor-route.test.mjs 会扫 `code:` 后面的字符串字面量,
754
+ // 要求每个都在词典里中英各一条 —— 三元里的 'string' 会被它当成一个 code。
755
+ if (outcome.code === 'noDirectory') {
756
+ return { ok: false, code: 'noDirectory', params: { path: directory }, error: '删除失败:找不到「' + displayName + '」的目录。' }
757
+ }
758
+ return { ok: false, code: 'deleteFailed', params: { detail: describe(outcome.code ?? 'unknown'), path: directory }, error: '删除失败:' + describe(outcome.code ?? 'unknown') }
759
+ }
749
760
  } catch (error) {
750
- return { ok: false, code: 'deleteFailed', params: { detail: describe(error) }, error: '删除失败:' + describe(error) }
761
+ return { ok: false, code: 'deleteFailed', params: { detail: describe(error), path: directory }, error: '删除失败:' + describe(error) }
751
762
  }
752
763
  return { ok: true, id, code: 'deleted', params: { name: displayName }, note: '已删除「' + displayName + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
753
764
  }
@@ -912,6 +923,18 @@ export function apply(ctx) {
912
923
  ? createDeclarativeBackend({ scope, log: console.log, warn: console.error })
913
924
  : null
914
925
 
926
+ /**
927
+ * 删除入口,按后端选。新线上服务**没有** `remove()`(唯一手段是 `register()` 的 disposer),
928
+ * 所以由声明式后端自己 dispose + 删目录;旧线继续用平台的文件系统语义。
929
+ *
930
+ * 这里返回 `undefined` 只发生在"两套都没有"的线上,那时才允许报 `noRemoveApi`。
931
+ */
932
+ const presetRemover = () => {
933
+ if (declarative !== null) return { remove: (id, dir) => declarative.remove(id, dir) }
934
+ if (typeof scope.agentPresets?.remove === 'function') return { remove: (id) => scope.agentPresets.remove(id) }
935
+ return undefined
936
+ }
937
+
915
938
  /** 原始 roster:只读、无副作用。同步逻辑走它,避免与 {@link roster} 互相递归。 */
916
939
  const rawRoster = async () => {
917
940
  const rows = await scope.agentPresets.list()
@@ -1134,7 +1157,7 @@ export function apply(ctx) {
1134
1157
  const result = await serializedWrite('tree', async () => reorderAssistant(await roster(), parsed))
1135
1158
  return json(result, result.ok === true ? 200 : 400)
1136
1159
  }
1137
- const result = await serializedWrite('delete:' + targetId, async () => deleteAssistant(await roster(), parsed, scope.agentPresets))
1160
+ const result = await serializedWrite('delete:' + targetId, async () => deleteAssistant(await roster(), parsed, presetRemover()))
1138
1161
  return json(result, result.ok === true ? 200 : 400)
1139
1162
  } catch (error) {
1140
1163
  // 兜底也要带 code:这是**意料之外**的异常,页面拿到的是 Node 的原始错误串,方向反着也一样糟
package/locales.mjs CHANGED
@@ -108,6 +108,7 @@ export const zh = {
108
108
  'api.metaWriteFailed': '写入 preset.yml 失败:{detail}',
109
109
  'api.promptReadFailed': '读取提示词失败:{detail}',
110
110
  'api.noRemoveApi': '当前 DSH 版本没有 agentPresets.remove(),无法删除。',
111
+ 'api.noDirectory': '删除失败:本机找不到这个助手的目录,页面上的列表可能是旧的。刷新一次再试。',
111
112
  'api.deleteFailed': '删除失败:{detail}',
112
113
  'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
113
114
  'api.badJson': '请求体不是合法 JSON',
@@ -339,6 +340,7 @@ export const en = {
339
340
  'api.metaWriteFailed': 'Writing preset.yml failed: {detail}',
340
341
  'api.promptReadFailed': 'Reading the prompt failed: {detail}',
341
342
  'api.noRemoveApi': 'This DSH build has no agentPresets.remove(), so deleting is unavailable.',
343
+ 'api.noDirectory': "Delete failed: this assistant's directory is gone from disk, so the list on screen is stale. Refresh and try again.",
342
344
  'api.deleteFailed': 'Delete failed: {detail}',
343
345
  'api.versionMissing': 'That version is gone (the history is capped).',
344
346
  'api.badJson': 'The request body is not valid JSON',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.9.14",
3
+ "version": "1.9.16",
4
4
  "description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), pick its base mode, switch plugin rows one by one, and keep several assistants side by side — coding, chat or roleplay (RP) personas (multi-mode / multi-agent).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,8 @@
12
12
  * 相对路径;旧线上它相对 preset 目录解析,而 `register()` 在 profile 的 baseUrl 下挂载。
13
13
  * 实测:相对路径 → `broken: … never started`;绝对 `file://` URL → 正常 mount。
14
14
  * ⇒ `toPluginRows()` 必须把相对说明符改写成绝对 URL。
15
- * 2. **`register()` 的返回值是 disposer**,注销唯一手段;服务上没有 `remove()`。
15
+ * 2. **`register()` 的返回值是 disposer**,注销唯一手段;服务上没有 `remove()` —— 所以"删除助手"
16
+ * 由本后端的 {@link remove} 自己做(dispose + 删目录),宿主那半不再要求平台提供 `remove()`。
16
17
  * 3. **同一个 id 重复注册会抛 `Duplicate agent preset`**
17
18
  * ⇒ 重新挂载前必须先 dispose 旧的。
18
19
  *
@@ -22,7 +23,7 @@
22
23
  * `agent.cordis.yml` 仍然写盘 —— 它是用户可见、可迁移、可手改的真相,也是旧线唯一的输入。
23
24
  */
24
25
 
25
- import { existsSync, readFileSync } from 'node:fs'
26
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
26
27
  import { join } from 'node:path'
27
28
  import { pathToFileURL } from 'node:url'
28
29
  import { parseComposition } from './parse-composition.mjs'
@@ -163,6 +164,29 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
163
164
  for (const id of [...mounted.keys()]) await unmount(id)
164
165
  }
165
166
 
167
+ /**
168
+ * 删除一个助手:**先注销,再删目录**。
169
+ *
170
+ * 这条线上服务没有 `remove()`(唯一手段是 `register()` 返回的 disposer),所以"删除"必须由本插件
171
+ * 自己完成 —— 而这正是设置页在 0.1.7 上**根本删不掉助手**的原因:宿主那半只认 `agentPresets.remove()`,
172
+ * 拿不到就回一个类型化的 `noRemoveApi`,页面弹完确认框只显示"删除失败"。
173
+ *
174
+ * 顺序不能反:先注销,选择器立刻不再提供它;再删目录。删目录失败时**如实抛出**(调用方转成
175
+ * `deleteFailed` 并把路径带给用户),而不是报告"已删除"却把目录留在磁盘上 —— 那样下次同步
176
+ * 会把它重新挂回来,用户看到的是"删了又回来了"。
177
+ *
178
+ * @param {string} id - assistant id.
179
+ * @param {string} [dir] - its directory; when omitted, the one remembered at mount time is used.
180
+ */
181
+ async function remove(id, dir) {
182
+ const remembered = mounted.get(id)?.dir
183
+ await unmount(id)
184
+ const target = typeof dir === 'string' && dir !== '' ? dir : remembered
185
+ if (typeof target !== 'string' || target === '') return { ok: false, code: 'noDirectory', id }
186
+ rmSync(target, { recursive: true, force: true })
187
+ return { ok: true, id, dir: target }
188
+ }
189
+
166
190
  /**
167
191
  * Fetch the shipped base compositions the host declares, as entry-list YAML.
168
192
  *
@@ -205,6 +229,7 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
205
229
  sync,
206
230
  mountOne,
207
231
  unmount,
232
+ remove,
208
233
  disposeAll,
209
234
  fetchBaseCompositions,
210
235
  mountedIds: () => [...mounted.keys()],