dsh-mindmap 0.8.1 → 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 +70 -13
- package/README.md +5 -5
- package/README.zh-CN.md +5 -5
- package/client.js +195 -66
- package/index.js +68 -16
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,22 +4,42 @@ All notable changes to this project are documented here. Release-specific notes
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
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
|
+
|
|
17
|
+
## [0.8.2] - 2026-09-09
|
|
18
|
+
|
|
7
19
|
### Added
|
|
8
20
|
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
- Node right-click menu gains「复制全文」: copies a node's complete own content to the clipboard (code blocks take the full fenced source, table blocks re-emit a valid Markdown table with the separator row restored, everything else takes the raw text), alongside the existing 复制为图片 / 导出为图片. A divider line separates the node title from the actions.
|
|
15
|
-
- Hover overlay for truncated content: prose blocks cut by the line clamp and code blocks share one floating panel (fixed-positioned outside the zoom layer, scrollable full text); it always docks to the right of the node box and never flips left — clicking a node auto-focuses it into view, so hovering again always shows everything.
|
|
16
|
-
- Canvas panning (021): the mindmap canvas can now be dragged around with the middle mouse button (anywhere, even over a node), the left button on blank canvas space (this is the Mac trackpad「click and drag」path), or Space + left button (even over a node, when the drag has to start on a card). Content follows the pointer 1:1, blank space shows a grab hand, and the canvas no longer chains its scroll to the host page at the edges. 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 no longer wipes the selection ring on release. Built on pointer events with pointer capture, so the pointer keeps dragging even outside the panel; touch input is left to native scrolling so momentum survives; and the gesture never re-renders the tree (cursor and text-selection lock go straight to the DOM, because a re-render would re-measure every `TreeRow`). Refinements from review (`_issues/001`): sub-threshold jitter no longer writes `scroll` or flashes the grab cursor (a 1–3px hand tremor on a plain click used to shift the canvas, and because the formula is absolute from the press anchor, skipping those early writes still tracks the pointer 1:1 — the full displacement lands in one jump once the threshold is crossed); the grab cursor and the text-selection lock are now applied on the first crossing rather than on press; a pan whose `setPointerCapture` failed no longer hangs when the pointer is released outside the scroller (a later button-less move ends the pan instead of dragging the canvas with nothing held); the click-suppression flag is cleared at the very top of every gesture rather than after the early returns, so a finished drag can no longer leak into a later tap that takes a non-panning path (touch tap, left-press on a node) and swallow it; and a second pointer can no longer drive an existing pan's anchor.
|
|
21
|
+
- Read-size guard for `mindmap_open` / `mindmap_get`: both tools `stat` the file first and refuse to read anything larger than 2 MB (`MAX_READ_BYTES`, the same cap as writes), so opening an accidentally huge document can no longer flood the model context.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Export table rendering: the per-cell wrap width is clamped to at least 12px (`tableCellMinInner`, one full-width character) in both box measurement and SVG drawing, so extremely wide tables (e.g. 60 columns) can never degenerate into one-character-per-line stacking.
|
|
17
26
|
|
|
18
27
|
### Changed
|
|
19
28
|
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
|
|
29
|
+
- Changelog realignment: entries that had shipped with 0.5.0–0.8.1 are moved from `[Unreleased]` into their own version sections, split by the real git tag boundaries; README and README.zh-CN updated to reflect the dual dsh compatibility (`0.1.1-rc.2` + `0.1.2-rc.1`), paragraph promotion (019 block concept), and the published release tag.
|
|
30
|
+
- Dependabot: major-version upgrades of `actions/checkout` and `actions/setup-node` (≥5) are ignored until evaluated separately (the two hanging PRs were closed).
|
|
31
|
+
|
|
32
|
+
## [0.8.1] - 2026-09-08
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `@deepseek-ai/dsh-tools` peer dependency with a four-range version window (`^0.1.0-rc.6 || ^0.1.1-rc.0 || ^0.1.2-alpha.0 || ^0.1.3-alpha.0`), so plugin installs resolve cleanly across the harness tooling line.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- Dependency refresh (`@deepseek-ai/cosmokit` 1.8.2 → 1.8.3, `@deepseek-ai/schemastery` 3.18.1 → 3.18.2, lockfile updated accordingly); `.gitignore` also ignores `_*` and `._*` at the repository root.
|
|
41
|
+
|
|
42
|
+
## [0.8.0] - 2026-09-04
|
|
23
43
|
|
|
24
44
|
### Fixed
|
|
25
45
|
|
|
@@ -28,8 +48,11 @@ All notable changes to this project are documented here. Release-specific notes
|
|
|
28
48
|
- **Live panel data**: the session snapshot no longer carries a flat `nodes` array (conversation content moved to the Chat view), so the panel now reads nodes via `useChat → ChatSnapshot.legacy.nodes` (0.1.2-rc.1+) first and falls back to `useSession → SessionSnapshot.nodes` (dsh ≤0.1.1). `ToolResultNode` field names are unchanged across both, so document replay, auto-open, and error surfacing work as before.
|
|
29
49
|
- **Settings panel**: `settings.describe` over the client connection now returns the descriptor array directly (0.1.2-rc.1+) instead of an aggregate `result.value.namespaces` (dsh ≤0.1.1); both envelope shapes are parsed.
|
|
30
50
|
- **Hygiene**: `dsh.client.inject` additionally lists `@deepseek-ai/dsh-cordis-client-runner` (the 0.1.2-rc.1 browser runtime module id; `@deepseek-ai/dsh-client-runtime` is kept for 0.1.1-rc.2 — a missing id is silently skipped by the loader, so both coexist safely).
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
|
|
52
|
+
## [0.7.0] - 2026-08-31
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
33
56
|
- A code-review sweep (`_issues/002`) landed 11 fixes, each with a failing regression test first:
|
|
34
57
|
- **Markdown parsing**: an unclosed `---` frontmatter fence at the top no longer swallows the whole document into an empty tree; the root-title echo merge (`firstTopH1Seen`) is set only by a top-level H1 so an earlier H2 or a heading inside a quote block no longer suppresses it; GFM table column count is pinned to the **separator row** (short rows padded, extra cells dropped) instead of the header; fenced code blocks close only on a matching fence type (`~~~` is no longer ended by a ``` line) and `parseTableRow` honors an escaped `\|` preceded by an escaped backslash (`\\|`).
|
|
35
58
|
- **Document snapshots**: `mergeDocuments` only deletes a dropped local entry when no live snapshot owns the same path, so a recreated file that a snapshot also references keeps its panel openable.
|
|
@@ -37,6 +60,40 @@ All notable changes to this project are documented here. Release-specific notes
|
|
|
37
60
|
- **Host safety & approval**: the tree route and all mindmap path resolutions now reject symlink escapes out of the working directory via a `realpath` walk-up (`resolvesInsideBase`); `requireApproval` gates `mindmap_create` as well as `mindmap_update`; and `listDirectoryLevel` stops iterating once it hits the entry cap (perf) while still flagging truncation.
|
|
38
61
|
- **PNG export**: wide tables measure their box height using the same clamped per-column width the renderer uses, so wrapped cell text no longer overflows the measured box bottom.
|
|
39
62
|
|
|
63
|
+
## [0.6.0] - 2026-08-30
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- Canvas panning (021): the mindmap canvas can now be dragged around with the middle mouse button (anywhere, even over a node), the left button on blank canvas space (this is the Mac trackpad「click and drag」path), or Space + left button (even over a node, when the drag has to start on a card). Content follows the pointer 1:1, blank space shows a grab hand, and the canvas no longer chains its scroll to the host page at the edges. 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 no longer wipes the selection ring on release. Built on pointer events with pointer capture, so the pointer keeps dragging even outside the panel; touch input is left to native scrolling so momentum survives; and the gesture never re-renders the tree (cursor and text-selection lock go straight to the DOM, because a re-render would re-measure every `TreeRow`). Refinements from review (`_issues/001`): sub-threshold jitter no longer writes `scroll` or flashes the grab cursor (a 1–3px hand tremor on a plain click used to shift the canvas, and because the formula is absolute from the press anchor, skipping those early writes still tracks the pointer 1:1 — the full displacement lands in one jump once the threshold is crossed); the grab cursor and the text-selection lock are now applied on the first crossing rather than on press; a pan whose `setPointerCapture` failed no longer hangs when the pointer is released outside the scroller (a later button-less move ends the pan instead of dragging the canvas with nothing held); the click-suppression flag is cleared at the very top of every gesture rather than after the early returns, so a finished drag can no longer leak into a later tap that takes a non-panning path (touch tap, left-press on a node) and swallow it; and a second pointer can no longer drive an existing pan's anchor.
|
|
68
|
+
|
|
69
|
+
## [0.5.1] - 2026-08-26
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- Block concept for mindmap nodes (per the 003 living spec): markdown content is now parsed into typed blocks — text, markdown (inline formatting), code (compact summary line + hover overlay for the full source), quote, and table (full grid, cells never split) — while block-level structure syntax (headings/lists/quotes) is split into child nodes and plain paragraphs are promoted to nodes instead of being stuffed into a parent's description. Inline formatting never triggers splitting.
|
|
74
|
+
- Unified clickable links: any URL inside any block is rendered complete (never abbreviated), clickable, and opens in a new browser tab; links inside the hover overlay are clickable too.
|
|
75
|
+
|
|
76
|
+
## [0.5.0] - 2026-08-26
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- Step-by-step update convention for the AI (system prompt + `mindmap_update` tool description): when an edit has several parts, call `mindmap_update` as soon as each part is ready instead of one giant update at the end — each call still carries the FULL document, and the panel's growth animation makes the tree visibly grow while the AI works. The former "panel updates in one step" wording is removed as it pointed the other way.
|
|
81
|
+
- Progressive growth animation for the mindmap panel: after every update, newly added/changed nodes fade in one by one (breadth-first from root to leaves, staggered up to 90ms apart) instead of the whole tree popping in at once. Diffing rides on the existing stable structural node IDs, so unchanged nodes never flicker or replay; the total duration is compressed to stay within ~2s for large documents; connector lines fade in together with their new children. The layout is computed in full up front, so the animation is compositor-only (opacity/transform) with no reflow cost. A new `growthAnimation` setting (settings panel「面板」→「生长动画」, default on) turns it off entirely, and `prefers-reduced-motion` is respected. The `mindmap_update` contract is unchanged (still full markdown).
|
|
82
|
+
- Theme token system: node colors/corners/shadows/states are produced by the pure function `resolveNodeStyle` from theme tokens, so card style and color theme switch cleanly without touching layout code.
|
|
83
|
+
- Node right-click menu gains「复制全文」: copies a node's complete own content to the clipboard (code blocks take the full fenced source, table blocks re-emit a valid Markdown table with the separator row restored, everything else takes the raw text), alongside the existing 复制为图片 / 导出为图片. A divider line separates the node title from the actions.
|
|
84
|
+
- Hover overlay for truncated content: prose blocks cut by the line clamp and code blocks share one floating panel (fixed-positioned outside the zoom layer, scrollable full text); it always docks to the right of the node box and never flips left — clicking a node auto-focuses it into view, so hovering again always shows everything.
|
|
85
|
+
|
|
86
|
+
### Changed
|
|
87
|
+
|
|
88
|
+
- Length governance for node boxes (020 revision, replacing unbounded full-wrap): every node box is capped at 320px wide so long content wraps instead of stretching into horizontal bars. Prose blocks (text/md/list/quote-topic) that still exceed 6 wrapped lines are clamped with an ellipsis — full text stays reachable via the hover overlay. The clamp uses an integer 20px line-height plus a hard `maxHeight` guard so the cut line never leaks half a glyph. Structural blocks keep their shape: tables get their own wider 680px writing surface (overflow scrolls inside the box) with per-cell min/max width guards.
|
|
89
|
+
- GFM table parsing contract: a literal `|` inside a cell must be escaped as `\|` and the parser now honors it; column count is pinned to the separator row — short rows are padded with empty cells and extra cells are dropped, so the grid never turns ragged even when a row contains unescaped pipes.
|
|
90
|
+
- Split the two largest client source fragments for maintainability (pure physical split, no behavior change): `src/client/ui/render.js` (572 lines) becomes `slot.js` (MindmapSlot) + `render.js` (NodeBox/TreeRow) + `canvas.js` (the 016 canvas/zoom region), and `src/client/ui/panel.js` (790 lines) becomes `panel.js` (component head) + `treetab.js` (the 013 directory-tree tab region) + `panelbody.js` (layout effect + overlay JSX). The split was done by line slicing only, and the rebuilt `client.js` is byte-for-byte identical to the previous build. `build-client.mjs` now decides fragment separators via an explicit `PHYSICAL_SPLITS` set instead of hard-coded list indices.
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- The panel now reliably auto-opens when the AI completes `mindmap_open` / `mindmap_create`. A structural fingerprint of the session nodes (`nodesFingerprint`) feeds a second `useSession` selector; its value comparison bypasses the reference-equality short-circuit that starved the auto-open effect whenever the host store mutated the nodes array in place.
|
|
95
|
+
- The "AI 正在打开脑图…" loading state is no longer a dead end. Snapshot documents whose path differs from the tree-click key only by letter case (macOS case-insensitive filesystem) now merge automatically; errored mindmap tool results (`isError` or `ok !== true`) surface as an inline error; a ~30s watchdog switches to a timeout state. Both failure states offer a one-click retry that re-sends the open request.
|
|
96
|
+
|
|
40
97
|
## [0.4.1] - 2026-08-24
|
|
41
98
|
|
|
42
99
|
### Changed
|
package/README.md
CHANGED
|
@@ -22,17 +22,17 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
|
|
|
22
22
|
- **Directory tree tab** — a persistent tree of the session working directory (served by a plugin-owned read-only route), lazy-loaded per directory; right-click to create a mindmap (at the root or inside a directory); left-click a `.md` to open it instantly and hand it to the AI for editing.
|
|
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
|
-
- **MarkGrove-style mapping** — heading hierarchy, nested lists (empty items become placeholder nodes), code blocks as leaf nodes, paragraphs
|
|
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.
|
|
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. 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
|
|
|
32
32
|
| Component | Baseline |
|
|
33
33
|
| --- | --- |
|
|
34
34
|
| Node.js | 20.11 or newer |
|
|
35
|
-
| DeepSeek Harness | tested against `0.1.1-rc.2` |
|
|
35
|
+
| DeepSeek Harness | tested against `0.1.1-rc.2` and `0.1.2-rc.1` |
|
|
36
36
|
|
|
37
37
|
## Installation
|
|
38
38
|
|
|
@@ -42,7 +42,7 @@ Development (link install, live source):
|
|
|
42
42
|
dsh plugin --profile web add link:/path/to/dsh-mindmap
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Released tag
|
|
45
|
+
Released tag:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
dsh plugin --profile <profile> add <pkg>#v<version>
|
package/README.zh-CN.md
CHANGED
|
@@ -22,17 +22,17 @@
|
|
|
22
22
|
- **常驻目录树 tab**——工作目录结构懒加载树(插件自建只读路由);空白处/目录右键新建脑图;左键点 `.md` 秒开 tab 并自动交给 AI 打开,随后直接对话继续编辑。
|
|
23
23
|
- **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
|
|
24
24
|
- **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
|
|
25
|
-
- **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 =
|
|
26
|
-
- **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space +
|
|
25
|
+
- **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落升格为独立节点(019 块概念)、稳定结构 ID、节点间直角折线。
|
|
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
|
|
|
32
32
|
| 组件 | 基线 |
|
|
33
33
|
| --- | --- |
|
|
34
34
|
| Node.js | 20.11 及以上 |
|
|
35
|
-
| DeepSeek Harness | 实测于 `0.1.1-rc.2` |
|
|
35
|
+
| DeepSeek Harness | 实测于 `0.1.1-rc.2` 与 `0.1.2-rc.1` |
|
|
36
36
|
|
|
37
37
|
## 安装
|
|
38
38
|
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
dsh plugin --profile web add link:/path/to/dsh-mindmap
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
发布 tag
|
|
45
|
+
发布 tag:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
dsh plugin --profile <profile> add <pkg>#v<version>
|
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
|
}
|
|
@@ -950,6 +965,12 @@ window.__ModuleLoader__.load({
|
|
|
950
965
|
nodeW: 220, padX: 12, padY: 8, hGap: 48, vGap: 12, pad: 20,
|
|
951
966
|
fontSize: 13, lineHeight: 18,
|
|
952
967
|
tableCellW: 110, tableCellPad: 8, tableMinW: 140, tableMaxW: 480,
|
|
968
|
+
// 单元格折行宽下限 = 导出字号 12px 的全角宽:极宽表(如 60 列)
|
|
969
|
+
// 列盒窄于内距时 cellInner 可能为负,钳到至少容纳一个全角字符;
|
|
970
|
+
// 测量与渲染必须共用同一钳制值(022 契约)。
|
|
971
|
+
tableCellMinInner: 12,
|
|
972
|
+
maxCanvasDimension: 8192,
|
|
973
|
+
maxCanvasPixels: 16 * 1024 * 1024,
|
|
953
974
|
};
|
|
954
975
|
|
|
955
976
|
function escapeXml(text) {
|
|
@@ -1021,7 +1042,7 @@ window.__ModuleLoader__.load({
|
|
|
1021
1042
|
const rows = (node.data && node.data.rows) || [];
|
|
1022
1043
|
const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
|
|
1023
1044
|
const w = Math.min(EXPORT.tableMaxW, Math.max(EXPORT.tableMinW, cols * EXPORT.tableCellW));
|
|
1024
|
-
const cellInner = w / cols - EXPORT.tableCellPad * 2;
|
|
1045
|
+
const cellInner = Math.max(w / cols - EXPORT.tableCellPad * 2, EXPORT.tableCellMinInner);
|
|
1025
1046
|
const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
|
|
1026
1047
|
const h = Math.max(EXPORT.lineHeight, rowLines.reduce((a, b) => a + b, 0) * EXPORT.lineHeight);
|
|
1027
1048
|
return { w, h };
|
|
@@ -1101,14 +1122,15 @@ window.__ModuleLoader__.load({
|
|
|
1101
1122
|
const rows = (node.data && node.data.rows) || [];
|
|
1102
1123
|
const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
|
|
1103
1124
|
const colW = p.size.w / cols;
|
|
1104
|
-
const
|
|
1125
|
+
const innerW = Math.max(colW - EXPORT.tableCellPad * 2, EXPORT.tableCellMinInner);
|
|
1126
|
+
const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), innerW, EXPORT.fontSize - 1).length), 1));
|
|
1105
1127
|
const rowH = rowLines.map((n) => n * EXPORT.lineHeight);
|
|
1106
1128
|
let ry = boxY;
|
|
1107
1129
|
rows.forEach((row, ri) => {
|
|
1108
1130
|
row.forEach((cell, ci) => {
|
|
1109
1131
|
const cx = p.x + ci * colW;
|
|
1110
1132
|
parts.push(`<rect x="${cx}" y="${ry}" width="${colW}" height="${rowH[ri]}" fill="${ri === 0 ? palette.surfaceCode : "none"}" stroke="${palette.borderSubtle}" stroke-width="1"/>`);
|
|
1111
|
-
const cellLines = wrapExportText(stripInlineForExport(cell),
|
|
1133
|
+
const cellLines = wrapExportText(stripInlineForExport(cell), innerW, EXPORT.fontSize - 1);
|
|
1112
1134
|
cellLines.forEach((ln, li) => {
|
|
1113
1135
|
const ty = ry + (li + 0.5) * EXPORT.lineHeight + (EXPORT.fontSize - 1) * 0.35;
|
|
1114
1136
|
parts.push(`<text x="${cx + EXPORT.tableCellPad}" y="${ty.toFixed(1)}" font-size="${EXPORT.fontSize - 1}" font-weight="${ri === 0 ? 600 : 400}" fill="${palette.text}">${escapeXml(ln)}</text>`);
|
|
@@ -1132,8 +1154,19 @@ window.__ModuleLoader__.load({
|
|
|
1132
1154
|
return { svg: parts.join(""), width, height };
|
|
1133
1155
|
}
|
|
1134
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
|
+
|
|
1135
1167
|
/** SVG → Image → 白底 canvas(下载 / 剪贴板共用,017 抽出)。 */
|
|
1136
1168
|
async function renderSvgToCanvas(svg, width, height) {
|
|
1169
|
+
const size = exportCanvasSize(width, height);
|
|
1137
1170
|
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
1138
1171
|
const img = new Image();
|
|
1139
1172
|
await new Promise((resolve, reject) => {
|
|
@@ -1142,8 +1175,8 @@ window.__ModuleLoader__.load({
|
|
|
1142
1175
|
img.src = url;
|
|
1143
1176
|
});
|
|
1144
1177
|
const canvas = document.createElement("canvas");
|
|
1145
|
-
canvas.width =
|
|
1146
|
-
canvas.height =
|
|
1178
|
+
canvas.width = size.width;
|
|
1179
|
+
canvas.height = size.height;
|
|
1147
1180
|
const ctx2d = canvas.getContext("2d");
|
|
1148
1181
|
ctx2d.fillStyle = "#ffffff";
|
|
1149
1182
|
ctx2d.fillRect(0, 0, canvas.width, canvas.height);
|
|
@@ -1920,7 +1953,7 @@ window.__ModuleLoader__.load({
|
|
|
1920
1953
|
// 阈值 4px 以内算「点击」:不写 scroll(手抖不挪画布)、不上抓手光标、
|
|
1921
1954
|
// 不吞随后的 click——保留点空白取消选中 / 点节点聚焦的既有行为。
|
|
1922
1955
|
// 触摸(触屏)不劫持——交给原生滚动,保住惯性。
|
|
1923
|
-
const PAN = { threshold: 4 };
|
|
1956
|
+
const PAN = { threshold: 4, freeRange: 0.5 };
|
|
1924
1957
|
|
|
1925
1958
|
/** 是否在该指针按下上启动平移。button: 0 左 / 1 中 / 2 右。 */
|
|
1926
1959
|
function shouldStartPan(button, options) {
|
|
@@ -1942,6 +1975,18 @@ window.__ModuleLoader__.load({
|
|
|
1942
1975
|
};
|
|
1943
1976
|
}
|
|
1944
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
|
+
|
|
1945
1990
|
/** 空格键是否落在可输入元素里(聊天框/输入框与面板同 document,不能抢空格)。 */
|
|
1946
1991
|
function isTextEntry(el) {
|
|
1947
1992
|
if (!el || typeof el.tagName !== "string") return false;
|
|
@@ -2021,6 +2066,9 @@ window.__ModuleLoader__.load({
|
|
|
2021
2066
|
// 都要重测一遍(O(n) 强制重排),大树上拖一下会明显顿一下。
|
|
2022
2067
|
// 光标与「禁用选中」直接改 DOM style,全程零重渲染。
|
|
2023
2068
|
const panRef = react.useRef(null);
|
|
2069
|
+
// 原生滚动没有余量或已到边缘时的有界补偿位移。同样只写 DOM,
|
|
2070
|
+
// 避免平移触发整棵树重渲染。
|
|
2071
|
+
const panOffsetRef = react.useRef({ x: 0, y: 0 });
|
|
2024
2072
|
// 021 空格键:按下时左键在节点上也能拖。同样用 ref(按空格不该重渲染)。
|
|
2025
2073
|
const spaceRef = react.useRef(false);
|
|
2026
2074
|
// 021 拖过就吞掉随后那次 click(保留单击空白取消选中 / 点节点聚焦)。
|
|
@@ -2036,6 +2084,7 @@ window.__ModuleLoader__.load({
|
|
|
2036
2084
|
const scroller = scrollRef.current;
|
|
2037
2085
|
const content = contentRef.current;
|
|
2038
2086
|
if (!scroller || !content) return;
|
|
2087
|
+
resetPanOffset();
|
|
2039
2088
|
const rect = content.getBoundingClientRect();
|
|
2040
2089
|
if (!(rect.width > 0) || !(rect.height > 0)) return;
|
|
2041
2090
|
const committed = committedZoomRef.current;
|
|
@@ -2048,7 +2097,9 @@ window.__ModuleLoader__.load({
|
|
|
2048
2097
|
scroller.scrollLeft = 0;
|
|
2049
2098
|
scroller.scrollTop = 0;
|
|
2050
2099
|
setZoomState(fit);
|
|
2100
|
+
return true;
|
|
2051
2101
|
}
|
|
2102
|
+
return false;
|
|
2052
2103
|
}
|
|
2053
2104
|
|
|
2054
2105
|
// 挂载 / 文档切换:清除「用户已手动缩放」标记与熔断计数,
|
|
@@ -2082,6 +2133,7 @@ window.__ModuleLoader__.load({
|
|
|
2082
2133
|
if (Math.abs(w - last.w) <= 2 && Math.abs(h - last.h) <= 2) return;
|
|
2083
2134
|
}
|
|
2084
2135
|
}
|
|
2136
|
+
if (!applyFit()) return;
|
|
2085
2137
|
const now = Date.now();
|
|
2086
2138
|
const stamps = fitStampRef.current = fitStampRef.current.filter((t) => now - t < 1500);
|
|
2087
2139
|
if (stamps.length >= 5) {
|
|
@@ -2089,7 +2141,6 @@ window.__ModuleLoader__.load({
|
|
|
2089
2141
|
return;
|
|
2090
2142
|
}
|
|
2091
2143
|
stamps.push(now);
|
|
2092
|
-
applyFit();
|
|
2093
2144
|
});
|
|
2094
2145
|
observer.observe(content);
|
|
2095
2146
|
observer.observe(scroller);
|
|
@@ -2133,6 +2184,18 @@ window.__ModuleLoader__.load({
|
|
|
2133
2184
|
scroller.style.userSelect = active ? "none" : "";
|
|
2134
2185
|
}
|
|
2135
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
|
+
|
|
2136
2199
|
function beginPan(e) {
|
|
2137
2200
|
// 吞 click 的标记一律在本轮手势的最开头清零,放在所有早退之前:
|
|
2138
2201
|
// 否则上轮手势留下的 true 会粘到下一次点击上——例:拖完画布后再
|
|
@@ -2151,6 +2214,8 @@ window.__ModuleLoader__.load({
|
|
|
2151
2214
|
y: e.clientY,
|
|
2152
2215
|
scrollLeft: scroller.scrollLeft,
|
|
2153
2216
|
scrollTop: scroller.scrollTop,
|
|
2217
|
+
offsetX: panOffsetRef.current.x,
|
|
2218
|
+
offsetY: panOffsetRef.current.y,
|
|
2154
2219
|
moved: false,
|
|
2155
2220
|
};
|
|
2156
2221
|
// 不在按下就上抓手光标:普通点击(位移 < 阈值)不该闪一下
|
|
@@ -2180,9 +2245,13 @@ window.__ModuleLoader__.load({
|
|
|
2180
2245
|
// 首次越过阈值:这才算拖拽——上抓手光标 + 锁文本选择。
|
|
2181
2246
|
if (!pan.moved) applyPanCursor(scroller, true);
|
|
2182
2247
|
if (step.moved) pan.moved = true;
|
|
2183
|
-
//
|
|
2248
|
+
// 先交给浏览器钳制;到边缘后把未被 scroll 消耗的位移补到内容
|
|
2249
|
+
// transform,上下左右始终可拖,且不读 scrollWidth 造成强制重排。
|
|
2184
2250
|
scroller.scrollLeft = step.scrollLeft;
|
|
2185
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);
|
|
2186
2255
|
}
|
|
2187
2256
|
|
|
2188
2257
|
function endPan() {
|
|
@@ -2204,7 +2273,7 @@ window.__ModuleLoader__.load({
|
|
|
2204
2273
|
function setZoom(next, anchorViewport) {
|
|
2205
2274
|
const value = clampZoom(next);
|
|
2206
2275
|
anchorRef.current = anchorViewport && scrollRef.current
|
|
2207
|
-
? { 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 }
|
|
2208
2277
|
: null;
|
|
2209
2278
|
zoomRef.current = value;
|
|
2210
2279
|
setZoomState(value);
|
|
@@ -2225,8 +2294,8 @@ window.__ModuleLoader__.load({
|
|
|
2225
2294
|
anchorRef.current = null;
|
|
2226
2295
|
const ratio = anchor.prevZoom > 0 ? zoom / anchor.prevZoom : 1;
|
|
2227
2296
|
if (!(ratio > 0) || ratio === 1) return;
|
|
2228
|
-
scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2) * ratio - scroller.clientWidth / 2;
|
|
2229
|
-
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;
|
|
2230
2299
|
}, [zoom]);
|
|
2231
2300
|
|
|
2232
2301
|
function zoomIn() {
|
|
@@ -2291,6 +2360,7 @@ window.__ModuleLoader__.load({
|
|
|
2291
2360
|
focusRef.current = null;
|
|
2292
2361
|
const scroller = scrollRef.current;
|
|
2293
2362
|
if (!scroller || !focus || !focus.boxEl || !focus.boxEl.isConnected) return;
|
|
2363
|
+
resetPanOffset();
|
|
2294
2364
|
const boxRect = focus.boxEl.getBoundingClientRect();
|
|
2295
2365
|
const scrollerRect = scroller.getBoundingClientRect();
|
|
2296
2366
|
const curX = boxRect.left + boxRect.width / 2 - scrollerRect.left;
|
|
@@ -2618,6 +2688,8 @@ window.__ModuleLoader__.load({
|
|
|
2618
2688
|
const [filledHint, setFilledHint] = react.useState("");
|
|
2619
2689
|
// fsTree:nodes = {path → 节点}, expanded = {path → true}, loading = {path → true}。
|
|
2620
2690
|
const [fsTree, setFsTree] = react.useState({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
|
|
2691
|
+
// 会话切换时递增,使旧请求的异步回包不能写入新会话的目录树。
|
|
2692
|
+
const treeGenerationRef = react.useRef(0);
|
|
2621
2693
|
// 013 右键菜单:{x, y, kind: "root"|"dir", rel};null = 关闭。
|
|
2622
2694
|
const [treeMenu, setTreeMenu] = react.useState(null);
|
|
2623
2695
|
// tab 右键菜单:{x, y, path}(path === TREE_TAB 时是「刷新目录树」)。
|
|
@@ -2744,10 +2816,24 @@ window.__ModuleLoader__.load({
|
|
|
2744
2816
|
}, [merged]);
|
|
2745
2817
|
|
|
2746
2818
|
// 013「所见即所编」焦点同步:AI 焦点 = 快照里最新工具结果的文档路径;
|
|
2747
|
-
// 脑图视图激活且其文档 ≠
|
|
2748
|
-
//
|
|
2819
|
+
// 脑图视图激活且其文档 ≠ 焦点时,仅在草稿为空时自动发送,让 AI
|
|
2820
|
+
// 跟上用户眼睛看的那颗脑图,又不覆盖用户正在编辑的消息。
|
|
2749
2821
|
const focusPath = docs.order.length > 0 ? docs.order[docs.order.length - 1] : null;
|
|
2750
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]);
|
|
2751
2837
|
react.useEffect(() => {
|
|
2752
2838
|
if (!open) return; // 面板收起时不自动发消息(014 overlay 形态守卫)
|
|
2753
2839
|
if (!sessionId) return;
|
|
@@ -2755,14 +2841,9 @@ window.__ModuleLoader__.load({
|
|
|
2755
2841
|
if (!docs.byPath[active]) return; // 本地占位:它的 open 请求已在途
|
|
2756
2842
|
if (focusPath === active) return;
|
|
2757
2843
|
if (focusSentRef.current === active) return; // 已发过,等 AI 结果追平
|
|
2758
|
-
if (!inputActions || typeof inputActions.setDraft !== "function") return;
|
|
2759
2844
|
const rel = fsTree.cwd ? relPathWithin(fsTree.cwd, active, stemOf(active)) : active;
|
|
2760
|
-
|
|
2761
|
-
inputActions.setDraft(`用 mindmap_open 打开 ${rel}`);
|
|
2762
|
-
if (typeof inputActions.submit === "function") inputActions.submit();
|
|
2845
|
+
if (submitEmptyDraft(`用 mindmap_open 打开 ${rel}`)) {
|
|
2763
2846
|
focusSentRef.current = active;
|
|
2764
|
-
} catch {
|
|
2765
|
-
// 发送失败:下次 active/focus 变化会再试;也可手动在聊天里说。
|
|
2766
2847
|
}
|
|
2767
2848
|
}, [active, focusPath, fsTree.cwd, docs, open]);
|
|
2768
2849
|
|
|
@@ -2780,11 +2861,32 @@ window.__ModuleLoader__.load({
|
|
|
2780
2861
|
}
|
|
2781
2862
|
|
|
2782
2863
|
//#region 013 目录树 tab:懒加载树 + 把指令填进聊天输入框
|
|
2783
|
-
//
|
|
2784
|
-
//
|
|
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
|
+
// 手动新建指令也不能覆盖草稿;空草稿时填入,否则复制到剪贴板。
|
|
2785
2887
|
function fillDraft(text) {
|
|
2786
2888
|
try {
|
|
2787
|
-
if (
|
|
2889
|
+
if (draftIsEmpty() && typeof inputActions.setDraft === "function") {
|
|
2788
2890
|
inputActions.setDraft(text);
|
|
2789
2891
|
setFilledHint("指令已填入聊天输入框");
|
|
2790
2892
|
return;
|
|
@@ -2817,10 +2919,14 @@ window.__ModuleLoader__.load({
|
|
|
2817
2919
|
// 就位——AI 工具结果到达后同 path 覆盖占位,节点才渲染;随后用户接着
|
|
2818
2920
|
// 说即可继续编辑(002 数据流不变:内容只来自 AI 工具结果)。
|
|
2819
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
|
+
}
|
|
2820
2927
|
// 016:记录点击时刻的错误基线(errorByPath 与 latestError 的全部
|
|
2821
2928
|
// 事件键)——只有其后新出现的错误才归因本次打开,旧错误不打扰。
|
|
2822
2929
|
localErrorBaseRef.current = errorEventKeys(merged);
|
|
2823
|
-
const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
|
|
2824
2930
|
// ① 本地占位:脑图 tab 立即切过去、内容为空(op:"local" 触发加载态);
|
|
2825
2931
|
// 新打开的脑图替换旧的那颗(单脑图模式)。
|
|
2826
2932
|
setHiddenPath(null);
|
|
@@ -2837,31 +2943,9 @@ window.__ModuleLoader__.load({
|
|
|
2837
2943
|
renamedFrom: null,
|
|
2838
2944
|
},
|
|
2839
2945
|
}));
|
|
2840
|
-
//
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
try {
|
|
2844
|
-
inputActions.setDraft(text);
|
|
2845
|
-
if (typeof inputActions.submit === "function") {
|
|
2846
|
-
inputActions.submit();
|
|
2847
|
-
sent = true;
|
|
2848
|
-
}
|
|
2849
|
-
} catch {
|
|
2850
|
-
// 落剪贴板降级
|
|
2851
|
-
}
|
|
2852
|
-
}
|
|
2853
|
-
if (sent) {
|
|
2854
|
-
// 标记已发,避免焦点同步 effect 对同一路径重复发送。
|
|
2855
|
-
focusSentRef.current = entry.path;
|
|
2856
|
-
setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
|
|
2857
|
-
return;
|
|
2858
|
-
}
|
|
2859
|
-
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
|
2860
|
-
navigator.clipboard.writeText(text).catch(() => {});
|
|
2861
|
-
setFilledHint("已复制指令到剪贴板,请粘贴到聊天输入框");
|
|
2862
|
-
return;
|
|
2863
|
-
}
|
|
2864
|
-
setFilledHint(text);
|
|
2946
|
+
// 指令已在占位前提交;标记避免焦点同步对同一路径重复发送。
|
|
2947
|
+
focusSentRef.current = entry.path;
|
|
2948
|
+
setFilledHint(`已让 AI 打开「${entry.name}」,在聊天里继续说就能继续编辑`);
|
|
2865
2949
|
}
|
|
2866
2950
|
|
|
2867
2951
|
/** 016 加载态恢复:错误/超时后重试——重发打开指令并重启看门狗。 */
|
|
@@ -2876,11 +2960,13 @@ window.__ModuleLoader__.load({
|
|
|
2876
2960
|
// 只读;返回 {path, cwd, entries:[{name,path,isDir,hidden}], truncated}。
|
|
2877
2961
|
// 返回 true/false 供调用方决定是否标记展开(失败时不要把目录标成已展开)。
|
|
2878
2962
|
async function loadTree(path) {
|
|
2963
|
+
const generation = treeGenerationRef.current;
|
|
2879
2964
|
const key = path === undefined || path === null ? "" : path;
|
|
2880
2965
|
setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: true }, error: null }));
|
|
2881
2966
|
try {
|
|
2882
2967
|
if (!mindmapFace || typeof mindmapFace.listTree !== "function") throw new Error("目录树能力不可用");
|
|
2883
2968
|
const listing = await mindmapFace.listTree(sessionId, typeof path === "string" && path ? path : undefined);
|
|
2969
|
+
if (generation !== treeGenerationRef.current) return false;
|
|
2884
2970
|
setFsTree((prev) => {
|
|
2885
2971
|
const nodes = { ...prev.nodes };
|
|
2886
2972
|
nodes[listing.path] = {
|
|
@@ -2903,6 +2989,7 @@ window.__ModuleLoader__.load({
|
|
|
2903
2989
|
});
|
|
2904
2990
|
return true;
|
|
2905
2991
|
} catch (error) {
|
|
2992
|
+
if (generation !== treeGenerationRef.current) return false;
|
|
2906
2993
|
setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: false }, error: String(error?.message ?? error) }));
|
|
2907
2994
|
return false;
|
|
2908
2995
|
}
|
|
@@ -2940,6 +3027,7 @@ window.__ModuleLoader__.load({
|
|
|
2940
3027
|
|
|
2941
3028
|
// 首次挂载:拉根目录(会话 cwd)。
|
|
2942
3029
|
react.useEffect(() => {
|
|
3030
|
+
treeGenerationRef.current += 1;
|
|
2943
3031
|
if (!sessionId) return;
|
|
2944
3032
|
setFsTree({ nodes: {}, expanded: {}, loading: {}, cwd: null, error: null });
|
|
2945
3033
|
loadTree(undefined);
|
|
@@ -3293,7 +3381,12 @@ window.__ModuleLoader__.load({
|
|
|
3293
3381
|
* namespaces 兜底,两代通吃。
|
|
3294
3382
|
*/
|
|
3295
3383
|
function settingsNamespacesOf(res) {
|
|
3296
|
-
|
|
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;
|
|
3297
3390
|
if (Array.isArray(value)) return value;
|
|
3298
3391
|
const list = value?.namespaces;
|
|
3299
3392
|
return Array.isArray(list) ? list : [];
|
|
@@ -3353,22 +3446,56 @@ window.__ModuleLoader__.load({
|
|
|
3353
3446
|
return parsed.value;
|
|
3354
3447
|
};
|
|
3355
3448
|
|
|
3356
|
-
// 015 设置面板:
|
|
3357
|
-
//
|
|
3358
|
-
|
|
3359
|
-
|
|
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
|
+
}
|
|
3360
3481
|
face.readSettings = async () => {
|
|
3361
|
-
|
|
3362
|
-
|
|
3482
|
+
const api = settingsApiOf();
|
|
3483
|
+
if (!api) return null;
|
|
3484
|
+
const res = api.kind === "remote" ? await api.settings.describe() : await api.settings.describe({});
|
|
3363
3485
|
const namespaces = settingsNamespacesOf(res);
|
|
3364
3486
|
const ns = namespaces.find((n) => n?.ns === "mindmap");
|
|
3365
3487
|
return ns?.value ?? null;
|
|
3366
3488
|
};
|
|
3367
3489
|
face.updateSettings = async (patch) => {
|
|
3368
|
-
|
|
3490
|
+
const api = settingsApiOf();
|
|
3491
|
+
if (!api) {
|
|
3369
3492
|
throw new Error("settings service unavailable");
|
|
3370
3493
|
}
|
|
3371
|
-
|
|
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
|
+
}
|
|
3372
3499
|
};
|
|
3373
3500
|
|
|
3374
3501
|
// 015 设置面板:settings.section(list 槽、root scope)——设置页左栏
|
|
@@ -3410,6 +3537,8 @@ window.__ModuleLoader__.load({
|
|
|
3410
3537
|
resultTextOfBlocks,
|
|
3411
3538
|
stemOf,
|
|
3412
3539
|
buildExportSvg,
|
|
3540
|
+
measureExportBox,
|
|
3541
|
+
exportCanvasSize,
|
|
3413
3542
|
createIdFactory,
|
|
3414
3543
|
collectTreeIds,
|
|
3415
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.'),
|
|
@@ -35,6 +36,7 @@ export const Config = Schema.object({
|
|
|
35
36
|
})
|
|
36
37
|
|
|
37
38
|
const MAX_CONTENT_BYTES = 2 * 1024 * 1024
|
|
39
|
+
const MAX_READ_BYTES = 2 * 1024 * 1024
|
|
38
40
|
const MAX_NAME_CHARS = 80
|
|
39
41
|
const TOOL_TIMEOUT_MS = 15_000
|
|
40
42
|
const MAX_TREE_ENTRIES = 500
|
|
@@ -50,7 +52,7 @@ Tools:
|
|
|
50
52
|
- mindmap_get(path): read the current markdown content.
|
|
51
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.
|
|
52
54
|
|
|
53
|
-
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.
|
|
54
56
|
|
|
55
57
|
Behavior rules:
|
|
56
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.
|
|
@@ -244,17 +246,14 @@ function sanitizeStem(input) {
|
|
|
244
246
|
|
|
245
247
|
/**
|
|
246
248
|
* 解析脑图文件路径:相对路径以会话 cwd 为基;结果必须落在 cwd 内(决策 1),
|
|
247
|
-
* 且必须以 .md 结尾。cwd
|
|
249
|
+
* 且必须以 .md 结尾。cwd 是文件授权边界,缺失时必须失败关闭。
|
|
248
250
|
* @returns 绝对规范化路径。
|
|
249
251
|
*/
|
|
250
252
|
async function resolveMindmapPath(cwd, input) {
|
|
251
253
|
if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
|
|
252
254
|
const p = input.trim()
|
|
253
255
|
if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
|
|
254
|
-
if (!cwd)
|
|
255
|
-
if (!isAbsolute(p)) throw new Error('The session has no working directory; pass an absolute .md path.')
|
|
256
|
-
return resolvePath(p)
|
|
257
|
-
}
|
|
256
|
+
if (!cwd) throw new Error('The session has no working directory; cannot access a mindmap.')
|
|
258
257
|
const resolved = resolvePath(cwd, p)
|
|
259
258
|
const rel = relative(cwd, resolved)
|
|
260
259
|
// realpath 兜底(#5):写路径经符号链接越狱是安全敏感操作。
|
|
@@ -291,6 +290,56 @@ function byteLength(value) {
|
|
|
291
290
|
return new TextEncoder().encode(value).byteLength
|
|
292
291
|
}
|
|
293
292
|
|
|
293
|
+
/**
|
|
294
|
+
* mindmap_open / mindmap_get 共用的有界读取。句柄打开后持续分块读取,文件在
|
|
295
|
+
* 初检后增长也不会绕过上限;非常规文件直接拒绝。
|
|
296
|
+
*/
|
|
297
|
+
async function readMindmap(path) {
|
|
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(() => {})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
294
343
|
/** 工具结果信封:client 面板与模型共用的唯一载体。 */
|
|
295
344
|
function buildResult(op, path, extra = {}) {
|
|
296
345
|
const base = String(path ?? '').split(/[\\/]/).pop() || 'mindmap'
|
|
@@ -307,7 +356,7 @@ export function apply(ctx, config = {}) {
|
|
|
307
356
|
// 入口配置作为 settings 组合层的 base:视觉三件套与默认宽度在这里
|
|
308
357
|
// 透传(带 schema 同款默认值),用户设置层仍可在设置面板覆盖。
|
|
309
358
|
const entryConfig = {
|
|
310
|
-
requireApproval: config.requireApproval
|
|
359
|
+
requireApproval: config.requireApproval !== false,
|
|
311
360
|
defaultPanelWidth: typeof config.defaultPanelWidth === 'number' ? config.defaultPanelWidth : 42,
|
|
312
361
|
lineStyle: config.lineStyle === 'curve' ? 'curve' : 'elbow',
|
|
313
362
|
cardStyle: config.cardStyle === 'square' ? 'square' : 'rounded',
|
|
@@ -331,7 +380,7 @@ export function apply(ctx, config = {}) {
|
|
|
331
380
|
ctx.systemPrompt.section({ name: 'tool:mindmap', order: 106, text: GUIDANCE })
|
|
332
381
|
|
|
333
382
|
// 后悔药开关(决策 6):钩子常驻注册,运行时读 activeConfig().requireApproval
|
|
334
|
-
//
|
|
383
|
+
// ——设置面板切换立即生效;仅明确关闭时直接放行。
|
|
335
384
|
// 022:create 同为写路径,一并纳入审批(CONTRIBUTING:所有写路径安全敏感)。
|
|
336
385
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
337
386
|
const decision = await next()
|
|
@@ -398,7 +447,7 @@ export function apply(ctx, config = {}) {
|
|
|
398
447
|
timeoutMs: TOOL_TIMEOUT_MS,
|
|
399
448
|
async execute(args, exec) {
|
|
400
449
|
const path = await resolveMindmapPath(sessionCwd(exec, ctx.sessions), args?.path)
|
|
401
|
-
const content = await
|
|
450
|
+
const content = await readMindmap(path)
|
|
402
451
|
return buildResult('open', path, { content })
|
|
403
452
|
},
|
|
404
453
|
}))
|
|
@@ -417,7 +466,7 @@ export function apply(ctx, config = {}) {
|
|
|
417
466
|
timeoutMs: TOOL_TIMEOUT_MS,
|
|
418
467
|
async execute(args, exec) {
|
|
419
468
|
const path = await resolveMindmapPath(sessionCwd(exec, ctx.sessions), args?.path)
|
|
420
|
-
const content = await
|
|
469
|
+
const content = await readMindmap(path)
|
|
421
470
|
return buildResult('get', path, { content })
|
|
422
471
|
},
|
|
423
472
|
}))
|
|
@@ -466,8 +515,8 @@ export function apply(ctx, config = {}) {
|
|
|
466
515
|
finalPath = target
|
|
467
516
|
}
|
|
468
517
|
}
|
|
469
|
-
if (hasContent) await
|
|
470
|
-
const content = hasContent ? args.content : await
|
|
518
|
+
if (hasContent) await writeMindmap(finalPath, args.content)
|
|
519
|
+
const content = hasContent ? args.content : await readMindmap(finalPath)
|
|
471
520
|
return buildResult('update', finalPath, { content, ...(renamedFrom ? { renamedFrom } : {}) })
|
|
472
521
|
},
|
|
473
522
|
}))
|
|
@@ -525,6 +574,9 @@ export function apply(ctx, config = {}) {
|
|
|
525
574
|
export const internals = Object.freeze({
|
|
526
575
|
GUIDANCE,
|
|
527
576
|
MAX_CONTENT_BYTES,
|
|
577
|
+
MAX_READ_BYTES,
|
|
578
|
+
readMindmap,
|
|
579
|
+
writeMindmap,
|
|
528
580
|
sanitizeStem,
|
|
529
581
|
resolveMindmapPath,
|
|
530
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
|