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 +12 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/client.js +186 -63
- package/index.js +57 -20
- package/package.json +3 -2
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.
|
|
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`
|
|
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 +
|
|
26
|
+
- **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);无论内容是否溢出都可横纵双向拖动,内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
|
|
27
27
|
- **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
|
|
28
|
-
- **安全**——`mindmap_create`/`mindmap_update`
|
|
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
|
|
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++)
|
|
487
|
-
|
|
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
|
|
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(
|
|
499
|
-
const body =
|
|
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 =
|
|
1151
|
-
canvas.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
|
-
//
|
|
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
|
-
// 脑图视图激活且其文档 ≠
|
|
2753
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
-
|
|
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 设置面板:
|
|
3362
|
-
//
|
|
3363
|
-
|
|
3364
|
-
|
|
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
|
-
|
|
3367
|
-
|
|
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
|
-
|
|
3490
|
+
const api = settingsApiOf();
|
|
3491
|
+
if (!api) {
|
|
3374
3492
|
throw new Error("settings service unavailable");
|
|
3375
3493
|
}
|
|
3376
|
-
|
|
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):默认
|
|
13
|
-
//
|
|
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,
|
|
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(
|
|
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;
|
|
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
|
|
297
|
-
*
|
|
294
|
+
* mindmap_open / mindmap_get 共用的有界读取。句柄打开后持续分块读取,文件在
|
|
295
|
+
* 初检后增长也不会绕过上限;非常规文件直接拒绝。
|
|
298
296
|
*/
|
|
299
297
|
async function readMindmap(path) {
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
483
|
-
const content = hasContent ? args.content : await
|
|
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.
|
|
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
|