dsh-mindmap 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project are documented here. Release-specific notes are also published on GitHub Releases.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ### Fixed
8
+
9
+ - Settings now wait for a late-arriving client connection instead of permanently capturing its initial absence. The client also supports direct settings descriptors and the current positional update API, while retaining the legacy envelopes.
10
+ - Canvas dragging remains available horizontally and vertically when the map fits inside the viewport. Native scrolling is used first; bounded offset compensation continues the drag at an edge, and Fit or node focus returns the map to center.
11
+ - Mindmap file access now fails closed when the session working directory is unavailable. Reads are limited during streaming rather than trusting a pre-read size check, and updates use a same-directory temporary file plus atomic replacement to avoid partial writes.
12
+ - Pathological Markdown tables now have row, column, and total-cell limits. PNG export rejects oversized canvas allocations instead of risking a browser crash.
13
+ - Switching sessions clears pending mindmap state and ignores stale directory responses; repeated resize events no longer disable automatic canvas fitting.
14
+ - Writes now require native approval by default. Directory opening and focus synchronization never overwrite a non-empty or unreadable chat draft.
15
+ - The tool guidance no longer implies that a placeholder node needs a trailing space: any list item with no text after the marker becomes a placeholder.
16
+
5
17
  ## [0.8.2] - 2026-09-09
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -23,9 +23,9 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
23
23
  - **Single-mindmap mode** — two tabs only: 目录 (tree) and 脑图 (the current mindmap); opening another `.md` replaces the previous one.
24
24
  - **"What you see is what the AI edits"** — when the visible mindmap differs from the AI's working document, the panel automatically asks the AI to open it, keeping the chat focus in sync.
25
25
  - **MarkGrove-style mapping** — heading hierarchy, nested lists (empty items become placeholder nodes), code blocks as leaf nodes, paragraphs promoted to their own nodes (019 block concept), stable structural IDs, and orthogonal connector lines between nodes.
26
- - **Centered canvas with zoom and pan** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas. The canvas also pans by drag: the **middle button** anywhere (even over a node), the **left button on blank canvas** (the Mac trackpad「click and drag」path), or **Space + left button** when the drag must start on a card. Content follows the pointer 1:1, blank space shows a grab hand, and a 4px threshold separates drag from click — so clicking blank space still clears the selection and clicking a node still focuses it, while a real drag never wipes the selection ring.
26
+ - **Centered canvas with zoom and pan** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas. The canvas also pans by drag: the **middle button** anywhere (even over a node), the **left button on blank canvas** (the Mac trackpad「click and drag」path), or **Space + left button** when the drag must start on a card. Panning works in both directions even when content does not overflow; content follows the pointer 1:1, blank space shows a grab hand, and a 4px threshold separates drag from click — so clicking blank space still clears the selection and clicking a node still focuses it, while a real drag never wipes the selection ring.
27
27
  - **PNG export** — one click on 导出图片 exports the current mindmap.
28
- - **Safety** — `mindmap_create` / `mindmap_update` are approval-free by default (files are git-managed) with a `requireApproval` switch as an escape hatch (when on, both write tools ask); the client has **no write path** to the filesystem — every edit goes through the AI tools.
28
+ - **Safety** — `mindmap_create` / `mindmap_update` require native approval by default. Automation can explicitly opt out with `requireApproval: false`; the client has **no write path** to the filesystem — every edit goes through the AI tools.
29
29
 
30
30
  ## Requirements
31
31
 
package/README.zh-CN.md CHANGED
@@ -23,9 +23,9 @@
23
23
  - **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
24
24
  - **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
25
25
  - **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落升格为独立节点(019 块概念)、稳定结构 ID、节点间直角折线。
26
- - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
26
+ - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);无论内容是否溢出都可横纵双向拖动,内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
27
27
  - **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
28
- - **安全**——`mindmap_create`/`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药(开启后两个写工具都走审批);客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
28
+ - **安全**——`mindmap_create`/`mindmap_update` 默认进入原生审批;可通过 `requireApproval: false` 为自动化场景明确关闭。客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
29
29
 
30
30
  ## 环境要求
31
31
 
package/client.js CHANGED
@@ -264,6 +264,11 @@ window.__ModuleLoader__.load({
264
264
  };
265
265
  }
266
266
 
267
+ // 表格网格会按分隔行补齐,单独限额以防小体积输入膨胀成海量单元格。
268
+ const MAX_TABLE_COLUMNS = 100;
269
+ const MAX_TABLE_ROWS = 1000;
270
+ const MAX_TABLE_CELLS = 10000;
271
+
267
272
  /** 缩进宽度:tab 按 4 空格折算。 */
268
273
  function indentWidth(raw) {
269
274
  let width = 0;
@@ -309,7 +314,8 @@ window.__ModuleLoader__.load({
309
314
  if (node.kind === "code") return data.code || node.topic || "";
310
315
  if (node.kind === "table" && Array.isArray(data.rows) && data.rows.length > 0) {
311
316
  // data.rows 不含分隔行(解析时剔除);复制时补回,粘回 Markdown 仍是合法表格。
312
- const lines = data.rows.map((row) => `| ${row.join(" | ")} |`);
317
+ const escapeCell = (cell) => String(cell ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
318
+ const lines = data.rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`);
313
319
  if (data.rows.length > 1) lines.splice(1, 0, `| ${data.rows[0].map(() => "---").join(" | ")} |`);
314
320
  return lines.join("\n");
315
321
  }
@@ -480,30 +486,39 @@ window.__ModuleLoader__.load({
480
486
  // 019 表格块:连续 | 行且第二行为分隔行 → table 节点(003 §5.5,
481
487
  // data.rows 全量保留,单元格不拆子节点)。不满足分隔行条件的 | 行
482
488
  // 按普通段落处理。
483
- if (/^\s*\|/.test(line)) {
489
+ if (/^\s*\|/.test(line) && i + 1 < lineList.length && isTableSeparator(lineList[i + 1])) {
484
490
  const rows = [];
491
+ let truncated = false;
485
492
  let j = i;
486
- for (; j < lineList.length && /^\s*\|/.test(lineList[j]); j++) rows.push(lineList[j]);
487
- if (rows.length >= 2 && isTableSeparator(rows[1])) {
493
+ for (; j < lineList.length && /^\s*\|/.test(lineList[j]); j++) {
494
+ if (rows.length < MAX_TABLE_ROWS) rows.push(lineList[j]);
495
+ else truncated = true;
496
+ }
497
+ if (rows.length >= 2) {
488
498
  flushParagraph();
489
499
  listStack = [];
490
500
  i = j - 1;
491
501
  // GFM 对齐契约:列数钉死在分隔行。表头与数据行同等待遇:
492
502
  // 少列补空、多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
493
- const cols = parseTableRow(rows[1]).length;
503
+ const detectedCols = parseTableRow(rows[1]).length;
504
+ const cols = Math.min(detectedCols, MAX_TABLE_COLUMNS);
505
+ if (detectedCols > cols) truncated = true;
506
+ const maxRows = Math.min(rows.length, Math.max(2, Math.floor(MAX_TABLE_CELLS / cols)));
507
+ if (rows.length > maxRows) truncated = true;
508
+ const visibleRows = rows.slice(0, maxRows);
494
509
  const toCols = (cells) => {
495
510
  if (cells.length >= cols) return cells.slice(0, cols);
496
511
  return cells.concat(new Array(cols - cells.length).fill(""));
497
512
  };
498
- const header = toCols(parseTableRow(rows[0]));
499
- const body = rows.slice(2).map((row) => toCols(parseTableRow(row)));
513
+ const header = toCols(parseTableRow(visibleRows[0]));
514
+ const body = visibleRows.slice(2).map((row) => toCols(parseTableRow(row)));
500
515
  const tableRows = [header].concat(body);
501
516
  appendNode({
502
517
  id: idOf("table", tableRows.map((r) => r.join("\u0001")).join("\u0002"), parentPathOf()),
503
518
  kind: "table",
504
- topic: `${tableRows.length}×${cols} 表格`,
519
+ topic: truncated ? `${tableRows.length}×${cols} 表格(已截断)` : `${tableRows.length}×${cols} 表格`,
505
520
  children: [],
506
- data: { rows: tableRows },
521
+ data: { rows: tableRows, truncated },
507
522
  });
508
523
  continue;
509
524
  }
@@ -954,6 +969,8 @@ window.__ModuleLoader__.load({
954
969
  // 列盒窄于内距时 cellInner 可能为负,钳到至少容纳一个全角字符;
955
970
  // 测量与渲染必须共用同一钳制值(022 契约)。
956
971
  tableCellMinInner: 12,
972
+ maxCanvasDimension: 8192,
973
+ maxCanvasPixels: 16 * 1024 * 1024,
957
974
  };
958
975
 
959
976
  function escapeXml(text) {
@@ -1137,8 +1154,19 @@ window.__ModuleLoader__.load({
1137
1154
  return { svg: parts.join(""), width, height };
1138
1155
  }
1139
1156
 
1157
+ /** Canvas 分配前的硬上限,避免合法但超长的脑图耗尽浏览器内存。 */
1158
+ function exportCanvasSize(width, height) {
1159
+ const w = Math.max(1, Math.ceil(width));
1160
+ const h = Math.max(1, Math.ceil(height));
1161
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w > EXPORT.maxCanvasDimension || h > EXPORT.maxCanvasDimension || w * h > EXPORT.maxCanvasPixels) {
1162
+ throw new Error("脑图图片过大,请缩小导出范围后重试");
1163
+ }
1164
+ return { width: w, height: h };
1165
+ }
1166
+
1140
1167
  /** SVG → Image → 白底 canvas(下载 / 剪贴板共用,017 抽出)。 */
1141
1168
  async function renderSvgToCanvas(svg, width, height) {
1169
+ const size = exportCanvasSize(width, height);
1142
1170
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
1143
1171
  const img = new Image();
1144
1172
  await new Promise((resolve, reject) => {
@@ -1147,8 +1175,8 @@ window.__ModuleLoader__.load({
1147
1175
  img.src = url;
1148
1176
  });
1149
1177
  const canvas = document.createElement("canvas");
1150
- canvas.width = Math.max(1, Math.ceil(width));
1151
- canvas.height = Math.max(1, Math.ceil(height));
1178
+ canvas.width = size.width;
1179
+ canvas.height = size.height;
1152
1180
  const ctx2d = canvas.getContext("2d");
1153
1181
  ctx2d.fillStyle = "#ffffff";
1154
1182
  ctx2d.fillRect(0, 0, canvas.width, canvas.height);
@@ -1925,7 +1953,7 @@ window.__ModuleLoader__.load({
1925
1953
  // 阈值 4px 以内算「点击」:不写 scroll(手抖不挪画布)、不上抓手光标、
1926
1954
  // 不吞随后的 click——保留点空白取消选中 / 点节点聚焦的既有行为。
1927
1955
  // 触摸(触屏)不劫持——交给原生滚动,保住惯性。
1928
- const PAN = { threshold: 4 };
1956
+ const PAN = { threshold: 4, freeRange: 0.5 };
1929
1957
 
1930
1958
  /** 是否在该指针按下上启动平移。button: 0 左 / 1 中 / 2 右。 */
1931
1959
  function shouldStartPan(button, options) {
@@ -1947,6 +1975,18 @@ window.__ModuleLoader__.load({
1947
1975
  };
1948
1976
  }
1949
1977
 
1978
+ /** 原生滚动到边缘后,用视口半宽/高的有界位移继续保持内容跟手。 */
1979
+ function freePanOffset(start, dx, dy, scrollLeft, scrollTop, viewWidth, viewHeight) {
1980
+ const limitX = Number.isFinite(viewWidth) && viewWidth > 0 ? viewWidth * PAN.freeRange : 0;
1981
+ const limitY = Number.isFinite(viewHeight) && viewHeight > 0 ? viewHeight * PAN.freeRange : 0;
1982
+ const actualLeft = Number.isFinite(scrollLeft) ? scrollLeft : start.scrollLeft;
1983
+ const actualTop = Number.isFinite(scrollTop) ? scrollTop : start.scrollTop;
1984
+ return {
1985
+ x: Math.min(limitX, Math.max(-limitX, start.offsetX + dx + actualLeft - start.scrollLeft)),
1986
+ y: Math.min(limitY, Math.max(-limitY, start.offsetY + dy + actualTop - start.scrollTop)),
1987
+ };
1988
+ }
1989
+
1950
1990
  /** 空格键是否落在可输入元素里(聊天框/输入框与面板同 document,不能抢空格)。 */
1951
1991
  function isTextEntry(el) {
1952
1992
  if (!el || typeof el.tagName !== "string") return false;
@@ -2026,6 +2066,9 @@ window.__ModuleLoader__.load({
2026
2066
  // 都要重测一遍(O(n) 强制重排),大树上拖一下会明显顿一下。
2027
2067
  // 光标与「禁用选中」直接改 DOM style,全程零重渲染。
2028
2068
  const panRef = react.useRef(null);
2069
+ // 原生滚动没有余量或已到边缘时的有界补偿位移。同样只写 DOM,
2070
+ // 避免平移触发整棵树重渲染。
2071
+ const panOffsetRef = react.useRef({ x: 0, y: 0 });
2029
2072
  // 021 空格键:按下时左键在节点上也能拖。同样用 ref(按空格不该重渲染)。
2030
2073
  const spaceRef = react.useRef(false);
2031
2074
  // 021 拖过就吞掉随后那次 click(保留单击空白取消选中 / 点节点聚焦)。
@@ -2041,6 +2084,7 @@ window.__ModuleLoader__.load({
2041
2084
  const scroller = scrollRef.current;
2042
2085
  const content = contentRef.current;
2043
2086
  if (!scroller || !content) return;
2087
+ resetPanOffset();
2044
2088
  const rect = content.getBoundingClientRect();
2045
2089
  if (!(rect.width > 0) || !(rect.height > 0)) return;
2046
2090
  const committed = committedZoomRef.current;
@@ -2053,7 +2097,9 @@ window.__ModuleLoader__.load({
2053
2097
  scroller.scrollLeft = 0;
2054
2098
  scroller.scrollTop = 0;
2055
2099
  setZoomState(fit);
2100
+ return true;
2056
2101
  }
2102
+ return false;
2057
2103
  }
2058
2104
 
2059
2105
  // 挂载 / 文档切换:清除「用户已手动缩放」标记与熔断计数,
@@ -2087,6 +2133,7 @@ window.__ModuleLoader__.load({
2087
2133
  if (Math.abs(w - last.w) <= 2 && Math.abs(h - last.h) <= 2) return;
2088
2134
  }
2089
2135
  }
2136
+ if (!applyFit()) return;
2090
2137
  const now = Date.now();
2091
2138
  const stamps = fitStampRef.current = fitStampRef.current.filter((t) => now - t < 1500);
2092
2139
  if (stamps.length >= 5) {
@@ -2094,7 +2141,6 @@ window.__ModuleLoader__.load({
2094
2141
  return;
2095
2142
  }
2096
2143
  stamps.push(now);
2097
- applyFit();
2098
2144
  });
2099
2145
  observer.observe(content);
2100
2146
  observer.observe(scroller);
@@ -2138,6 +2184,18 @@ window.__ModuleLoader__.load({
2138
2184
  scroller.style.userSelect = active ? "none" : "";
2139
2185
  }
2140
2186
 
2187
+ function resetPanOffset() {
2188
+ panOffsetRef.current = { x: 0, y: 0 };
2189
+ const content = contentRef.current;
2190
+ if (content) content.style.transform = "";
2191
+ }
2192
+
2193
+ function applyPanOffset(offset) {
2194
+ const content = contentRef.current;
2195
+ if (!content) return;
2196
+ content.style.transform = offset.x || offset.y ? `translate(${offset.x}px, ${offset.y}px)` : "";
2197
+ }
2198
+
2141
2199
  function beginPan(e) {
2142
2200
  // 吞 click 的标记一律在本轮手势的最开头清零,放在所有早退之前:
2143
2201
  // 否则上轮手势留下的 true 会粘到下一次点击上——例:拖完画布后再
@@ -2156,6 +2214,8 @@ window.__ModuleLoader__.load({
2156
2214
  y: e.clientY,
2157
2215
  scrollLeft: scroller.scrollLeft,
2158
2216
  scrollTop: scroller.scrollTop,
2217
+ offsetX: panOffsetRef.current.x,
2218
+ offsetY: panOffsetRef.current.y,
2159
2219
  moved: false,
2160
2220
  };
2161
2221
  // 不在按下就上抓手光标:普通点击(位移 < 阈值)不该闪一下
@@ -2185,9 +2245,13 @@ window.__ModuleLoader__.load({
2185
2245
  // 首次越过阈值:这才算拖拽——上抓手光标 + 锁文本选择。
2186
2246
  if (!pan.moved) applyPanCursor(scroller, true);
2187
2247
  if (step.moved) pan.moved = true;
2188
- // 越界交给浏览器钳制(不自己算 scrollWidth,省一次重排)。
2248
+ // 先交给浏览器钳制;到边缘后把未被 scroll 消耗的位移补到内容
2249
+ // transform,上下左右始终可拖,且不读 scrollWidth 造成强制重排。
2189
2250
  scroller.scrollLeft = step.scrollLeft;
2190
2251
  scroller.scrollTop = step.scrollTop;
2252
+ const offset = freePanOffset(pan, e.clientX - pan.x, e.clientY - pan.y, scroller.scrollLeft, scroller.scrollTop, scroller.clientWidth, scroller.clientHeight);
2253
+ panOffsetRef.current = offset;
2254
+ applyPanOffset(offset);
2191
2255
  }
2192
2256
 
2193
2257
  function endPan() {
@@ -2209,7 +2273,7 @@ window.__ModuleLoader__.load({
2209
2273
  function setZoom(next, anchorViewport) {
2210
2274
  const value = clampZoom(next);
2211
2275
  anchorRef.current = anchorViewport && scrollRef.current
2212
- ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop }
2276
+ ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop, offsetX: panOffsetRef.current.x, offsetY: panOffsetRef.current.y }
2213
2277
  : null;
2214
2278
  zoomRef.current = value;
2215
2279
  setZoomState(value);
@@ -2230,8 +2294,8 @@ window.__ModuleLoader__.load({
2230
2294
  anchorRef.current = null;
2231
2295
  const ratio = anchor.prevZoom > 0 ? zoom / anchor.prevZoom : 1;
2232
2296
  if (!(ratio > 0) || ratio === 1) return;
2233
- scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2) * ratio - scroller.clientWidth / 2;
2234
- scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2) * ratio - scroller.clientHeight / 2;
2297
+ scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2 - anchor.offsetX) * ratio + anchor.offsetX - scroller.clientWidth / 2;
2298
+ scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2 - anchor.offsetY) * ratio + anchor.offsetY - scroller.clientHeight / 2;
2235
2299
  }, [zoom]);
2236
2300
 
2237
2301
  function zoomIn() {
@@ -2296,6 +2360,7 @@ window.__ModuleLoader__.load({
2296
2360
  focusRef.current = null;
2297
2361
  const scroller = scrollRef.current;
2298
2362
  if (!scroller || !focus || !focus.boxEl || !focus.boxEl.isConnected) return;
2363
+ resetPanOffset();
2299
2364
  const boxRect = focus.boxEl.getBoundingClientRect();
2300
2365
  const scrollerRect = scroller.getBoundingClientRect();
2301
2366
  const curX = boxRect.left + boxRect.width / 2 - scrollerRect.left;
@@ -2623,6 +2688,8 @@ window.__ModuleLoader__.load({
2623
2688
  const [filledHint, setFilledHint] = react.useState("");
2624
2689
  // fsTree:nodes = {path → 节点}, expanded = {path → true}, loading = {path → true}。
2625
2690
  const [fsTree, setFsTree] = react.useState({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
2691
+ // 会话切换时递增,使旧请求的异步回包不能写入新会话的目录树。
2692
+ const treeGenerationRef = react.useRef(0);
2626
2693
  // 013 右键菜单:{x, y, kind: "root"|"dir", rel};null = 关闭。
2627
2694
  const [treeMenu, setTreeMenu] = react.useState(null);
2628
2695
  // tab 右键菜单:{x, y, path}(path === TREE_TAB 时是「刷新目录树」)。
@@ -2749,10 +2816,24 @@ window.__ModuleLoader__.load({
2749
2816
  }, [merged]);
2750
2817
 
2751
2818
  // 013「所见即所编」焦点同步:AI 焦点 = 快照里最新工具结果的文档路径;
2752
- // 脑图视图激活且其文档 ≠ 焦点时,自动填「用 mindmap_open 打开 <它>」
2753
- // 并发送,让 AI 跟上用户眼睛看的那颗脑图。
2819
+ // 脑图视图激活且其文档 ≠ 焦点时,仅在草稿为空时自动发送,让 AI
2820
+ // 跟上用户眼睛看的那颗脑图,又不覆盖用户正在编辑的消息。
2754
2821
  const focusPath = docs.order.length > 0 ? docs.order[docs.order.length - 1] : null;
2755
2822
  const focusSentRef = react.useRef(null);
2823
+ // 所有这些状态都属于会话,不得让 A 会话的在途打开/目录结果遗留到 B。
2824
+ react.useEffect(() => {
2825
+ setLocalDocs({});
2826
+ setCurrentPath(null);
2827
+ setHiddenPath(null);
2828
+ setView("tree");
2829
+ setOpenTimedOut(false);
2830
+ setTreeMenu(null);
2831
+ setTabMenu(null);
2832
+ setFilledHint("");
2833
+ localErrorBaseRef.current = null;
2834
+ focusSentRef.current = null;
2835
+ prevIdsRef.current = { path: null, ids: null };
2836
+ }, [sessionId]);
2756
2837
  react.useEffect(() => {
2757
2838
  if (!open) return; // 面板收起时不自动发消息(014 overlay 形态守卫)
2758
2839
  if (!sessionId) return;
@@ -2760,14 +2841,9 @@ window.__ModuleLoader__.load({
2760
2841
  if (!docs.byPath[active]) return; // 本地占位:它的 open 请求已在途
2761
2842
  if (focusPath === active) return;
2762
2843
  if (focusSentRef.current === active) return; // 已发过,等 AI 结果追平
2763
- if (!inputActions || typeof inputActions.setDraft !== "function") return;
2764
2844
  const rel = fsTree.cwd ? relPathWithin(fsTree.cwd, active, stemOf(active)) : active;
2765
- try {
2766
- inputActions.setDraft(`用 mindmap_open 打开 ${rel}`);
2767
- if (typeof inputActions.submit === "function") inputActions.submit();
2845
+ if (submitEmptyDraft(`用 mindmap_open 打开 ${rel}`)) {
2768
2846
  focusSentRef.current = active;
2769
- } catch {
2770
- // 发送失败:下次 active/focus 变化会再试;也可手动在聊天里说。
2771
2847
  }
2772
2848
  }, [active, focusPath, fsTree.cwd, docs, open]);
2773
2849
 
@@ -2785,11 +2861,32 @@ window.__ModuleLoader__.load({
2785
2861
  }
2786
2862
 
2787
2863
  //#region 013 目录树 tab:懒加载树 + 把指令填进聊天输入框
2788
- // 主路径 = inputActions.setDraft(官方公共面,整串替换草稿);
2789
- // 无则降级剪贴板复制 + 面板内提示。
2864
+ // 只有宿主能确认草稿为空时才允许替换;未知/非空均保守放行给用户,
2865
+ // 不让目录打开或焦点同步静默丢失正在编辑的消息。
2866
+ function draftIsEmpty() {
2867
+ if (!inputActions || typeof inputActions.getDraft !== "function") return false;
2868
+ try {
2869
+ return !String(inputActions.getDraft() ?? "").trim();
2870
+ } catch {
2871
+ return false;
2872
+ }
2873
+ }
2874
+
2875
+ function submitEmptyDraft(text) {
2876
+ if (!draftIsEmpty() || typeof inputActions.setDraft !== "function" || typeof inputActions.submit !== "function") return false;
2877
+ try {
2878
+ inputActions.setDraft(text);
2879
+ inputActions.submit();
2880
+ return true;
2881
+ } catch {
2882
+ return false;
2883
+ }
2884
+ }
2885
+
2886
+ // 手动新建指令也不能覆盖草稿;空草稿时填入,否则复制到剪贴板。
2790
2887
  function fillDraft(text) {
2791
2888
  try {
2792
- if (inputActions && typeof inputActions.setDraft === "function") {
2889
+ if (draftIsEmpty() && typeof inputActions.setDraft === "function") {
2793
2890
  inputActions.setDraft(text);
2794
2891
  setFilledHint("指令已填入聊天输入框");
2795
2892
  return;
@@ -2822,10 +2919,14 @@ window.__ModuleLoader__.load({
2822
2919
  // 就位——AI 工具结果到达后同 path 覆盖占位,节点才渲染;随后用户接着
2823
2920
  // 说即可继续编辑(002 数据流不变:内容只来自 AI 工具结果)。
2824
2921
  function openMindmap(entry) {
2922
+ const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
2923
+ if (!submitEmptyDraft(text)) {
2924
+ setFilledHint("为保护未发送草稿,未自动打开。请发送或清空草稿后重试");
2925
+ return;
2926
+ }
2825
2927
  // 016:记录点击时刻的错误基线(errorByPath 与 latestError 的全部
2826
2928
  // 事件键)——只有其后新出现的错误才归因本次打开,旧错误不打扰。
2827
2929
  localErrorBaseRef.current = errorEventKeys(merged);
2828
- const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
2829
2930
  // ① 本地占位:脑图 tab 立即切过去、内容为空(op:"local" 触发加载态);
2830
2931
  // 新打开的脑图替换旧的那颗(单脑图模式)。
2831
2932
  setHiddenPath(null);
@@ -2842,31 +2943,9 @@ window.__ModuleLoader__.load({
2842
2943
  renamedFrom: null,
2843
2944
  },
2844
2945
  }));
2845
- // ② AI 就位:填指令并直接提交(失败降级剪贴板)。
2846
- let sent = false;
2847
- if (inputActions && typeof inputActions.setDraft === "function") {
2848
- try {
2849
- inputActions.setDraft(text);
2850
- if (typeof inputActions.submit === "function") {
2851
- inputActions.submit();
2852
- sent = true;
2853
- }
2854
- } catch {
2855
- // 落剪贴板降级
2856
- }
2857
- }
2858
- if (sent) {
2859
- // 标记已发,避免焦点同步 effect 对同一路径重复发送。
2860
- focusSentRef.current = entry.path;
2861
- setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
2862
- return;
2863
- }
2864
- if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
2865
- navigator.clipboard.writeText(text).catch(() => {});
2866
- setFilledHint("已复制指令到剪贴板,请粘贴到聊天输入框");
2867
- return;
2868
- }
2869
- setFilledHint(text);
2946
+ // 指令已在占位前提交;标记避免焦点同步对同一路径重复发送。
2947
+ focusSentRef.current = entry.path;
2948
+ setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
2870
2949
  }
2871
2950
 
2872
2951
  /** 016 加载态恢复:错误/超时后重试——重发打开指令并重启看门狗。 */
@@ -2881,11 +2960,13 @@ window.__ModuleLoader__.load({
2881
2960
  // 只读;返回 {path, cwd, entries:[{name,path,isDir,hidden}], truncated}。
2882
2961
  // 返回 true/false 供调用方决定是否标记展开(失败时不要把目录标成已展开)。
2883
2962
  async function loadTree(path) {
2963
+ const generation = treeGenerationRef.current;
2884
2964
  const key = path === undefined || path === null ? "" : path;
2885
2965
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: true }, error: null }));
2886
2966
  try {
2887
2967
  if (!mindmapFace || typeof mindmapFace.listTree !== "function") throw new Error("目录树能力不可用");
2888
2968
  const listing = await mindmapFace.listTree(sessionId, typeof path === "string" && path ? path : undefined);
2969
+ if (generation !== treeGenerationRef.current) return false;
2889
2970
  setFsTree((prev) => {
2890
2971
  const nodes = { ...prev.nodes };
2891
2972
  nodes[listing.path] = {
@@ -2908,6 +2989,7 @@ window.__ModuleLoader__.load({
2908
2989
  });
2909
2990
  return true;
2910
2991
  } catch (error) {
2992
+ if (generation !== treeGenerationRef.current) return false;
2911
2993
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: false }, error: String(error?.message ?? error) }));
2912
2994
  return false;
2913
2995
  }
@@ -2945,6 +3027,7 @@ window.__ModuleLoader__.load({
2945
3027
 
2946
3028
  // 首次挂载:拉根目录(会话 cwd)。
2947
3029
  react.useEffect(() => {
3030
+ treeGenerationRef.current += 1;
2948
3031
  if (!sessionId) return;
2949
3032
  setFsTree({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
2950
3033
  loadTree(undefined);
@@ -3298,7 +3381,12 @@ window.__ModuleLoader__.load({
3298
3381
  * namespaces 兜底,两代通吃。
3299
3382
  */
3300
3383
  function settingsNamespacesOf(res) {
3301
- const value = res?.result?.value;
3384
+ // connection 旧代理包一层 result.value;新版远端直接返回描述符数组。
3385
+ const value = res?.ok === true
3386
+ ? res.value
3387
+ : Array.isArray(res) || Array.isArray(res?.value)
3388
+ ? (Array.isArray(res) ? res : res.value)
3389
+ : res?.result?.value;
3302
3390
  if (Array.isArray(value)) return value;
3303
3391
  const list = value?.namespaces;
3304
3392
  return Array.isArray(list) ? list : [];
@@ -3358,22 +3446,56 @@ window.__ModuleLoader__.load({
3358
3446
  return parsed.value;
3359
3447
  };
3360
3448
 
3361
- // 015 设置面板:settings namespace(dsh-grafana 同款读写面)。
3362
- // connection ctx.get 可选查取(动态 ctx 契约);缺失时设置面板降级提示。
3363
- const connection = ctx.get("connection");
3364
- const settingsApi = connection && typeof connection.api === "object" ? connection.api : null;
3449
+ // 015 设置面板:connection/remote 在客户端插件启动后才可能就绪,不能在
3450
+ // apply 时捕获一次 undefined;每次读写前重新查取,服务晚到也能恢复。
3451
+ // 未声明 inject 的服务只能整名走 ctx.get 可选查取。带点号的服务
3452
+ // (remote.settings)同样是独立服务名:先取 remote 再读 .settings
3453
+ // 被守卫拒绝(cannot get property "remote.settings" without inject),
3454
+ // 故一律传全名,并对任何守卫异常降级为「服务不可用」。
3455
+ function serviceOf(name) {
3456
+ if (typeof ctx.get !== "function") return null;
3457
+ try {
3458
+ return ctx.get(name) ?? null;
3459
+ } catch {
3460
+ return null;
3461
+ }
3462
+ }
3463
+ function isSettingsApi(value) {
3464
+ try {
3465
+ return Boolean(value) && typeof value.describe === "function" && typeof value.update === "function";
3466
+ } catch {
3467
+ return false;
3468
+ }
3469
+ }
3470
+ function settingsApiOf() {
3471
+ const remote = serviceOf("remote.settings");
3472
+ if (isSettingsApi(remote)) return { kind: "remote", settings: remote };
3473
+ try {
3474
+ const settings = serviceOf("connection")?.api?.settings;
3475
+ if (isSettingsApi(settings)) return { kind: "connection", settings };
3476
+ } catch {
3477
+ // 守卫拒绝或连接形态异常:与服务缺失同样降级处理。
3478
+ }
3479
+ return null;
3480
+ }
3365
3481
  face.readSettings = async () => {
3366
- if (!settingsApi || typeof settingsApi.settings?.describe !== "function") return null;
3367
- const res = await settingsApi.settings.describe({});
3482
+ const api = settingsApiOf();
3483
+ if (!api) return null;
3484
+ const res = api.kind === "remote" ? await api.settings.describe() : await api.settings.describe({});
3368
3485
  const namespaces = settingsNamespacesOf(res);
3369
3486
  const ns = namespaces.find((n) => n?.ns === "mindmap");
3370
3487
  return ns?.value ?? null;
3371
3488
  };
3372
3489
  face.updateSettings = async (patch) => {
3373
- if (!settingsApi || typeof settingsApi.settings?.update !== "function") {
3490
+ const api = settingsApiOf();
3491
+ if (!api) {
3374
3492
  throw new Error("settings service unavailable");
3375
3493
  }
3376
- await settingsApi.settings.update({ ns: "mindmap", patch });
3494
+ if (api.kind === "remote" || api.settings.update.length !== 1) {
3495
+ await api.settings.update("mindmap", patch, undefined);
3496
+ } else {
3497
+ await api.settings.update({ ns: "mindmap", patch });
3498
+ }
3377
3499
  };
3378
3500
 
3379
3501
  // 015 设置面板:settings.section(list 槽、root scope)——设置页左栏
@@ -3416,6 +3538,7 @@ window.__ModuleLoader__.load({
3416
3538
  stemOf,
3417
3539
  buildExportSvg,
3418
3540
  measureExportBox,
3541
+ exportCanvasSize,
3419
3542
  createIdFactory,
3420
3543
  collectTreeIds,
3421
3544
  planGrowthReveal,
package/index.js CHANGED
@@ -9,12 +9,13 @@
9
9
  // - 四工具都带 path/name 参数(决策 3:多脑图并存,作用于指定那颗)。
10
10
  // - 结果 JSON {ok, op, path, rootTitle, content, renamedFrom?}:content 全文
11
11
  // 供模型续编辑,client 用同一份重放面板(工具结果即实时通道,002 第二节)。
12
- // - requireApproval 配置(决策 6):默认 false 免审批;置 true mindmap_create
13
- // mindmap_update 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
12
+ // - requireApproval 配置(决策 6):默认 true;仅明确关闭时 mindmap_create
13
+ // mindmap_update 可免原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
14
14
  // 015 起经 settings namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
15
15
  // - 依赖:仅 @deepseek-ai/schemastery(settings schema;发布包正常解析,
16
16
  // link 开发需先 npm i)。工具参数 schema 仍手写 JSON Schema(003 偏差 1)。
17
- import { access, opendir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
17
+ import { access, open, opendir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
18
+ import { randomUUID } from 'node:crypto'
18
19
  import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'
19
20
  import Schema from '@deepseek-ai/schemastery'
20
21
 
@@ -26,7 +27,7 @@ export const inject = ['tools', 'systemPrompt', 'webServer', 'sessions']
26
27
  // defaultPanelWidth 供客户端面板取默认宽度(20-80 钳制由客户端执行)。
27
28
  export const SETTINGS_NAMESPACE = 'mindmap'
28
29
  export const Config = Schema.object({
29
- requireApproval: Schema.boolean().default(false).description('Require native DSH approval for every mindmap_create and mindmap_update (including renameRoot). Files are git-managed, so it defaults to off. Hidden from the settings panel; still honored at runtime.'),
30
+ requireApproval: Schema.boolean().default(true).description('Require native DSH approval for every mindmap_create and mindmap_update (including renameRoot). Enabled by default and hidden from the settings panel; still honored at runtime.'),
30
31
  defaultPanelWidth: Schema.number().default(42).description('Default floating-panel width as a percentage of the viewport (clamped 20-80 on the client).'),
31
32
  lineStyle: Schema.union(['curve', 'elbow']).default('elbow').description('Connector line style between nodes: curve (bezier) or elbow (orthogonal).'),
32
33
  cardStyle: Schema.union(['rounded', 'square']).default('rounded').description('Node card corner style.'),
@@ -51,7 +52,7 @@ Tools:
51
52
  - mindmap_get(path): read the current markdown content.
52
53
  - mindmap_update(path, content, renameRoot?): write the FULL updated markdown. renameRoot renames the file to match a new root title (fails on name collision); use it only when the user asks to rename the root node.
53
54
 
54
- Markdown mapping (the panel's parser): headings nest by level (H1 are root children, H2 under the previous H1, ...); list items are child nodes nested by 2-space indentation; an EMPTY list item ("- " followed by nothing) renders as a placeholder node — use placeholders for planned-but-unwritten nodes; a fenced code block becomes a leaf node titled "[lang] first line"; plain paragraphs become the note text of the nearest heading.
55
+ Markdown mapping (the panel's parser): headings nest by level (H1 are root children, H2 under the previous H1, ...); list items are child nodes nested by 2-space indentation; a list item with no text after the marker renders as a placeholder node (both "-" and "- " work; no trailing space is required) — use placeholders for planned-but-unwritten nodes; a fenced code block becomes a leaf node titled "[lang] first line"; plain paragraphs become the note text of the nearest heading.
55
56
 
56
57
  Behavior rules:
57
58
  - When the user asks to create a mindmap, call mindmap_create. When the user asks to open, view, show, or switch to an existing mindmap, call mindmap_open (do not use mindmap_get alone). Both operations bring that document to the visible mindmap panel automatically.
@@ -245,17 +246,14 @@ function sanitizeStem(input) {
245
246
 
246
247
  /**
247
248
  * 解析脑图文件路径:相对路径以会话 cwd 为基;结果必须落在 cwd 内(决策 1),
248
- * 且必须以 .md 结尾。cwd 缺失时仅接受绝对路径。
249
+ * 且必须以 .md 结尾。cwd 是文件授权边界,缺失时必须失败关闭。
249
250
  * @returns 绝对规范化路径。
250
251
  */
251
252
  async function resolveMindmapPath(cwd, input) {
252
253
  if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
253
254
  const p = input.trim()
254
255
  if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
255
- if (!cwd) {
256
- if (!isAbsolute(p)) throw new Error('The session has no working directory; pass an absolute .md path.')
257
- return resolvePath(p)
258
- }
256
+ if (!cwd) throw new Error('The session has no working directory; cannot access a mindmap.')
259
257
  const resolved = resolvePath(cwd, p)
260
258
  const rel = relative(cwd, resolved)
261
259
  // realpath 兜底(#5):写路径经符号链接越狱是安全敏感操作。
@@ -293,15 +291,53 @@ function byteLength(value) {
293
291
  }
294
292
 
295
293
  /**
296
- * mindmap_open / mindmap_get 共用的读取路径:先 stat 查文件大小(不为测
297
- * 大小读全量),超限即拒;不存在时 stat 抛 ENOENT,readFile 的原语义不变。
294
+ * mindmap_open / mindmap_get 共用的有界读取。句柄打开后持续分块读取,文件在
295
+ * 初检后增长也不会绕过上限;非常规文件直接拒绝。
298
296
  */
299
297
  async function readMindmap(path) {
300
- const { size } = await stat(path)
301
- if (size > MAX_READ_BYTES) {
302
- throw new Error(`mindmap size exceeds the ${MAX_READ_BYTES}-byte limit.`)
298
+ const handle = await open(path, 'r')
299
+ try {
300
+ const info = await handle.stat()
301
+ if (!info.isFile()) throw new Error('mindmap path must be a regular file.')
302
+ if (info.size > MAX_READ_BYTES) {
303
+ throw new Error(`mindmap size exceeds the ${MAX_READ_BYTES}-byte limit.`)
304
+ }
305
+ const chunks = []
306
+ let total = 0
307
+ let position = 0
308
+ for (;;) {
309
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_READ_BYTES + 1 - total))
310
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position)
311
+ if (bytesRead === 0) break
312
+ total += bytesRead
313
+ if (total > MAX_READ_BYTES) {
314
+ throw new Error(`mindmap size exceeds the ${MAX_READ_BYTES}-byte limit.`)
315
+ }
316
+ chunks.push(chunk.subarray(0, bytesRead))
317
+ position += bytesRead
318
+ }
319
+ return Buffer.concat(chunks, total).toString('utf8')
320
+ } finally {
321
+ await handle.close()
322
+ }
323
+ }
324
+
325
+ /** 同目录临时文件完整落盘后再替换,任何写入失败都保留旧内容。 */
326
+ async function writeMindmap(path, content) {
327
+ const temp = join(dirname(path), `.${randomUUID()}.mindmap-tmp`)
328
+ const { mode } = await stat(path)
329
+ let handle
330
+ try {
331
+ handle = await open(temp, 'wx', mode & 0o777)
332
+ await handle.writeFile(content, 'utf8')
333
+ await handle.sync()
334
+ await handle.close()
335
+ handle = undefined
336
+ await rename(temp, path)
337
+ } finally {
338
+ if (handle) await handle.close().catch(() => {})
339
+ await unlink(temp).catch(() => {})
303
340
  }
304
- return readFile(path, 'utf8')
305
341
  }
306
342
 
307
343
  /** 工具结果信封:client 面板与模型共用的唯一载体。 */
@@ -320,7 +356,7 @@ export function apply(ctx, config = {}) {
320
356
  // 入口配置作为 settings 组合层的 base:视觉三件套与默认宽度在这里
321
357
  // 透传(带 schema 同款默认值),用户设置层仍可在设置面板覆盖。
322
358
  const entryConfig = {
323
- requireApproval: config.requireApproval === true,
359
+ requireApproval: config.requireApproval !== false,
324
360
  defaultPanelWidth: typeof config.defaultPanelWidth === 'number' ? config.defaultPanelWidth : 42,
325
361
  lineStyle: config.lineStyle === 'curve' ? 'curve' : 'elbow',
326
362
  cardStyle: config.cardStyle === 'square' ? 'square' : 'rounded',
@@ -344,7 +380,7 @@ export function apply(ctx, config = {}) {
344
380
  ctx.systemPrompt.section({ name: 'tool:mindmap', order: 106, text: GUIDANCE })
345
381
 
346
382
  // 后悔药开关(决策 6):钩子常驻注册,运行时读 activeConfig().requireApproval
347
- // ——设置面板切换立即生效;关闭时直接放行(默认 false 免审批)。
383
+ // ——设置面板切换立即生效;仅明确关闭时直接放行。
348
384
  // 022:create 同为写路径,一并纳入审批(CONTRIBUTING:所有写路径安全敏感)。
349
385
  ctx.on('tools/pre-execute', async (exec, next) => {
350
386
  const decision = await next()
@@ -479,8 +515,8 @@ export function apply(ctx, config = {}) {
479
515
  finalPath = target
480
516
  }
481
517
  }
482
- if (hasContent) await writeFile(finalPath, args.content, 'utf8')
483
- const content = hasContent ? args.content : await readFile(finalPath, 'utf8')
518
+ if (hasContent) await writeMindmap(finalPath, args.content)
519
+ const content = hasContent ? args.content : await readMindmap(finalPath)
484
520
  return buildResult('update', finalPath, { content, ...(renamedFrom ? { renamedFrom } : {}) })
485
521
  },
486
522
  }))
@@ -540,6 +576,7 @@ export const internals = Object.freeze({
540
576
  MAX_CONTENT_BYTES,
541
577
  MAX_READ_BYTES,
542
578
  readMindmap,
579
+ writeMindmap,
543
580
  sanitizeStem,
544
581
  resolveMindmapPath,
545
582
  sessionCwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Mindmap plugin for DeepSeek Harness: a plain markdown file in the working directory IS the mindmap; the chat edits it step by step and the right-side panel follows live.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,7 +46,8 @@
46
46
  "inject": [
47
47
  "@deepseek-ai/dsh-client-runtime",
48
48
  "@deepseek-ai/dsh-cordis-client-runner",
49
- "@deepseek-ai/dsh-client-ui-layout"
49
+ "@deepseek-ai/dsh-client-ui-layout",
50
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
50
51
  ],
51
52
  "platform": "web",
52
53
  "immediately": true