weflow-cli 1.9.0 → 1.11.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.
Files changed (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -0,0 +1,646 @@
1
+ # -*- coding: utf-8 -*-
2
+ """把知识库导成一张**自包含**的 3D 图谱页面(用来"逛",不是用来分析)。
3
+
4
+ 为什么内联而不是让页面去读 json:`file://` 下 `fetch` 被 CORS 挡死,要读外部 json 就得
5
+ 起本地服务器或给浏览器加 `--allow-file-access-from-files`。内联一次,双击就能开。
6
+
7
+ 图的口径(与助手那边同一个):
8
+ - 节点 = 概念页(两个目录都在内)
9
+ - 边 = 页面**正文**里的 `[[链接]]`,且指向另一个概念页
10
+ - **排除 `## 来源` 那一段**:它指向的是卡片,而卡片不在库里,算进去会多出一堆悬空边。
11
+
12
+ 为什么第一版用 `3d-force-graph` 而这一版自己拼 three + d3-force-3d(实测数据):
13
+ 第一版在 RTX 5060 上只有 **2.2 fps** —— 25,431 个球和 70,092 条线各自是一个独立对象,
14
+ 每帧发出 **27,108 次 draw call**(关掉线 9.9 fps、关掉球 6.7 fps,瓶颈就在这个数量上)。
15
+ 现在节点合成**一个** `THREE.Points`、线合成**一个** `LineSegments`:每帧 3~4 次绘制。
16
+ 顺带三个好处:`sizeAttenuation:false` 让点在任何缩放下都是同样大小(第一版缩到 0.2 像素
17
+ 就等于消失了);布局算完即冻结,逛的时候不再吃 CPU;不再依赖 5.8 MB 的捆绑包。
18
+ """
19
+ import argparse
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ # 仓库根 = 这个文件的上两级。**不许按 cwd 找**:CLI 从哪儿调用都要能跑,
28
+ # 而 `output/` 是用户数据、`resources/` 是随包发的静态件,两者的家在仓库里是固定的。
29
+ REPO = Path(__file__).resolve().parent.parent
30
+ sys.path.insert(0, str(REPO / 'scripts'))
31
+ sys.path.insert(0, str(REPO))
32
+ # **先切到仓库根**:`wiki_lint.CARD_DIRS` 是导入时按 cwd 通配 `output/*-notes` 算出来的,
33
+ # 在别的目录里调用会静默换成另一套口径(而 --out / --vault 都是绝对路径,不该被 cwd 影响)。
34
+ os.chdir(REPO)
35
+ import wiki_lint as wl
36
+ import _utils
37
+
38
+ # 页面要用的三个库随包发(`resources/` 整个进 npm 包),布局助手跟它们的库待在一起:
39
+ # 它 `new Function` 跑的就是这几个 UMD,放一起就不会一个在包外、一个在包内。
40
+ LIB_DIR = REPO / 'resources' / 'js' / 'graph3d'
41
+ # 布局缓存是**派生数据**(几千个坐标),跟 graph.json 一起放 output/ 下,不进仓库。
42
+ CACHE_DIR = REPO / 'output' / '.graph3d-cache'
43
+ DEFAULT_VAULT = REPO / 'output' / 'wechat-vault'
44
+ DEFAULT_OUT = REPO / 'output' / 'knowledge-graph-3d.html'
45
+ LIB_THREE = LIB_DIR / 'three.min.js'
46
+ # d3-force-3d 的 UMD 包**不带依赖**:它从全局 `d3` 命名空间里取 dispatch/timer/quadtree/
47
+ # octree/binarytree,所以这五个必须按顺序先引。少一个的报错是 "u.timer is not a function",
48
+ # 看着像库坏了,其实只是缺件。
49
+ LIB_D3 = ['d3-dispatch.min.js', 'd3-timer.min.js', 'd3-quadtree.min.js',
50
+ 'd3-binarytree.min.js', 'd3-octree.min.js', 'd3-force-3d.min.js']
51
+
52
+
53
+ # 布局的力参数**只在这里定义**(`layout.mjs` 只负责照做),而且**要计入缓存 key**:
54
+ # 参数改了而 key 不变,就会一直读到用旧参数算的坐标 —— 这个坑刚踩过(改了 2D 的斥力、
55
+ # 重跑却打印"图没变,沿用已有布局",图上一点变化都没有)。
56
+ #
57
+ # 同一个力参数在 2D 与 3D **不是同一张图**:少一维可摊开,同样的斥力会把 5 万个点压成一坨
58
+ # 密实的圆盘(实测:2D 沿用 3D 的参数,出来是一块没有任何结构的"饼")。
59
+ LAYOUT_PARAMS = {
60
+ '3': {'charge': -22, 'chargeMax': 600, 'linkDist': 22, 'linkStrength': 0.6},
61
+ '2': {'charge': -70, 'chargeMax': 1600, 'linkDist': 55, 'linkStrength': 0.5},
62
+ }
63
+
64
+
65
+ def layout_key(data, dims, ticks):
66
+ """缓存 key:**数据 + 维度 + tick 数 + 力参数**。少任何一项都会读到过期的坐标。"""
67
+ params = LAYOUT_PARAMS[str(dims)]
68
+ payload = '%s|dims=%s|ticks=%s|%s' % (data, dims, ticks, json.dumps(params, sort_keys=True))
69
+ return hashlib.sha1(payload.encode('utf-8')).hexdigest()[:12]
70
+
71
+
72
+ def run_layout(cache_dir, digest, dims, ticks):
73
+ """让 `layout.mjs` 把坐标算好写进缓存(参数由这里给,助手只照做)。"""
74
+ params = json.dumps(LAYOUT_PARAMS[str(dims)], sort_keys=True)
75
+ subprocess.run(['node', str(LIB_DIR / 'layout.mjs'), str(cache_dir), str(LIB_DIR),
76
+ str(ticks), digest, str(dims), params], check=True)
77
+
78
+
79
+ def build_graph(vault, min_degree=0, line='all'):
80
+ """`min_degree` / `line` 只在**产出**这一步过滤,改动的是给页面看的点集。
81
+
82
+ `min_degree` 用的是**原图里**的度数("它总共连了 ≥N 个概念"),不是子图里重算的 ——
83
+ 否则每放大一次门槛,度数就变一次,同一个数在不同页面上含义不同。
84
+ """
85
+ pages = []
86
+ # 概念目录清单来自 `_utils.KNOWLEDGE_LINES`(经 `wiki_lint` 透出)。**别再在这里硬编码**:
87
+ # 这两行曾经把两条线的相对路径直接拼在 `vault` 上,而它们不在任何一致性清单里
88
+ # (`test/concept-dirs-agreement.test.ts` 只管那五处声明)⇒ 清单改了图也不会变,
89
+ # 症状是"图里少一半节点",而且不报错。
90
+ for label, _root, concepts in wl.KNOWLEDGE_LINES:
91
+ d = vault / concepts
92
+ if line != 'all' and label != line:
93
+ continue
94
+ for p in wl.collect(str(d), wl.CARD_DIRS):
95
+ p['line'] = label
96
+ pages.append(p)
97
+ # **主键必须带线**(`wiki:DeepSeek`):两条线各有一张同名页是设计使然,而以 stem 为主键
98
+ # 时它们是**同一个键** —— 合并图里那 36 组同名会互相顶掉(前端按 id 建 Map,后来的覆盖
99
+ # 先来的)。症状很轻:节点总数看着对,只是其中 36 个点不到、边也连着错的那一张。
100
+ by_line = {}
101
+ for p in pages:
102
+ by_line.setdefault(p['line'], set()).add(p['stem'])
103
+ # 线内解析不了时才回退到另一条线:两条线共用的那些概念就是它们之间那 4% 的"重叠信息",
104
+ # 这张图正是要看这个(见 D-051)。回退的那条边方向是明确的:只有一条线有这个名字。
105
+ other_line = {}
106
+ for lbl, stems in by_line.items():
107
+ for stem in stems:
108
+ other_line.setdefault(stem, lbl)
109
+ edges = set()
110
+ for p in pages:
111
+ body = p['body']
112
+ at = body.find('## 来源')
113
+ if at != -1:
114
+ body = body[:at]
115
+ mine = _utils.concept_key(p['line'], p['stem'])
116
+ for _sec, name in wl.links_by_section(body):
117
+ t = name.replace('.md', '').strip()
118
+ here = by_line.get(p['line'], ())
119
+ if t in here:
120
+ target = _utils.concept_key(p['line'], t) # 同一条线里的那个
121
+ elif t in other_line:
122
+ target = _utils.concept_key(other_line[t], t) # 只有另一条线有它
123
+ else:
124
+ continue
125
+ if target != mine:
126
+ edges.add((mine, target))
127
+ degree = {}
128
+ for a, b in edges:
129
+ degree[a] = degree.get(a, 0) + 1
130
+ degree[b] = degree.get(b, 0) + 1
131
+ nodes = [{'id': _utils.concept_key(p['line'], p['stem']), 'name': p['stem'],
132
+ 'deg': degree.get(_utils.concept_key(p['line'], p['stem']), 0), 'line': p['line']}
133
+ for p in pages]
134
+ # 边写成二元数组而不是 {source,target} 对象:7 万条边能省掉 1 MB 的重复键名
135
+ links = [[a, b] for a, b in sorted(edges)]
136
+ if min_degree > 0:
137
+ keep = {n['id'] for n in nodes if n['deg'] >= min_degree}
138
+ # **删点必须连边一起删**:布局按 id 连边,留着指向已删点的边,力导向会直接报错
139
+ # (error: node not found),而且只在跑布局时才炸 —— 页面那边看着像"库坏了"。
140
+ links = [pair for pair in links if pair[0] in keep and pair[1] in keep]
141
+ nodes = [n for n in nodes if n['id'] in keep]
142
+ return nodes, links
143
+
144
+
145
+ VIEWER = r'''
146
+ (function () {
147
+ window.__t0 = performance.now();
148
+ const NODES = window.__DATA__.nodes;
149
+ const LINKS = window.__DATA__.links.map(([s, t]) => ({ source: s, target: t }));
150
+ // 线的展示名由 Python 那边传进来(`_utils.LINE_LABELS`),页面里不再写第二份 ——
151
+ // 合并视图必须说清每个点来自哪条线,而两份名字漂了的那天不会报错。
152
+ const LINE_LABELS = window.__DATA__.lineLabels || {};
153
+ const lineLabel = (n) => LINE_LABELS[n.line] || n.line || '';
154
+ const N = NODES.length;
155
+ const hud = document.getElementById('stats');
156
+ const info = document.getElementById('info');
157
+ const bar = document.getElementById('bar');
158
+ const boottext = document.getElementById('boottext');
159
+ const tag = document.getElementById('tag');
160
+
161
+ // 度 → 暖色:一眼看出哪几个是枢纽(与第一版同一套映射)
162
+ const maxDeg = Math.max(1, ...NODES.map((n) => n.deg));
163
+ const lg = Math.log10(1 + maxDeg);
164
+ const warm = (d) => Math.min(1, Math.log10(1 + d) / lg);
165
+
166
+ // 声明统统提到最前面:resize / 渲染回调里要用,写在后面会踩
167
+ // "Cannot access 'dirty' before initialization"(TDZ),而且只在真的 resize 时才炸
168
+ let points, lines, hubPoints, marker, selLines, hubIdx = [];
169
+ let target, radius, sph, autoRotate = true, dirty = true, fly = null, selected = null;
170
+ // 邻接表**按 id 建键**。一开始这里用 l.source 直接当键,而那时它还是字符串 id,
171
+ // 查询却传节点对象 —— 永远查不到,邻居列表和高亮边静默为空(不报错,只是没有)。
172
+ const byId = new Map(NODES.map((n) => [n.id, n]));
173
+ const neighbors = new Map();
174
+ for (const l of LINKS) {
175
+ if (!neighbors.has(l.source)) neighbors.set(l.source, []);
176
+ if (!neighbors.has(l.target)) neighbors.set(l.target, []);
177
+ neighbors.get(l.source).push(l.target);
178
+ neighbors.get(l.target).push(l.source);
179
+ }
180
+ const nbrs = (id) => (neighbors.get(id) || []).map((k) => byId.get(k)).filter(Boolean);
181
+
182
+ const scene = new THREE.Scene();
183
+ scene.background = new THREE.Color(0x05070e);
184
+ const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 1, 5e5);
185
+ const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
186
+ renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
187
+ renderer.setSize(innerWidth, innerHeight);
188
+ document.getElementById('view').appendChild(renderer.domElement);
189
+ addEventListener('resize', () => {
190
+ camera.aspect = innerWidth / innerHeight;
191
+ camera.updateProjectionMatrix();
192
+ renderer.setSize(innerWidth, innerHeight);
193
+ dirty = true;
194
+ });
195
+
196
+ // ---------- 布局:分块跑,跑完就停 ----------
197
+ // 25,431 个点跑满收敛要几百 tick(每个 tick 几十毫秒),对"逛"没必要 —— 90 tick 已经
198
+ // 能看出结构,剩下的时间不如让页面早点能用。跑完 sim.stop(),之后逛的时候 CPU 是 0。
199
+ let sim = null;
200
+ if (!window.__POS__) {
201
+ sim = d3.forceSimulation(NODES, 3)
202
+ .force('charge', d3.forceManyBody().strength(-22).distanceMax(600).theta(1.1))
203
+ .force('link', d3.forceLink(LINKS).id((d) => d.id).distance(22).strength(0.6))
204
+ .force('center', d3.forceCenter(0, 0, 0))
205
+ .stop();
206
+ }
207
+ const TICKS = 90;
208
+ let done = 0;
209
+ function layout() {
210
+ const t0 = performance.now();
211
+ while (done < TICKS && performance.now() - t0 < 24) { sim.tick(); done += 1; }
212
+ bar.style.width = ((100 * done) / TICKS).toFixed(0) + '%';
213
+ boottext.textContent = '正在布局 ' + N.toLocaleString() + ' 个概念… ' + ((100 * done) / TICKS).toFixed(0) + '%';
214
+ if (done < TICKS) { requestAnimationFrame(layout); return; }
215
+ sim.stop();
216
+ start();
217
+ }
218
+
219
+ // ---------- 建几何 ----------
220
+ function start() {
221
+ // 坐标由 build.py 在 Node 里预先算好内联进来(见 layout.mjs):打开即用,
222
+ // 不用在浏览器里现跑十几秒。没有内联坐标时下面 layout() 会在页面里现算 —— 兜底。
223
+ if (window.__POS__) {
224
+ const p = window.__POS__.split(',').map(Number);
225
+ NODES.forEach((n, i) => { n.x = p[3 * i]; n.y = p[3 * i + 1]; n.z = p[3 * i + 2]; });
226
+ }
227
+ const pos = new Float32Array(N * 3);
228
+ const col = new Float32Array(N * 3);
229
+ NODES.forEach((n, i) => {
230
+ pos[3 * i] = n.x; pos[3 * i + 1] = n.y; pos[3 * i + 2] = n.z;
231
+ const k = warm(n.deg);
232
+ col[3 * i] = (90 + 165 * k) / 255;
233
+ col[3 * i + 1] = (120 - 60 * k) / 255;
234
+ col[3 * i + 2] = (220 - 160 * k) / 255;
235
+ });
236
+ const pgeo = new THREE.BufferGeometry();
237
+ pgeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
238
+ pgeo.setAttribute('color', new THREE.BufferAttribute(col, 3));
239
+ // sizeAttenuation:false ⇒ 不管缩放到哪一层,点永远是 2.2 像素。这是"能逛"的关键:
240
+ // 打开时看整团云是密密麻麻的星点,飞近了星点不会变成糊住的巨球。
241
+ // three 的点**不加贴图就是方片** —— 小点上看不出来,但选中标记放大到 15/40 px 时
242
+ // 明显是个方块。给一张径向渐变的贴图,点就是圆的,还自带柔光。
243
+ const c = document.createElement('canvas');
244
+ c.width = 64; c.height = 64;
245
+ const g2 = c.getContext('2d');
246
+ const rg = g2.createRadialGradient(32, 32, 0, 32, 32, 32);
247
+ rg.addColorStop(0, 'rgba(255,255,255,1)');
248
+ rg.addColorStop(0.4, 'rgba(255,255,255,0.9)');
249
+ rg.addColorStop(1, 'rgba(255,255,255,0)');
250
+ g2.fillStyle = rg; g2.fillRect(0, 0, 64, 64);
251
+ const DOT = new THREE.CanvasTexture(c);
252
+
253
+ points = new THREE.Points(pgeo, new THREE.PointsMaterial({
254
+ size: 3.2, sizeAttenuation: false, vertexColors: true, transparent: true,
255
+ opacity: 0.95, map: DOT, depthWrite: false,
256
+ }));
257
+ scene.add(points);
258
+
259
+ // 枢纽单独一层,点更大:它是"故事的枢纽",得在一堆星点里认出来
260
+ hubIdx = NODES.filter((n) => n.deg >= 40);
261
+ if (hubIdx.length) {
262
+ const hpos = new Float32Array(hubIdx.length * 3);
263
+ const hcol = new Float32Array(hubIdx.length * 3);
264
+ hubIdx.forEach((n, j) => {
265
+ hpos[3 * j] = n.x; hpos[3 * j + 1] = n.y; hpos[3 * j + 2] = n.z;
266
+ hcol[3 * j] = 1; hcol[3 * j + 1] = 0.72; hcol[3 * j + 2] = 0.45;
267
+ });
268
+ const hgeo = new THREE.BufferGeometry();
269
+ hgeo.setAttribute('position', new THREE.BufferAttribute(hpos, 3));
270
+ hgeo.setAttribute('color', new THREE.BufferAttribute(hcol, 3));
271
+ hubPoints = new THREE.Points(hgeo, new THREE.PointsMaterial({
272
+ size: 7, sizeAttenuation: false, vertexColors: true, transparent: true,
273
+ opacity: 0.95, map: DOT, depthWrite: false,
274
+ }));
275
+ scene.add(hubPoints);
276
+ }
277
+
278
+ const lpos = new Float32Array(LINKS.length * 6);
279
+ LINKS.forEach((l, i) => {
280
+ lpos.set([l.source.x, l.source.y, l.source.z, l.target.x, l.target.y, l.target.z], i * 6);
281
+ });
282
+ const lgeo = new THREE.BufferGeometry();
283
+ lgeo.setAttribute('position', new THREE.BufferAttribute(lpos, 3));
284
+ lines = new THREE.LineSegments(lgeo, new THREE.LineBasicMaterial({
285
+ color: 0x35507f, transparent: true, opacity: 0.15,
286
+ }));
287
+ scene.add(lines);
288
+
289
+ // 选中标记(白点 + 光晕两层)与"它连着的边"(另一次绘制)
290
+ const mgeo = new THREE.BufferGeometry();
291
+ mgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3));
292
+ marker = new THREE.Points(mgeo, new THREE.PointsMaterial({
293
+ size: 16, sizeAttenuation: false, color: 0xffffff, transparent: true,
294
+ opacity: 0.95, map: DOT, depthWrite: false,
295
+ }));
296
+ marker.visible = false;
297
+ scene.add(marker);
298
+ const halo = new THREE.Points(mgeo.clone(), new THREE.PointsMaterial({
299
+ size: 46, sizeAttenuation: false, color: 0x7fb0ff, transparent: true,
300
+ opacity: 0.25, map: DOT, depthWrite: false,
301
+ }));
302
+ halo.visible = false;
303
+ scene.add(halo);
304
+ marker.userData.halo = halo;
305
+ selLines = new THREE.LineSegments(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({
306
+ color: 0xffb060, transparent: true, opacity: 0.6,
307
+ }));
308
+ selLines.visible = false;
309
+ scene.add(selLines);
310
+
311
+ // 取景:把整团云框进画面
312
+ let cx = 0, cy = 0, cz = 0;
313
+ for (const n of NODES) { cx += n.x; cy += n.y; cz += n.z; }
314
+ cx /= N; cy /= N; cz /= N;
315
+ radius = 0;
316
+ for (const n of NODES) {
317
+ const d = Math.hypot(n.x - cx, n.y - cy, n.z - cz);
318
+ if (d > radius) radius = d;
319
+ }
320
+ target = new THREE.Vector3(cx, cy, cz);
321
+ const dist = (radius / Math.sin((camera.fov / 2) * Math.PI / 180)) * 1.02;
322
+ sph = { r: dist, theta: 0.7, phi: Math.PI / 2 - 0.22 };
323
+ applyCam();
324
+
325
+ hud.textContent = N.toLocaleString() + ' 个概念 · ' +
326
+ LINKS.length.toLocaleString() + ' 条关系 · ' + (hubIdx.length) + ' 个枢纽';
327
+ tag.textContent = '拖 = 转 · 滚轮 = 缩放 · 点一个概念看它连着谁 · 双击空白 = 停/开自转';
328
+ // 有内联坐标就没什么可等的,直接撤掉加载遮罩
329
+ setTimeout(() => { document.getElementById('boot').style.display = 'none'; },
330
+ window.__POS__ ? 0 : 600);
331
+ // 自检用(探针靠它判断"到底建好了没有",以及量布局花了多久)
332
+ window.__layoutMs = Math.round(performance.now() - window.__t0);
333
+ window.__viz = { nodes: NODES, camera, renderer, scene, select: select_, pick, sph, radius,
334
+ // 给自检用:不然按需渲染下"帧率"量到的是空转的 rAF(144 fps 那种假数字)
335
+ rotate: (v) => { autoRotate = v; dirty = true; },
336
+ info: () => ({ 自动旋转: autoRotate, 脏: dirty, 半径: Math.round(sph.r) }) };
337
+ requestAnimationFrame(loop);
338
+ }
339
+
340
+ function applyCam() {
341
+ camera.position.set(
342
+ target.x + sph.r * Math.sin(sph.phi) * Math.cos(sph.theta),
343
+ target.y + sph.r * Math.cos(sph.phi),
344
+ target.z + sph.r * Math.sin(sph.phi) * Math.sin(sph.theta),
345
+ );
346
+ camera.lookAt(target);
347
+ camera.updateMatrixWorld();
348
+ dirty = true;
349
+ }
350
+
351
+ // ---------- 自己写的轨道控制(够用,且不用再引一个 UMD 包) ----------
352
+ const canvas = () => renderer.domElement;
353
+ let drag = null, moved = 0;
354
+ canvas().addEventListener('mousedown', (e) => {
355
+ drag = { x: e.clientX, y: e.clientY, btn: e.button, shift: e.shiftKey };
356
+ moved = 0;
357
+ });
358
+ addEventListener('mouseup', (e) => {
359
+ if (drag && moved < 5 && drag.btn === 0 && !drag.shift) pick(e.clientX, e.clientY, 16, true);
360
+ drag = null;
361
+ });
362
+ addEventListener('mousemove', (e) => {
363
+ if (!drag) { hover(e.clientX, e.clientY); return; }
364
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
365
+ drag.x = e.clientX; drag.y = e.clientY;
366
+ moved += Math.abs(dx) + Math.abs(dy);
367
+ autoRotate = false;
368
+ if (drag.btn === 2 || drag.shift) {
369
+ // 平移:沿相机的右/上方向挪 target
370
+ const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0);
371
+ const up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1);
372
+ const k = sph.r * 0.0016;
373
+ target.addScaledVector(right, -dx * k).addScaledVector(up, dy * k);
374
+ } else {
375
+ sph.theta -= dx * 0.005;
376
+ sph.phi = Math.max(0.05, Math.min(Math.PI - 0.05, sph.phi - dy * 0.005));
377
+ }
378
+ fly = null;
379
+ applyCam();
380
+ });
381
+ canvas().addEventListener('wheel', (e) => {
382
+ e.preventDefault();
383
+ fly = null;
384
+ sph.r = Math.max(20, Math.min(sph.r * Math.exp(e.deltaY * 0.0011), radius * 6));
385
+ applyCam();
386
+ }, { passive: false });
387
+ canvas().addEventListener('contextmenu', (e) => e.preventDefault());
388
+ canvas().addEventListener('dblclick', () => { autoRotate = !autoRotate; dirty = true; });
389
+
390
+ // ---------- 选中 / 悬停:把点投到屏幕上找最近的 ----------
391
+ // 不做射线求交:2.5 万个点每次投影一遍也就零点几毫秒,比给每个点挂可拾取对象便宜得多。
392
+ const v = new THREE.Vector3();
393
+ function pick(x, y, tol, select) {
394
+ const W = innerWidth, H = innerHeight;
395
+ let best = null, bestD = tol * tol;
396
+ for (let i = 0; i < N; i += 1) {
397
+ const n = NODES[i];
398
+ v.set(n.x, n.y, n.z).project(camera);
399
+ if (v.z > 1) continue;
400
+ const sx = (v.x * 0.5 + 0.5) * W, sy = (-v.y * 0.5 + 0.5) * H;
401
+ const d = (sx - x) * (sx - x) + (sy - y) * (sy - y);
402
+ if (d < bestD) { bestD = d; best = n; }
403
+ }
404
+ if (best && select) select_(best);
405
+ return best;
406
+ }
407
+
408
+ function select_(n) {
409
+ selected = n;
410
+ if (marker) {
411
+ marker.geometry.attributes.position.setXYZ(0, n.x, n.y, n.z);
412
+ marker.geometry.attributes.position.needsUpdate = true;
413
+ marker.visible = true;
414
+ const halo = marker.userData.halo;
415
+ halo.geometry.attributes.position.setXYZ(0, n.x, n.y, n.z);
416
+ halo.geometry.attributes.position.needsUpdate = true;
417
+ halo.visible = true;
418
+ const nb = nbrs(n.id).slice(0, 400);
419
+ const arr = new Float32Array(nb.length * 6);
420
+ nb.forEach((m, i) => {
421
+ arr.set([n.x, n.y, n.z, m.x, m.y, m.z], i * 6);
422
+ });
423
+ selLines.geometry.dispose();
424
+ const g = new THREE.BufferGeometry();
425
+ g.setAttribute('position', new THREE.BufferAttribute(arr, 3));
426
+ selLines.geometry = g;
427
+ selLines.visible = nb.length > 0;
428
+ }
429
+ const nb = nbrs(n.id).slice().sort((a, b) => b.deg - a.deg);
430
+ // 显示一律用 `name`(主键是 `线:名字`)—— 直接把 id 印出来的话,两个同名的概念
431
+ // 会看着像两条线的名字里真有个冒号。
432
+ const head = nb.slice(0, 18).map((m) => m.name).join('、');
433
+ info.innerHTML = '<b>' + esc(n.name) + '</b> · ' + esc(lineLabel(n)) + ' · ' + n.deg + ' 条连接' +
434
+ (nb.length ? '<div class="nb">' + esc(head) + (nb.length > 18 ? ' …(共 ' + nb.length + ')' : '') + '</div>' : '');
435
+ // 取景要让「它 + 它的邻居」一起进画面。固定拉近到 60 单位是错的:枢纽的邻居散在
436
+ // 整团云里(Codex 的 603 个邻居横跨全图),那样屏幕上全是不相干的点。
437
+ // 取邻居距离的 80 分位再乘 2.2 —— 大多数邻居入画,又不会为了几个远亲把镜头拉到天上。
438
+ let framR = 60;
439
+ if (nb.length) {
440
+ const ds = nb.map((m) => Math.hypot(m.x - n.x, m.y - n.y, m.z - n.z)).sort((a, b) => a - b);
441
+ framR = Math.min(radius * 2.5, Math.max(60, ds[Math.floor(ds.length * 0.8)] * 2.2));
442
+ }
443
+ fly = { t: 0, dur: 0.7,
444
+ from: { x: target.x, y: target.y, z: target.z, r: sph.r },
445
+ to: { x: n.x, y: n.y, z: n.z, r: framR } };
446
+ autoRotate = false;
447
+ }
448
+
449
+ function esc(s) {
450
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
451
+ }
452
+
453
+ let hoverAt = 0;
454
+ function hover(x, y) {
455
+ const now = performance.now();
456
+ if (now - hoverAt < 60) return; // 2.5 万次投影不必每像素都做
457
+ hoverAt = now;
458
+ const n = pick(x, y, 9, false);
459
+ if (!n) { tag.textContent = '拖 = 转 · 滚轮 = 缩放 · 点一个概念看它连着谁 · 双击空白 = 停/开自转'; return; }
460
+ tag.textContent = n.name + ' · ' + lineLabel(n) + ' · ' + n.deg + ' 条连接';
461
+ }
462
+
463
+ // 搜索:25,431 个点里靠拖是找不到的,直接跳
464
+ const box = document.getElementById('q');
465
+ box.addEventListener('keydown', (e) => {
466
+ if (e.key !== 'Enter') return;
467
+ const q = box.value.trim().toLowerCase();
468
+ if (!q) return;
469
+ // 搜的是 `name`,不是主键(主键是 `线:名字`,两条线都有的名字按主键搜会只中一个)。
470
+ // 同名两页都在时**两个都要说**:合并视图里这正是要看见的东西,只跳第一个会让人
471
+ // 以为另一条线没有这个概念。
472
+ const exact = NODES.filter((n) => n.name.toLowerCase() === q || n.id.toLowerCase() === q);
473
+ const pool = (exact.length ? exact : NODES.filter((n) => n.name.toLowerCase().includes(q)))
474
+ .slice().sort((a, b) => b.deg - a.deg);
475
+ if (!pool.length) { info.textContent = '没找到「' + box.value.trim() + '」'; return; }
476
+ select_(pool[0]);
477
+ if (pool.length > 1) {
478
+ info.innerHTML += '<div class="nb">两条线都有「' + esc(pool[0].name) + '」:' +
479
+ esc(pool.map((m) => lineLabel(m)).join('、')) + '(这里跳的是连接更多的那张)</div>';
480
+ }
481
+ });
482
+
483
+ // ---------- 渲染循环:按需渲染 ----------
484
+ let last = performance.now();
485
+ function loop(now) {
486
+ const dt = Math.min(0.05, (now - last) / 1000);
487
+ last = now;
488
+ if (fly) {
489
+ fly.t = Math.min(1, fly.t + dt / fly.dur);
490
+ const e = fly.t < 0.5 ? 2 * fly.t * fly.t : 1 - 2 * (1 - fly.t) * (1 - fly.t);
491
+ target.x = fly.from.x + (fly.to.x - fly.from.x) * e;
492
+ target.y = fly.from.y + (fly.to.y - fly.from.y) * e;
493
+ target.z = fly.from.z + (fly.to.z - fly.from.z) * e;
494
+ sph.r = fly.from.r + (fly.to.r - fly.from.r) * e;
495
+ applyCam();
496
+ if (fly.t >= 1) fly = null;
497
+ } else if (autoRotate) {
498
+ sph.theta += 0.16 * dt;
499
+ applyCam();
500
+ }
501
+ if (dirty) { renderer.render(scene, camera); dirty = false; }
502
+ requestAnimationFrame(loop);
503
+ }
504
+
505
+ // 渲染循环**只能**在 start() 里启动:如果在这里就 rAF(loop),而布局还没跑完,
506
+ // loop 第一帧就会读还没赋值的 sph → 抛异常 → 那行 rAF(loop) 不再执行 → 循环死掉,
507
+ // 页面永远是黑的。这个 bug 的表现是"帧率 144 但 draw call 是 0"。
508
+ if (window.__POS__) start(); else layout();
509
+ })();
510
+ '''
511
+
512
+
513
+ def _parse_args(argv):
514
+ ap = argparse.ArgumentParser(description='把知识库导成一张自包含的 3D 图谱页面(只读本地、不联网)')
515
+ ap.add_argument('--vault', default=str(DEFAULT_VAULT), help='Vault 根目录(默认 output/wechat-vault)')
516
+ ap.add_argument('--out', default=str(DEFAULT_OUT), help='页面写到哪(默认 output/knowledge-graph-3d.html)')
517
+ # argparse 的短名要写在 metavar 里(写成 '--min-degree <n>' 是 commander 的语法,
518
+ # dest 会变成别的名字,运行时报 AttributeError)
519
+ ap.add_argument('--min-degree', dest='min_degree', metavar='N', type=int, default=0,
520
+ help='只画在原图里连接数 ≥N 的概念(去掉细枝,看骨架);0 = 全画')
521
+ ap.add_argument('--line', dest='line', metavar='WHICH', choices=['all', 'wiki', 'chat'], default='all',
522
+ help='只画某一条线:文章线的概念页 / 聊天线的概念页 / 两条都画')
523
+ ap.add_argument('--ticks', type=int, default=250, help='力导向迭代次数(默认 250,越多越舒展也越慢)')
524
+ ap.add_argument('--dry-run', action='store_true', help='只报概念与链接数:不写文件、也不算布局')
525
+ ap.add_argument('--cache', default=str(CACHE_DIR),
526
+ help='布局缓存目录(派生数据;测试请指到临时目录,别踩真实的那份)')
527
+ ap.add_argument('--json', action='store_true', help='输出机器可读结果')
528
+ return ap.parse_args(argv)
529
+
530
+
531
+ def _fail(message, as_json):
532
+ if as_json:
533
+ print(json.dumps({'success': False, 'action': 'graph-3d', 'error': message}, ensure_ascii=False))
534
+ else:
535
+ print(message, file=sys.stderr)
536
+ return 1
537
+
538
+
539
+ def _missing_libs():
540
+ return [p.name for p in [LIB_THREE] + [LIB_DIR / f for f in LIB_D3] if not p.exists()]
541
+
542
+
543
+ def main(argv=None):
544
+ # 手跑这份脚本时(不走 CLI 的 pythonBridge)stdout 是本机编码(这台机器是 GBK):
545
+ # 中文会变乱码,遇到非 GBK 字符(`✓` 之类)直接 UnicodeEncodeError 把整次生成挂掉 ——
546
+ # 而它**在写文件之后**打印,所以现象是"生成失败"其实产物已经落盘。
547
+ # 仓库里三十多份脚本都在 main() 里做同一行(见 docs/EXTENDING.md 的 Recipe E)。
548
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
549
+ args = _parse_args(sys.argv[1:] if argv is None else argv)
550
+ vault = Path(args.vault)
551
+ if not any((vault / concepts).is_dir() for _line, _root, concepts in wl.KNOWLEDGE_LINES):
552
+ return _fail('Vault 里没有概念目录:%s(先跑 `wiki compile`,或用 --vault 指定)' % vault, args.json)
553
+
554
+ nodes, links = build_graph(vault, min_degree=args.min_degree, line=args.line)
555
+ if args.dry_run:
556
+ # 只报数:不写文件、也不算布局(布局是最慢的一步,预览不该等它)。
557
+ # 库的存在性检查也放在这之后 —— 预览不内联任何库,缺库不该挡住"先看看有多少个点"。
558
+ if args.json:
559
+ print(json.dumps({'success': True, 'action': 'graph-3d', 'dryRun': True,
560
+ 'nodes': len(nodes), 'links': len(links), 'vault': str(vault),
561
+ 'minDegree': args.min_degree, 'line': args.line,
562
+ 'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True},
563
+ ensure_ascii=False))
564
+ else:
565
+ print('概念 %d 个、链接 %d 条(--dry-run:没有写文件)' % (len(nodes), len(links)))
566
+ return 0
567
+ # 库缺了就说清楚缺哪个 —— 少了它会跑到 new Function 里才炸,报错长得像"库坏了"。
568
+ missing = _missing_libs()
569
+ if missing:
570
+ return _fail('缺库:%s(应当在 %s 下随包发)' % ('、'.join(missing), LIB_DIR), args.json)
571
+ three = LIB_THREE.read_text(encoding='utf-8')
572
+ d3lib = '\n'.join((LIB_DIR / f).read_text(encoding='utf-8') for f in LIB_D3)
573
+ # 页面数据**一份**(含线的展示名),而布局缓存的键是**抽掉展示名的那一份**:
574
+ # 往缓存键里加一个字段就等于换一把键 —— 5 万点的布局要重算 55 秒,而图一个点都没变。
575
+ data = json.dumps({'nodes': nodes, 'links': links}, ensure_ascii=False, separators=(',', ':'))
576
+ html_data = json.dumps({'nodes': nodes, 'links': links, 'lineLabels': _utils.LINE_LABELS},
577
+ ensure_ascii=False, separators=(',', ':'))
578
+ # 布局在 Node 里算(250 tick 实测 55 秒),留到构建期;页面打开就是现成坐标。
579
+ # 图没变就沿用缓存 —— 只改渲染的时候不该每次都等一分钟。
580
+ cache = Path(args.cache)
581
+ cache.mkdir(parents=True, exist_ok=True)
582
+ # **按图的内容分槽**:核心图与全量图是两张不同的图,坐标不通用。共用一份文件名的话,
583
+ # 建完核心图就把 5 万点的坐标挤掉了,切回去要重算 55 秒。
584
+ digest = layout_key(data, 3, args.ticks)
585
+ graph_file = cache / ('graph-%s.json' % digest)
586
+ pos_file = cache / ('positions-%s.json' % digest)
587
+ same = graph_file.exists() and graph_file.read_text(encoding='utf-8') == data
588
+ if same and pos_file.exists():
589
+ print('图没变,沿用已有布局')
590
+ else:
591
+ graph_file.write_text(data, encoding='utf-8')
592
+ run_layout(cache, digest, 3, args.ticks)
593
+ pos = pos_file.read_text(encoding='utf-8')
594
+ page = f"""<!doctype html>
595
+ <html lang="zh"><head><meta charset="utf-8">
596
+ <title>知识库 3D({len(nodes)} 个概念)</title>
597
+ <style>
598
+ html,body {{ margin:0; height:100%; background:#05070e; color:#c9d3e6;
599
+ font:13px/1.5 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
600
+ overflow:hidden; user-select:none; }}
601
+ #view {{ position:fixed; inset:0; }}
602
+ #view canvas {{ display:block; cursor:crosshair; }}
603
+ #hud {{ position:fixed; left:14px; top:12px; pointer-events:none; max-width:60vw; }}
604
+ #stats {{ color:#6d7a93; }}
605
+ #info {{ margin-top:6px; font-size:15px; min-height:22px; }}
606
+ #info .nb {{ color:#8b98b3; font-size:12px; margin-top:3px; max-width:52vw; }}
607
+ #q {{ position:fixed; right:14px; top:12px; width:220px; padding:6px 10px;
608
+ background:#121826; color:#c9d3e6; border:1px solid #26304a; border-radius:8px; outline:none; }}
609
+ #q:focus {{ border-color:#3f6ad8; }}
610
+ #tag {{ position:fixed; right:14px; bottom:10px; color:#4d5a72; text-align:right; }}
611
+ #boot {{ position:fixed; inset:0; display:flex; flex-direction:column; align-items:center;
612
+ justify-content:center; gap:14px; background:#05070e; }}
613
+ #boot .b {{ width:260px; height:3px; background:#161d2e; border-radius:2px; overflow:hidden; }}
614
+ #bar {{ width:0; height:100%; background:#4a7fe0; transition:width .1s linear; }}
615
+ </style></head>
616
+ <body>
617
+ <div id="view"></div>
618
+ <div id="boot"><div id="boottext">正在布局…</div><div class="b"><div id="bar"></div></div></div>
619
+ <div id="hud"><div id="stats"></div><div id="info">点一个概念看它连着谁 · 拖动旋转 · 滚轮缩放</div></div>
620
+ <input id="q" placeholder="搜概念,回车跳过去">
621
+ <div id="tag">拖 = 转 · 滚轮 = 缩放 · 点一个概念看它连着谁 · 双击空白 = 停/开自转</div>
622
+ <script>{three}</script>
623
+ <script>{d3lib}</script>
624
+ <script>window.__DATA__ = {html_data};</script>
625
+ <script>window.__POS__ = "{pos}";</script>
626
+ <script>{VIEWER}</script>
627
+ </body></html>
628
+ """
629
+ out = Path(args.out)
630
+ out.parent.mkdir(parents=True, exist_ok=True)
631
+ out.write_text(page, encoding='utf-8')
632
+ mb = out.stat().st_size / 1048576
633
+ if args.json:
634
+ print(json.dumps({'success': True, 'action': 'graph-3d', 'nodes': len(nodes), 'links': len(links),
635
+ 'out': str(out), 'vault': str(vault), 'mb': round(mb, 2),
636
+ 'minDegree': args.min_degree, 'line': args.line,
637
+ 'selfContained': True, 'readsLocalData': True, 'invokesAI': False,
638
+ 'sendsNothing': True}, ensure_ascii=False))
639
+ else:
640
+ print('已生成 %s' % out)
641
+ print(' 节点 %d / 边 %d / 文件 %.1f MB' % (len(nodes), len(links), mb))
642
+ return 0
643
+
644
+
645
+ if __name__ == '__main__':
646
+ sys.exit(main())