dsh-plugin-mind-tree 0.0.0-stage → 0.4.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/lib/format.js ADDED
@@ -0,0 +1,364 @@
1
+ // 共享格式化层:把思维树数据渲染成 Mermaid 思维导图 / 大纲 Markdown / Obsidian 画布 JSON。
2
+ // 宿主半区(工具导出)与客户端半区(右键导出)都使用本模块,保证两边格式完全一致。
3
+ // 只依赖纯 JS,不引用任何 DSH 实现代码。
4
+ // Copyright (c) 2026 ShOpenMind · https://github.com/ShOpenMind/dsh-plugin-mind-tree · MIT
5
+
6
+ /** 作者署名:出现在导出的 YAML 属性与页脚中。 */
7
+ export const AUTHOR_NAME = 'ShOpenMind';
8
+ export const AUTHOR_URL = 'https://github.com/ShOpenMind/dsh-plugin-mind-tree';
9
+
10
+ /** 折叠空白并截断到 max 个字符。 */
11
+ export function squash(text, max = 240) {
12
+ if (typeof text !== 'string') return '';
13
+ const flat = text.replace(/\s+/g, ' ').trim();
14
+ const chars = [...flat];
15
+ if (chars.length <= max) return flat;
16
+ return `${chars.slice(0, max - 1).join('')}…`;
17
+ }
18
+
19
+ /** 截断到指定字符数(用于 SVG/画布布局估算)。 */
20
+ export function clampChars(text, max) {
21
+ const chars = [...String(text ?? '')];
22
+ return chars.length > max ? `${chars.slice(0, Math.max(1, max - 1)).join('')}…` : chars.join('');
23
+ }
24
+
25
+ /** token 数量格式化:1200 → 1.2k,1500000 → 1.5M。 */
26
+ export function fmtTokens(n) {
27
+ if (!Number.isFinite(n) || n <= 0) return '';
28
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
29
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
30
+ return String(Math.round(n));
31
+ }
32
+
33
+ /** 文件名安全化。 */
34
+ export function safeFileName(title, fallback = 'mind-tree') {
35
+ return String(title ?? '').replace(/[\\/:*?"<>|]/g, '_').slice(0, 40) || fallback;
36
+ }
37
+
38
+ /** YAML 标量转义(始终加引号)。 */
39
+ export function yamlScalar(value) {
40
+ return `"${String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
41
+ }
42
+
43
+ // ---------------- 层级(与客户端渲染逻辑一致的 "next 即同级主干" 规则) ----------------
44
+
45
+ /** 构建层级:step/result/spawn 为父子,"next" 链展开为同级主干。 */
46
+ export function buildHierarchy(tree) {
47
+ const byId = new Map();
48
+ for (const node of tree?.nodes ?? []) byId.set(node.id, node);
49
+ const children = new Map();
50
+ const secondary = new Map();
51
+ const parentOf = new Map();
52
+ const nextOf = new Map();
53
+ const nextTargets = new Set();
54
+ for (const edge of tree?.edges ?? []) {
55
+ if (!byId.has(edge.from) || !byId.has(edge.to)) continue;
56
+ if (edge.kind === 'replaced-by' || edge.kind === 'derived') {
57
+ secondary.set(`${edge.from}->${edge.to}`, edge);
58
+ continue;
59
+ }
60
+ if (edge.kind === 'next') {
61
+ nextOf.set(edge.from, edge.to);
62
+ nextTargets.add(edge.to);
63
+ continue;
64
+ }
65
+ if (!children.has(edge.from)) children.set(edge.from, []);
66
+ children.get(edge.from).push(edge);
67
+ parentOf.set(edge.to, edge.from);
68
+ }
69
+ const pushChild = (parentId, childId) => {
70
+ if (!children.has(parentId)) children.set(parentId, []);
71
+ children.get(parentId).push({ from: parentId, to: childId, kind: 'next' });
72
+ parentOf.set(childId, parentId);
73
+ };
74
+ const chained = new Set();
75
+ for (const head of [...nextOf.keys()].filter((id) => !nextTargets.has(id))) {
76
+ const base = parentOf.get(head) ?? head;
77
+ let cursor = nextOf.get(head);
78
+ while (cursor !== undefined && !chained.has(cursor) && cursor !== head) {
79
+ chained.add(cursor);
80
+ pushChild(base, cursor);
81
+ cursor = nextOf.get(cursor);
82
+ }
83
+ }
84
+ for (const node of tree?.nodes ?? []) if (!children.has(node.id)) children.set(node.id, []);
85
+ const roots = (tree?.nodes ?? []).filter((n) => !parentOf.has(n.id)).map((n) => n.id);
86
+ return { byId, children, roots, secondary, parentOf };
87
+ }
88
+
89
+ /** 深度优先展平(全展开),返回行列表 [{ node, depth }]。 */
90
+ export function flattenTree(tree) {
91
+ const { byId, children, roots } = buildHierarchy(tree);
92
+ const rows = [];
93
+ const seen = new Set();
94
+ const walk = (id, depth) => {
95
+ if (seen.has(id)) return;
96
+ const node = byId.get(id);
97
+ if (node === undefined) return;
98
+ seen.add(id);
99
+ rows.push({ node, depth });
100
+ for (const edge of children.get(id) ?? []) walk(edge.to, depth + 1);
101
+ };
102
+ for (const root of roots) walk(root, 0);
103
+ for (const node of tree?.nodes ?? []) if (!seen.has(node.id)) walk(node.id, 0);
104
+ return rows;
105
+ }
106
+
107
+ // ---------------- 导图模型(抽稀为"决策骨架") ----------------
108
+
109
+ const STATE_BRANCHES = new Set(['todo', 'goal', 'plan', 'compaction', 'approval', 'team', 'retry', 'fork', 'replace', 'error']);
110
+ const INJECTED_LABEL_PREFIXES = [
111
+ '[model changed', 'background job ', 'Current runtime context', 'You are an AI agent',
112
+ 'The user ', 'Tokens prefixed', '<system-reminder>', 'Image "', '【', 'Note:', 'System:',
113
+ ];
114
+
115
+ /** 注入型"用户消息"(模型切换、后台任务通知、运行时上下文)不算用户真实发言。 */
116
+ export function isInjectedLabel(label) {
117
+ const text = String(label ?? '').trim();
118
+ if (text === '') return true;
119
+ return INJECTED_LABEL_PREFIXES.some((prefix) => text.startsWith(prefix));
120
+ }
121
+
122
+ /**
123
+ * 抽取导图模型:轮次 + 分支点 + 用户真实发言 + 子会话;
124
+ * 状态型分支(重复的 todo/goal 等)每轮只保留最后一次。
125
+ * @param tree - 思维树数据 { sessionId, title, nodes, edges }
126
+ * @param options - { branchLabel: (kind) => string, maxUsersPerTurn?: number }
127
+ */
128
+ export function buildMindmapModel(tree, options = {}) {
129
+ const branchLabel = options.branchLabel ?? ((kind) => kind);
130
+ const maxUsers = options.maxUsersPerTurn ?? 6;
131
+ const { byId, children } = buildHierarchy(tree);
132
+ const turns = (tree?.nodes ?? []).filter((n) => n.kind === 'turn');
133
+ const model = { title: tree?.title ?? tree?.sessionId ?? '思维树', sessionId: tree?.sessionId, turns: [] };
134
+ for (const turn of turns) {
135
+ const kids = (children.get(turn.id) ?? []).map((edge) => byId.get(edge.to)).filter(Boolean);
136
+ const users = kids.filter((n) => n.kind === 'user' && !isInjectedLabel(n.label));
137
+ const branches = kids.filter((n) => n.branch);
138
+ const sessions = kids.filter((n) => n.kind === 'session');
139
+ const others = kids.length - users.length - branches.length - sessions.length;
140
+ const keptBranches = [];
141
+ const lastStateIndex = new Map();
142
+ branches.forEach((branch, index) => {
143
+ const kind = branch.branch?.kind ?? 'branch';
144
+ if (STATE_BRANCHES.has(kind)) lastStateIndex.set(kind, index);
145
+ else keptBranches.push({ branch, index });
146
+ });
147
+ for (const [kind, index] of lastStateIndex) keptBranches.push({ branch: branches[index], index });
148
+ keptBranches.sort((a, b) => a.index - b.index);
149
+ const items = [];
150
+ for (const user of users.slice(0, maxUsers)) items.push({ kind: 'user', text: user.label });
151
+ if (users.length > maxUsers) items.push({ kind: 'more', text: `…另有 ${users.length - maxUsers} 条用户消息` });
152
+ for (const entry of keptBranches) {
153
+ const branch = entry.branch;
154
+ const options2 = (branch.branch?.detail?.questions?.[0]?.options ?? [])
155
+ .map((option) => option.label).filter(Boolean).slice(0, 4);
156
+ items.push({ kind: 'branch', text: `${branchLabel(branch.branch.kind)}:${branch.label}`, options: options2 });
157
+ }
158
+ for (const child of sessions) items.push({ kind: 'session', text: `子会话:${child.label}` });
159
+ if (others > 0) items.push({ kind: 'more', text: `…另有 ${others} 条过程消息` });
160
+ model.turns.push({ turn, items });
161
+ }
162
+ return model;
163
+ }
164
+
165
+ /** Mermaid 文本清洗:结构字符转全角,避免破坏 mindmap 语法。 */
166
+ export function mermaidText(text, max = 44) {
167
+ let out = String(text ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
168
+ const chars = [...out];
169
+ if (chars.length > max) out = `${chars.slice(0, max - 1).join('')}…`;
170
+ return out
171
+ .replace(/\(/g, '(').replace(/\)/g, ')')
172
+ .replace(/\[/g, '【').replace(/\]/g, '】')
173
+ .replace(/\{/g, '{').replace(/\}/g, '}')
174
+ .replace(/"/g, '”').replace(/`/g, '')
175
+ .replace(/::/g, ':')
176
+ .replace(/;/g, ',');
177
+ }
178
+
179
+ // ---------------- 三种导出格式 ----------------
180
+
181
+ function frontmatterLines(tree, format) {
182
+ const turns = (tree?.nodes ?? []).filter((n) => n.kind === 'turn');
183
+ const title = tree?.title ?? tree?.sessionId ?? '思维树';
184
+ return [
185
+ '---',
186
+ `title: ${yamlScalar(`思维树 · ${title}`)}`,
187
+ `session: ${yamlScalar(tree?.sessionId ?? '')}`,
188
+ `turns: ${turns.length}`,
189
+ `nodes: ${(tree?.nodes ?? []).length}`,
190
+ `exported: ${new Date().toISOString()}`,
191
+ `format: ${yamlScalar(format)}`,
192
+ `author: ${yamlScalar(AUTHOR_NAME)}`,
193
+ 'tags:',
194
+ ' - dsh',
195
+ ' - mind-tree',
196
+ '---',
197
+ '',
198
+ ];
199
+ }
200
+
201
+ /** ① Mermaid 思维导图 Markdown:Obsidian 打开即见图(零插件)。 */
202
+ export function buildMindmapMarkdown(tree, options = {}) {
203
+ const branchLabel = options.branchLabel ?? ((kind) => kind);
204
+ const model = buildMindmapModel(tree, { branchLabel, maxUsersPerTurn: options.maxUsersPerTurn });
205
+ const title = model.title;
206
+ const lines = frontmatterLines(tree, 'mindmap');
207
+ lines.push(`# 思维树: ${title}`);
208
+ lines.push('');
209
+ lines.push('```mermaid');
210
+ lines.push('mindmap');
211
+ lines.push(` root((${mermaidText(title, 30)}))`);
212
+ for (const { turn, items } of model.turns) {
213
+ const stat = turn.usage
214
+ ? ` · ${turn.usage.messageCount ?? 0} 条 · ${fmtTokens(turn.usage.total || turn.usage.input + turn.usage.output)} tok`
215
+ : '';
216
+ lines.push(` ${mermaidText(`第 ${turn.turn ?? '?'} 轮 · ${turn.summary ?? '(无摘要)'}${stat}`, 46)}`);
217
+ for (const item of items) {
218
+ const prefix = item.kind === 'branch' ? '◆ ' : (item.kind === 'session' ? '↳ ' : '');
219
+ lines.push(` ${mermaidText(`${prefix}${item.text}`, 44)}`);
220
+ for (const option of item.options ?? []) lines.push(` ${mermaidText(`○ ${option}`, 30)}`);
221
+ }
222
+ }
223
+ lines.push('```');
224
+ lines.push('');
225
+ const keyCount = model.turns.reduce((sum, t) => sum + t.items.length, 0);
226
+ lines.push(`> 导图仅含轮次、分支点、用户消息与子会话(共 ${keyCount} 个关键节点)。`);
227
+ lines.push(`> 完整过程(${(tree?.nodes ?? []).length} 个节点)请用大纲 Markdown,或配合 Markmap / Markmind 插件阅读。`);
228
+ lines.push('>');
229
+ lines.push(`> 由 dsh-plugin-mind-tree 生成 · 作者:${AUTHOR_NAME} · ${AUTHOR_URL}`);
230
+ lines.push('');
231
+ return lines.join('\n');
232
+ }
233
+
234
+ /** ② 大纲 Markdown:全量节点,YAML 属性 + 轮次标题 + 嵌套列表。 */
235
+ export function buildOutlineMarkdown(tree, options = {}) {
236
+ const branchLabel = options.branchLabel ?? ((kind) => kind);
237
+ const kindLabel = options.kindLabel ?? ((kind) => kind);
238
+ const title = tree?.title ?? tree?.sessionId ?? '思维树';
239
+ const lines = frontmatterLines(tree, 'outline');
240
+ lines.push(`# 思维树: ${title}`);
241
+ lines.push('');
242
+ for (const row of flattenTree(tree)) {
243
+ const node = row.node;
244
+ if (node.kind === 'turn') {
245
+ const usage = node.usage;
246
+ const tokens = usage ? fmtTokens(usage.total || usage.input + usage.output) : '';
247
+ const stat = usage ? ` · ${usage.messageCount ?? 0} 条${tokens ? ` · ${tokens} tok` : ''}` : '';
248
+ const summary = node.summary ? ` · ${node.summary}` : '';
249
+ lines.push('');
250
+ lines.push(`## 第 ${node.turn ?? '?'} 轮${summary}${stat}`);
251
+ continue;
252
+ }
253
+ if (node.id === 'root') continue;
254
+ const depth = Math.max(0, row.depth - 2);
255
+ const indent = ' '.repeat(depth);
256
+ const seqPart = node.seq >= 0 ? `#${node.seq} ` : '';
257
+ const mark = node.branch ? ` 〔分支:${branchLabel(node.branch.kind)}〕` : '';
258
+ const surf = node.surface === 'shadowed' ? ' 〔已脱离上下文〕' : '';
259
+ lines.push(`${indent}- ${seqPart}[${kindLabel(node.kind)}] ${node.label}${mark}${surf}`);
260
+ }
261
+ lines.push('');
262
+ lines.push('---');
263
+ lines.push('');
264
+ lines.push(`> 由 dsh-plugin-mind-tree 生成 · 作者:${AUTHOR_NAME} · ${AUTHOR_URL}`);
265
+ return lines.join('\n');
266
+ }
267
+
268
+ /** ③ Obsidian 原生画布 JSON:可拖拽/编辑的真思维导图(零插件)。 */
269
+ export function buildCanvasJson(tree, options = {}) {
270
+ const branchLabel = options.branchLabel ?? ((kind) => kind);
271
+ const model = buildMindmapModel(tree, { branchLabel, maxUsersPerTurn: options.maxUsersPerTurn });
272
+ const COL = { turn: '6', branch: '2', user: '5', session: '3', more: '4' };
273
+ const canvasNodes = [];
274
+ const canvasEdges = [];
275
+ const rowH = 78;
276
+ const blockGap = 36;
277
+ let cursorY = 0;
278
+ const blocks = [];
279
+ for (const entry of model.turns) {
280
+ const blockHeight = Math.max(96, entry.items.length * rowH);
281
+ blocks.push({ ...entry, top: cursorY, height: blockHeight });
282
+ cursorY += blockHeight + blockGap;
283
+ }
284
+ const totalHeight = Math.max(200, cursorY);
285
+ const totalNodes = (tree?.nodes ?? []).length;
286
+ const rootId = 'root';
287
+ canvasNodes.push({
288
+ id: rootId, type: 'text',
289
+ text: `# ${model.title}\n\n${model.turns.length} 轮 · ${totalNodes} 节点`,
290
+ x: -420, y: totalHeight / 2 - 60, width: 320, height: 120, color: '1',
291
+ });
292
+ for (const block of blocks) {
293
+ const turnY = block.top + block.height / 2 - 45;
294
+ const usage = block.turn.usage;
295
+ const stat = usage
296
+ ? `\n${usage.messageCount ?? 0} 条 · ${fmtTokens(usage.total || usage.input + usage.output)} tok`
297
+ : '';
298
+ canvasNodes.push({
299
+ id: block.turn.id, type: 'text',
300
+ text: `## 第 ${block.turn.turn ?? '?'} 轮 · ${block.turn.summary ?? ''}${stat}`,
301
+ x: -20, y: turnY, width: 340, height: 96, color: COL.turn,
302
+ });
303
+ canvasEdges.push({ id: `e-root-${block.turn.id}`, fromNode: rootId, fromSide: 'right', toNode: block.turn.id, toSide: 'left' });
304
+ block.items.forEach((item, index) => {
305
+ const itemId = `${block.turn.id}-i${index}`;
306
+ canvasNodes.push({
307
+ id: itemId, type: 'text',
308
+ text: item.kind === 'branch' ? `◆ ${item.text}` : item.text,
309
+ x: 400, y: block.top + index * rowH, width: 360, height: 64,
310
+ color: COL[item.kind] ?? '5',
311
+ });
312
+ canvasEdges.push({
313
+ id: `e-${itemId}`, fromNode: block.turn.id, fromSide: 'right', toNode: itemId, toSide: 'left',
314
+ ...(item.kind === 'branch' ? { label: '分支' } : {}),
315
+ });
316
+ (item.options ?? []).forEach((option, optionIndex) => {
317
+ const optionId = `${itemId}-o${optionIndex}`;
318
+ canvasNodes.push({
319
+ id: optionId, type: 'text', text: `○ ${option}`,
320
+ x: 800, y: block.top + index * rowH + optionIndex * 56, width: 260, height: 48, color: '6',
321
+ });
322
+ canvasEdges.push({ id: `e-${optionId}`, fromNode: itemId, fromSide: 'right', toNode: optionId, toSide: 'left' });
323
+ });
324
+ });
325
+ }
326
+ return JSON.stringify({ nodes: canvasNodes, edges: canvasEdges }, null, 2);
327
+ }
328
+
329
+ /** 原始 JSON(供程序化消费)。 */
330
+ export function buildRawJson(tree) {
331
+ return JSON.stringify(tree, null, 2);
332
+ }
333
+
334
+ /** 各格式的文件名 / MIME / 描述。 */
335
+ export const EXPORT_FORMATS = {
336
+ mindmap: { extension: '.md', suffix: '-导图.md', mime: 'text/markdown', description: 'Markdown(思维导图)' },
337
+ outline: { extension: '.md', suffix: '-大纲.md', mime: 'text/markdown', description: 'Markdown(大纲)' },
338
+ canvas: { extension: '.canvas', suffix: '.canvas', mime: 'application/json', description: 'Obsidian Canvas' },
339
+ json: { extension: '.json', suffix: '.json', mime: 'application/json', description: 'JSON' },
340
+ };
341
+
342
+ /**
343
+ * 按格式生成导出内容。
344
+ * @param tree - 思维树数据。
345
+ * @param format - mindmap | outline | canvas | json。
346
+ * @param options - { branchLabel, kindLabel }
347
+ * @returns { fileName, mime, description, content }
348
+ */
349
+ export function buildExport(tree, format, options = {}) {
350
+ const spec = EXPORT_FORMATS[format] ?? EXPORT_FORMATS.mindmap;
351
+ const base = safeFileName(tree?.title ?? tree?.sessionId);
352
+ let content;
353
+ if (format === 'outline') content = buildOutlineMarkdown(tree, options);
354
+ else if (format === 'canvas') content = buildCanvasJson(tree, options);
355
+ else if (format === 'json') content = buildRawJson(tree);
356
+ else content = buildMindmapMarkdown(tree, options);
357
+ return {
358
+ format: EXPORT_FORMATS[format] ? format : 'mindmap',
359
+ fileName: `${base}${spec.suffix}`,
360
+ mime: spec.mime,
361
+ description: spec.description,
362
+ content,
363
+ };
364
+ }