weflow-cli 1.8.2 → 1.10.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 (185) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1290 -0
  3. package/OPERATIONS.md +144 -11
  4. package/README.en.md +20 -4
  5. package/README.md +30 -5
  6. package/bin/weflow-cli-electron.cjs +2 -0
  7. package/bin/weflow-cli.ts +996 -118
  8. package/dist/bin/weflow-cli.js +1038 -146
  9. package/dist/bin/weflow-cli.js.map +1 -1
  10. package/dist/mcp-server/http.d.ts +14 -0
  11. package/dist/mcp-server/http.d.ts.map +1 -0
  12. package/dist/mcp-server/http.js +84 -0
  13. package/dist/mcp-server/http.js.map +1 -0
  14. package/dist/mcp-server/httpConfig.d.ts +50 -0
  15. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  16. package/dist/mcp-server/httpConfig.js +104 -0
  17. package/dist/mcp-server/httpConfig.js.map +1 -0
  18. package/dist/mcp-server/index.d.ts +13 -1
  19. package/dist/mcp-server/index.d.ts.map +1 -1
  20. package/dist/mcp-server/index.js +58 -374
  21. package/dist/mcp-server/index.js.map +1 -1
  22. package/dist/src/core/dbPathService.d.ts +12 -0
  23. package/dist/src/core/dbPathService.d.ts.map +1 -1
  24. package/dist/src/core/dbPathService.js +20 -3
  25. package/dist/src/core/dbPathService.js.map +1 -1
  26. package/dist/src/panel/server.d.ts.map +1 -1
  27. package/dist/src/panel/server.js +61 -1
  28. package/dist/src/panel/server.js.map +1 -1
  29. package/dist/src/services/assistantEval.d.ts +69 -0
  30. package/dist/src/services/assistantEval.d.ts.map +1 -1
  31. package/dist/src/services/assistantEval.js +270 -8
  32. package/dist/src/services/assistantEval.js.map +1 -1
  33. package/dist/src/services/assistantScenes.d.ts +81 -0
  34. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  35. package/dist/src/services/assistantScenes.js +322 -0
  36. package/dist/src/services/assistantScenes.js.map +1 -0
  37. package/dist/src/services/assistantService.d.ts +33 -4
  38. package/dist/src/services/assistantService.d.ts.map +1 -1
  39. package/dist/src/services/assistantService.js +102 -6
  40. package/dist/src/services/assistantService.js.map +1 -1
  41. package/dist/src/services/assistantSkills.d.ts +94 -0
  42. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  43. package/dist/src/services/assistantSkills.js +330 -0
  44. package/dist/src/services/assistantSkills.js.map +1 -0
  45. package/dist/src/services/assistantTools.d.ts +34 -1
  46. package/dist/src/services/assistantTools.d.ts.map +1 -1
  47. package/dist/src/services/assistantTools.js +931 -88
  48. package/dist/src/services/assistantTools.js.map +1 -1
  49. package/dist/src/services/assistantTrace.d.ts +4 -0
  50. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  51. package/dist/src/services/assistantTrace.js +9 -0
  52. package/dist/src/services/assistantTrace.js.map +1 -1
  53. package/dist/src/services/configService.d.ts +9 -0
  54. package/dist/src/services/configService.d.ts.map +1 -1
  55. package/dist/src/services/configService.js +5 -2
  56. package/dist/src/services/configService.js.map +1 -1
  57. package/dist/src/services/messageQuery.d.ts +18 -0
  58. package/dist/src/services/messageQuery.d.ts.map +1 -1
  59. package/dist/src/services/messageQuery.js +29 -0
  60. package/dist/src/services/messageQuery.js.map +1 -1
  61. package/dist/src/services/outboundSend.d.ts +53 -0
  62. package/dist/src/services/outboundSend.d.ts.map +1 -0
  63. package/dist/src/services/outboundSend.js +90 -0
  64. package/dist/src/services/outboundSend.js.map +1 -0
  65. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  66. package/dist/src/services/pythonBridge.js +1 -0
  67. package/dist/src/services/pythonBridge.js.map +1 -1
  68. package/dist/src/services/wechatClient.d.ts +125 -0
  69. package/dist/src/services/wechatClient.d.ts.map +1 -0
  70. package/dist/src/services/wechatClient.js +344 -0
  71. package/dist/src/services/wechatClient.js.map +1 -0
  72. package/dist/src/services/wechatMedia.d.ts +44 -0
  73. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  74. package/dist/src/services/wechatMedia.js +155 -0
  75. package/dist/src/services/wechatMedia.js.map +1 -0
  76. package/dist/src/services/wechatMessageService.d.ts +11 -0
  77. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  78. package/dist/src/services/wechatMessageService.js +55 -11
  79. package/dist/src/services/wechatMessageService.js.map +1 -1
  80. package/dist/src/types.d.ts +4 -0
  81. package/dist/src/types.d.ts.map +1 -1
  82. package/dist/src/utils/python.js +2 -2
  83. package/dist/src/utils/python.js.map +1 -1
  84. package/docs/BRANCHES.md +37 -0
  85. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  86. package/docs/DECISIONS.md +1624 -0
  87. package/docs/EXTENDING.md +98 -10
  88. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  89. package/docs/HEALTH-CHECK.md +12 -7
  90. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  91. package/docs/MCP.md +58 -4
  92. package/docs/PROJECT_STATE.md +180 -11
  93. package/docs/RELEASING.md +115 -1
  94. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  95. package/logo.png +0 -0
  96. package/mcp-server/http.ts +108 -0
  97. package/mcp-server/httpConfig.ts +126 -0
  98. package/mcp-server/index.ts +359 -681
  99. package/package.json +5 -3
  100. package/resources/js/graph3d/NOTICE.txt +35 -0
  101. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  102. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  103. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  104. package/resources/js/graph3d/d3-octree.min.js +2 -0
  105. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  106. package/resources/js/graph3d/d3-timer.min.js +2 -0
  107. package/resources/js/graph3d/layout.mjs +63 -0
  108. package/resources/js/graph3d/three.min.js +6 -0
  109. package/resources/js/graph3d/viewer2d.js +448 -0
  110. package/resources/panel/ball-position.cjs +48 -2
  111. package/resources/panel/index.html +18 -4
  112. package/resources/panel/main.cjs +137 -5
  113. package/resources/panel/mascot-base.png +0 -0
  114. package/resources/panel/mascot-blink-1.png +0 -0
  115. package/resources/panel/mascot-blink-2.png +0 -0
  116. package/resources/panel/mascot-iris.png +0 -0
  117. package/resources/panel/mascot-lift-down-1.png +0 -0
  118. package/resources/panel/mascot-lift-down-2.png +0 -0
  119. package/resources/panel/mascot-lift-down-3.png +0 -0
  120. package/resources/panel/mascot-lift-down-4.png +0 -0
  121. package/resources/panel/mascot-lift-down-5.png +0 -0
  122. package/resources/panel/mascot-lift-down-6.png +0 -0
  123. package/resources/panel/mascot-lift-up-1.png +0 -0
  124. package/resources/panel/mascot-lift-up-2.png +0 -0
  125. package/resources/panel/mascot-lift-up-3.png +0 -0
  126. package/resources/panel/mascot-lift-up-4.png +0 -0
  127. package/resources/panel/mascot-lift-up-5.png +0 -0
  128. package/resources/panel/mascot-lift-up-6.png +0 -0
  129. package/resources/panel/mascot-peek.png +0 -0
  130. package/resources/panel/mascot-tickle-1.png +0 -0
  131. package/resources/panel/mascot-tickle-2.png +0 -0
  132. package/resources/panel/mascot-tickle-3.png +0 -0
  133. package/resources/panel/mascot-tickle-4.png +0 -0
  134. package/resources/panel/panel.css +205 -59
  135. package/resources/panel/preload.cjs +26 -0
  136. package/resources/panel/renderer.js +646 -17
  137. package/scripts/_utils.py +1203 -931
  138. package/scripts/backfill_articles.py +202 -21
  139. package/scripts/biz_daily.py +26 -34
  140. package/scripts/bonds.py +331 -0
  141. package/scripts/chat_notes.py +561 -470
  142. package/scripts/chat_stats.py +8 -7
  143. package/scripts/check_readme_commands.py +426 -0
  144. package/scripts/compile_wiki.py +187 -59
  145. package/scripts/contact_schema.py +339 -0
  146. package/scripts/data/wechat_emoji.json +225 -0
  147. package/scripts/export_chat_html.py +3 -1
  148. package/scripts/fav_notes.py +4 -1
  149. package/scripts/graph_2d.py +151 -0
  150. package/scripts/graph_3d.py +646 -0
  151. package/scripts/health_check.py +163 -26
  152. package/scripts/mcp_bridge.py +6 -7
  153. package/scripts/nt_decrypt.py +3 -3
  154. package/scripts/panel_frames.py +683 -0
  155. package/scripts/pipeline.py +21 -10
  156. package/scripts/quality_eval.py +5 -0
  157. package/scripts/rag_chat.py +33 -16
  158. package/scripts/reply_debt.py +26 -6
  159. package/scripts/semantic_search.py +130 -22
  160. package/scripts/vault_rag.py +42 -16
  161. package/scripts/vault_search.py +44 -8
  162. package/scripts/wechat_emoticon.py +6 -0
  163. package/scripts/wechat_voice.py +9 -0
  164. package/scripts/wiki_lint.py +124 -24
  165. package/src/core/dbPathService.ts +22 -4
  166. package/src/panel/server.ts +58 -1
  167. package/src/services/assistantEval.ts +323 -12
  168. package/src/services/assistantScenes.ts +342 -0
  169. package/src/services/assistantService.ts +107 -6
  170. package/src/services/assistantSkills.ts +364 -0
  171. package/src/services/assistantTools.ts +2452 -1640
  172. package/src/services/assistantTrace.ts +9 -0
  173. package/src/services/configService.ts +415 -403
  174. package/src/services/messageQuery.ts +29 -0
  175. package/src/services/outboundSend.ts +126 -0
  176. package/src/services/pythonBridge.ts +1 -0
  177. package/src/services/wechatClient.ts +380 -0
  178. package/src/services/wechatMedia.ts +166 -0
  179. package/src/services/wechatMessageService.ts +62 -11
  180. package/src/types.ts +4 -0
  181. package/src/utils/python.ts +2 -2
  182. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  183. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  184. package/dist/src/utils/wechatEmoji.js +0 -35
  185. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -0,0 +1,448 @@
1
+ // 2D 图谱视图(canvas)。数据与坐标都是构建期内联进来的,这里只负责画与交互。
2
+ //
3
+ // 为什么是 canvas 而不是 SVG/DOM:5 万个点用元素画,光建元素就要几秒 —— 这和 Obsidian 全局图谱
4
+ // 在十万级上吃力的原因同类。这里每帧只做三件事:一次批量描线、按线分三批批量填点、一小批文字。
5
+ // 视口外的点与线直接跳过(所以缩放到任何一级,画的都只是眼前这些)。
6
+ //
7
+ // 坐标分两套,别混:**世界坐标**(构建期算出来的那套,成千上万)用来画点线,套在 context 变换里;
8
+ // **屏幕坐标**用来写字与命中判断。第一版把两套混着用,图形会被画到屏幕外。
9
+ //
10
+ // 标签只给度数最高的一小批:5 万个名字全画上去就是一团墨,什么也读不出来。
11
+ (() => {
12
+ 'use strict'
13
+
14
+ const data = window.__DATA__ || { nodes: [], links: [] }
15
+ const nodes = data.nodes
16
+ const N = nodes.length
17
+ const flat = String(window.__POS__ || '').split(',')
18
+ const X = new Float64Array(N)
19
+ const Y = new Float64Array(N)
20
+ for (let i = 0; i < N; i += 1) {
21
+ X[i] = Number(flat[2 * i]) || 0
22
+ Y[i] = Number(flat[2 * i + 1]) || 0
23
+ }
24
+
25
+ const index = new Map()
26
+ for (let i = 0; i < N; i += 1) index.set(nodes[i].id, i)
27
+
28
+ // **画给人看的一律用 `name`**:主键是 `线:名字`(两条线各有一张同名页时要分开),
29
+ // 直接把主键印在画布上,两个同名概念会像叫「wiki:DeepSeek」「chat:DeepSeek」。
30
+ const LINE_LABELS = data.lineLabels || {}
31
+ const label = (n) => n.name || n.id
32
+ const lineLabel = (n) => LINE_LABELS[n.line] || n.line || ''
33
+
34
+ const edges = []
35
+ for (const pair of data.links) {
36
+ const a = index.get(pair[0])
37
+ const b = index.get(pair[1])
38
+ if (a === undefined || b === undefined) continue // 悬空边(上游过滤过,这里再兜一次)
39
+ edges.push(a, b)
40
+ }
41
+ const E = Int32Array.from(edges)
42
+ const adj = new Array(N)
43
+ for (let i = 0; i < N; i += 1) adj[i] = []
44
+ for (let k = 0; k < E.length; k += 2) {
45
+ adj[E[k]].push(E[k + 1])
46
+ adj[E[k + 1]].push(E[k])
47
+ }
48
+
49
+ const bucketOf = (line) => (line === 'wiki' || line === 'chat' ? line : 'other')
50
+ const LINE_COLOR = { wiki: '#6ea8fe', chat: '#f0a35e', other: '#8b98b3' }
51
+
52
+ // **按线预排一次**:每帧就能一趟扫完。不为分色把 5 万个点扫三遍 —— 省下的是纯遍历时间。
53
+ const ORDER = new Int32Array(N)
54
+ for (let i = 0; i < N; i += 1) ORDER[i] = i
55
+ ORDER.sort((a, b) => bucketOf(nodes[a].line).localeCompare(bucketOf(nodes[b].line)))
56
+ // 排序后同一条线的点连成一段,扫一遍就能得到三个桶的边界(顺序与上面的字典序一致)
57
+ const BUCKET_LINE = ['chat', 'other', 'wiki']
58
+ const BUCKET = [0, 0, 0, N]
59
+ {
60
+ let cursor = 0
61
+ for (let b = 0; b < 3; b += 1) {
62
+ BUCKET[b] = cursor
63
+ while (cursor < N && bucketOf(nodes[ORDER[cursor]].line) === BUCKET_LINE[b]) cursor += 1
64
+ }
65
+ BUCKET[3] = N
66
+ }
67
+
68
+ // 边的**世界坐标**中位长度:乘上当前缩放就是屏幕上的长度。缩到看不见时直接不画边 ——
69
+ // 一屏 14 万条线糊成一片,没有信息量,却要花掉每帧四成的时间。
70
+ const MEDIAN_EDGE = (() => {
71
+ if (E.length === 0) return 0
72
+ const total = E.length / 2
73
+ const step = Math.max(1, Math.floor(total / 2000))
74
+ const lens = []
75
+ for (let k = 0; k < E.length; k += 2 * step) {
76
+ lens.push(Math.hypot(X[E[k]] - X[E[k + 1]], Y[E[k]] - Y[E[k + 1]]))
77
+ }
78
+ lens.sort((a, b) => a - b)
79
+ return lens[lens.length >> 1] || 0
80
+ })()
81
+ const EDGE_MIN_PX = 2 // 屏幕上短于这么多像素的边就别画了
82
+
83
+ // 度数最高的一批画名字(其余只在鼠标指着或点开时显示)
84
+ const labelled = nodes
85
+ .map((_n, i) => i)
86
+ .sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))
87
+ .slice(0, 80)
88
+ const labelledSet = new Set(labelled)
89
+ const LABEL_MAX = 40 // 一屏最多这么多名字,超了就前面的优先(还得互相避让,见 draw 里那段)
90
+
91
+ const canvas = document.getElementById('cv')
92
+ const hudStats = document.getElementById('stats')
93
+ const hudInfo = document.getElementById('info')
94
+ const boot = document.getElementById('boot')
95
+ const q = document.getElementById('q')
96
+ const HINT = '拖动平移 · 滚轮缩放 · 点一个概念看它连谁 · 双击空白复位'
97
+
98
+ let ctx = null
99
+ try {
100
+ ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null
101
+ } catch (_error) {
102
+ ctx = null
103
+ }
104
+ if (!ctx) {
105
+ if (boot) boot.textContent = '这个浏览器给不出 2D 画布,图谱无法渲染'
106
+ return
107
+ }
108
+
109
+ const dpr = Math.min(2, window.devicePixelRatio || 1)
110
+ const view = { scale: 1, x: 0, y: 0 } // 世界 → 屏幕:sx = view.x + X * scale
111
+ let hovered = -1
112
+ let focus = -1
113
+ let neighbours = null
114
+
115
+ const cssW = () => canvas.clientWidth || window.innerWidth || 800
116
+ const cssH = () => canvas.clientHeight || window.innerHeight || 600
117
+
118
+ function escapeHtml(text) {
119
+ return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
120
+ }
121
+
122
+ function resize() {
123
+ const w = cssW()
124
+ const h = cssH()
125
+ // **画布尺寸与 CSS 尺寸必须一起设**:canvas 是替换元素,`position:fixed; inset:0` 不会
126
+ // 让它铺满(它保持固有的 300x150)—— 只设 width/height(后备缓冲)而 CSS 盒还是 300x150 的话,
127
+ // `clientWidth` 就一直是 300,整张图会被算进左上角那一小块。第一版就是这么错的。
128
+ canvas.style.width = `${w}px`
129
+ canvas.style.height = `${h}px`
130
+ canvas.width = Math.floor(w * dpr)
131
+ canvas.height = Math.floor(h * dpr)
132
+ draw()
133
+ }
134
+
135
+ function fit() {
136
+ let minX = Infinity
137
+ let minY = Infinity
138
+ let maxX = -Infinity
139
+ let maxY = -Infinity
140
+ for (let i = 0; i < N; i += 1) {
141
+ if (X[i] < minX) minX = X[i]
142
+ if (X[i] > maxX) maxX = X[i]
143
+ if (Y[i] < minY) minY = Y[i]
144
+ if (Y[i] > maxY) maxY = Y[i]
145
+ }
146
+ if (!Number.isFinite(minX)) {
147
+ view.scale = 1
148
+ view.x = cssW() / 2
149
+ view.y = cssH() / 2
150
+ draw()
151
+ return
152
+ }
153
+ const pad = 28
154
+ view.scale = Math.max(1e-6, Math.min((cssW() - pad * 2) / Math.max(1, maxX - minX),
155
+ (cssH() - pad * 2) / Math.max(1, maxY - minY)))
156
+ view.x = cssW() / 2 - ((minX + maxX) / 2) * view.scale
157
+ view.y = cssH() / 2 - ((minY + maxY) / 2) * view.scale
158
+ draw()
159
+ }
160
+
161
+ /** 屏幕坐标 → 命中的节点下标(找不到是 -1)。 */
162
+ function pick(px, py) {
163
+ const s = view.scale
164
+ const wx = (px - view.x) / s
165
+ const wy = (py - view.y) / s
166
+ const tol = 7 / s
167
+ let best = -1
168
+ let bestD = tol * tol
169
+ for (let i = 0; i < N; i += 1) {
170
+ const dx = X[i] - wx
171
+ const dy = Y[i] - wy
172
+ const d = dx * dx + dy * dy
173
+ if (d <= bestD) {
174
+ bestD = d
175
+ best = i
176
+ }
177
+ }
178
+ return best
179
+ }
180
+
181
+ function draw() {
182
+ const w = cssW()
183
+ const h = cssH()
184
+ const s = view.scale
185
+ const ox = view.x
186
+ const oy = view.y
187
+
188
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
189
+ ctx.clearRect(0, 0, w, h)
190
+
191
+ // 视口在世界坐标里的范围(多留一点余量,免得边缘的点被切掉半个)
192
+ const pad = 4 / s
193
+ const x0 = -ox / s - pad
194
+ const y0 = -oy / s - pad
195
+ const x1 = (w - ox) / s + pad
196
+ const y1 = (h - oy) / s + pad
197
+
198
+ // ---- 世界坐标:点与线 ----
199
+ ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * ox, dpr * oy)
200
+
201
+ // 聚焦时把无关的点线压暗(不是藏起来):结构还在,但一眼看得出它连的是谁
202
+ const dimOthers = focus >= 0
203
+ const near = (i) => !dimOthers || i === focus || (neighbours !== null && neighbours.has(i))
204
+
205
+ // 屏幕上的边短到看不清时,整批不画(见 MEDIAN_EDGE 那段)
206
+ const drawEdges = MEDIAN_EDGE * s >= EDGE_MIN_PX
207
+ ctx.lineWidth = 0.7 / s
208
+ ctx.strokeStyle = dimOthers ? 'rgba(96,116,152,0.10)' : 'rgba(96,116,152,0.26)'
209
+ ctx.beginPath()
210
+ for (let k = 0; drawEdges && k < E.length; k += 2) {
211
+ const i = E[k]
212
+ const j = E[k + 1]
213
+ if (dimOthers && !(near(i) && near(j))) continue
214
+ const xi = X[i]
215
+ const yi = Y[i]
216
+ const xj = X[j]
217
+ const yj = Y[j]
218
+ if ((xi < x0 && xj < x0) || (xi > x1 && xj > x1)) continue
219
+ if ((yi < y0 && yj < y0) || (yi > y1 && yj > y1)) continue
220
+ ctx.moveTo(xi, yi)
221
+ ctx.lineTo(xj, yj)
222
+ }
223
+ ctx.stroke()
224
+
225
+ // 一趟扫完(ORDER 已按线排好,每段一个桶一次 fill),而不是为了分色把全部点扫三遍
226
+ for (let b = 0; b < 3; b += 1) {
227
+ const from = BUCKET[b]
228
+ const to = BUCKET[b + 1]
229
+ if (from === to) continue
230
+ ctx.beginPath()
231
+ ctx.fillStyle = LINE_COLOR[BUCKET_LINE[b]]
232
+ let any = false
233
+ for (let k = from; k < to; k += 1) {
234
+ const i = ORDER[k]
235
+ if (X[i] < x0 || X[i] > x1 || Y[i] < y0 || Y[i] > y1) continue
236
+ if (dimOthers && !near(i)) continue
237
+ const deg = nodes[i].deg || 0
238
+ const r = (i === focus ? 4 : deg >= 50 ? 2.6 : deg >= 10 ? 1.9 : 1.4) / s
239
+ // **屏幕上小到看不出圆角时用方块**:实测 5 万个方块 3.4ms、5 万个圆 9.0ms。
240
+ // 缩到最远时每个点不到 2px,方块与圆在这里没有任何视觉差别。
241
+ if (r * s < 2) ctx.rect(X[i] - r, Y[i] - r, r * 2, r * 2)
242
+ else {
243
+ ctx.moveTo(X[i] + r, Y[i])
244
+ ctx.arc(X[i], Y[i], r, 0, Math.PI * 2)
245
+ }
246
+ any = true
247
+ }
248
+ if (any) ctx.fill()
249
+ }
250
+
251
+ // ---- 屏幕坐标:名字 ----
252
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
253
+ ctx.font = '11px "Segoe UI","Microsoft YaHei",system-ui,sans-serif'
254
+ ctx.textAlign = 'left'
255
+ ctx.textBaseline = 'middle'
256
+ // 只遍历"要画名字的那一小批",而不是全部 5 万个(以前每帧都要扫 N 次 Set 查询)
257
+ const extra = []
258
+ if (hovered >= 0 && !labelledSet.has(hovered)) extra.push(hovered)
259
+ if (focus >= 0 && focus !== hovered && !labelledSet.has(focus)) extra.push(focus)
260
+ // **聚焦时的候选换成"它自己 + 它连着的那些"**:其余点都压暗了,给它们画名字只会糊住
261
+ // 中间那个。邻居按度数从高到低(`LABEL_MAX` 截断时先给连接多的),所以同一张图每次
262
+ // 画出来的名字是一样的。
263
+ //
264
+ // 这里原来写的是"聚焦就只画 `extra`",而 `extra` 只装**不在度数最高那批里的**点 ——
265
+ // 于是点开一个枢纽(小图里就是点开任何一个点)画布上**一个名字都没有**,正好与上面
266
+ // "一眼看得出它连的是谁"相反(2026-10-11 修)。
267
+ const focusLabels = []
268
+ if (dimOthers) {
269
+ focusLabels.push(focus)
270
+ if (neighbours) {
271
+ for (const j of [...neighbours].sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))) {
272
+ focusLabels.push(j)
273
+ }
274
+ }
275
+ }
276
+ // **互相压住的名字谁也别画**(贪心避让):度数最高的那批全是枢纽,它们本来就挤在中心,
277
+ // 早先那版把它们全画出来,正中间是一团白字,等于没有标签。鼠标指着/点开的那两个永远画。
278
+ const placed = []
279
+ let shownLabels = 0
280
+ for (const i of dimOthers ? focusLabels.concat(extra) : labelled.concat(extra)) {
281
+ const sx = ox + X[i] * s
282
+ const sy = oy + Y[i] * s
283
+ if (sx < -40 || sy < -20 || sx > w + 40 || sy > h + 20) continue
284
+ const forced = i === hovered || i === focus
285
+ if (!forced) {
286
+ if (shownLabels >= LABEL_MAX) continue
287
+ let clash = false
288
+ for (let p = 0; p < placed.length; p += 1) {
289
+ if (Math.abs(placed[p][0] - sx) < 52 && Math.abs(placed[p][1] - sy) < 13) {
290
+ clash = true
291
+ break
292
+ }
293
+ }
294
+ if (clash) continue
295
+ }
296
+ placed.push([sx, sy])
297
+ shownLabels += 1
298
+ ctx.fillStyle = forced ? '#ffffff' : 'rgba(201,211,230,0.78)'
299
+ ctx.fillText(label(nodes[i]), sx + 6, sy)
300
+ }
301
+ }
302
+
303
+ // **每帧最多画一次。** 拖动、滚轮、悬停原本是每个事件都直接 draw();事件比一帧还密时
304
+ // (拖动时一秒几百个 pointermove)画布就一直在重画,一次 12.8ms —— 操作会"粘住",这才是
305
+ // 它最初卡的主因。合并成每帧一次之后,帧时间就是那一次 draw 的时间,交互立刻顺。
306
+ const RAF = (typeof window.requestAnimationFrame === 'function')
307
+ ? (fn) => window.requestAnimationFrame(fn)
308
+ : (fn) => setTimeout(fn, 16)
309
+ let scheduled = false
310
+ function scheduleDraw() {
311
+ if (scheduled) return
312
+ scheduled = true
313
+ RAF(() => {
314
+ scheduled = false
315
+ draw()
316
+ })
317
+ }
318
+
319
+ function setFocus(i) {
320
+ focus = i
321
+ if (i >= 0) {
322
+ neighbours = new Set(adj[i])
323
+ if (hudInfo) {
324
+ const names = [...neighbours].slice(0, 6).map((j) => label(nodes[j])).join('、')
325
+ const more = neighbours.size > 6 ? ' 等' : ''
326
+ hudInfo.innerHTML = `<b>${escapeHtml(label(nodes[i]))}</b> · ${escapeHtml(lineLabel(nodes[i]))} · 连 ${neighbours.size} 个(${escapeHtml(names)}${more})`
327
+ }
328
+ } else {
329
+ neighbours = null
330
+ if (hudInfo) hudInfo.textContent = HINT
331
+ }
332
+ scheduleDraw()
333
+ }
334
+
335
+ // ---- 交互 ----
336
+ let dragging = false
337
+ let lastX = 0
338
+ let lastY = 0
339
+ let moved = 0
340
+
341
+ canvas.addEventListener('pointerdown', (event) => {
342
+ if (event.button !== 0) return
343
+ dragging = true
344
+ moved = 0
345
+ lastX = event.clientX
346
+ lastY = event.clientY
347
+ if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId)
348
+ })
349
+ canvas.addEventListener('pointermove', (event) => {
350
+ if (dragging) {
351
+ const dx = event.clientX - lastX
352
+ const dy = event.clientY - lastY
353
+ moved += Math.abs(dx) + Math.abs(dy)
354
+ lastX = event.clientX
355
+ lastY = event.clientY
356
+ view.x += dx
357
+ view.y += dy
358
+ scheduleDraw()
359
+ return
360
+ }
361
+ const rect = canvas.getBoundingClientRect()
362
+ const hit = pick(event.clientX - rect.left, event.clientY - rect.top)
363
+ if (hit !== hovered) {
364
+ hovered = hit
365
+ canvas.style.cursor = hit >= 0 ? 'pointer' : 'grab'
366
+ scheduleDraw()
367
+ }
368
+ })
369
+ canvas.addEventListener('pointerup', (event) => {
370
+ if (!dragging) return
371
+ dragging = false
372
+ if (moved > 3) return // 拖过就是平移,不算点选
373
+ const rect = canvas.getBoundingClientRect()
374
+ setFocus(pick(event.clientX - rect.left, event.clientY - rect.top))
375
+ })
376
+ canvas.addEventListener('dblclick', () => setFocus(-1))
377
+ canvas.addEventListener('pointerleave', () => {
378
+ if (hovered >= 0) {
379
+ hovered = -1
380
+ scheduleDraw()
381
+ }
382
+ })
383
+ canvas.addEventListener('wheel', (event) => {
384
+ event.preventDefault()
385
+ const rect = canvas.getBoundingClientRect()
386
+ const px = event.clientX - rect.left
387
+ const py = event.clientY - rect.top
388
+ const next = Math.max(0.02, Math.min(60, view.scale * Math.exp(-event.deltaY * 0.0015)))
389
+ const k = next / view.scale
390
+ view.x = px - (px - view.x) * k
391
+ view.y = py - (py - view.y) * k
392
+ view.scale = next
393
+ scheduleDraw()
394
+ }, { passive: false })
395
+
396
+ if (q) {
397
+ q.addEventListener('keydown', (event) => {
398
+ if (event.key !== 'Enter') return
399
+ const needle = q.value.trim().toLowerCase()
400
+ if (!needle) return
401
+ // 搜 `name`(主键也认,方便精确指定某一条线)。同名两页都在时**两个都算命中**,
402
+ // 跳连接更多的那张并把另一条线也说出来 —— 合并视图里这正是要看见的东西。
403
+ const exact = []
404
+ for (let i = 0; i < N; i += 1) {
405
+ if (label(nodes[i]).toLowerCase() === needle || nodes[i].id.toLowerCase() === needle) exact.push(i)
406
+ }
407
+ let pool = exact
408
+ if (!pool.length) {
409
+ pool = []
410
+ for (let i = 0; i < N; i += 1) {
411
+ if (label(nodes[i]).toLowerCase().includes(needle)) pool.push(i)
412
+ }
413
+ }
414
+ if (!pool.length) {
415
+ if (hudInfo) hudInfo.textContent = `没找到「${q.value.trim()}」`
416
+ return
417
+ }
418
+ pool.sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))
419
+ const hit = pool[0]
420
+ view.scale = Math.max(view.scale, 2.5)
421
+ view.x = cssW() / 2 - X[hit] * view.scale
422
+ view.y = cssH() / 2 - Y[hit] * view.scale
423
+ setFocus(hit)
424
+ if (pool.length > 1 && hudInfo) {
425
+ hudInfo.innerHTML += `<div>两条线都有「${escapeHtml(label(nodes[hit]))}」:${escapeHtml(pool.map((i) => lineLabel(nodes[i])).join('、'))}</div>`
426
+ }
427
+ })
428
+ }
429
+
430
+ if (hudStats) {
431
+ let wiki = 0
432
+ let chat = 0
433
+ for (let i = 0; i < N; i += 1) {
434
+ if (nodes[i].line === 'wiki') wiki += 1
435
+ else if (nodes[i].line === 'chat') chat += 1
436
+ }
437
+ hudStats.textContent = `${N.toLocaleString('en-US')} 个概念 · ${(E.length / 2).toLocaleString('en-US')} 条链接`
438
+ + ` · ${LINE_LABELS.wiki || 'wiki'} ${wiki.toLocaleString('en-US')} / ${LINE_LABELS.chat || 'chat'} ${chat.toLocaleString('en-US')}`
439
+ }
440
+ if (hudInfo) hudInfo.textContent = HINT
441
+ if (boot) boot.style.display = 'none'
442
+
443
+ window.addEventListener('resize', resize)
444
+ resize()
445
+ fit()
446
+ // 给冒烟测试一个把手:能问它"画了多少个点、多少条线"
447
+ window.__GRAPH2D__ = { nodes: N, edges: E.length / 2, draw, fit, view }
448
+ })()
@@ -29,6 +29,52 @@ function defaultBallPosition(workArea, ballSize = BALL_SIZE, margin = EDGE_MARGI
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * 半隐("躲起来"):贴住屏幕左右边时,**整窗留在工作区内、贴着边**。
34
+ *
35
+ * 不再把窗口推到屏幕外:姿势换成了"身子藏在边后面探头"(`mascot-peek.png`),
36
+ * 那张图的左边是一条笔直的切边,窗口贴边时它正好落在屏幕边上——**切的错觉由图和位置一起给**,
37
+ * 窗口自己不用出去。顺带三个好处:不在屏幕外的窗口不会跟贴边/任务栏打架、
38
+ * 露出的部分整个可点、而且**窗口完整可见**(上次那版是半截在屏幕外)。
39
+ */
40
+ const HIDE_SNAP = 16
41
+
42
+ /**
43
+ * 松手的位置算不算"贴住了某块屏的左右边"。
44
+ *
45
+ * 只做左右:上下贴边在 Windows 上会跟任务栏、窗口贴边打架,而且一个圆球从上下边半隐
46
+ * 看着也不像"躲",像被切了。
47
+ *
48
+ * @returns {{edge:'left'|'right', area:object}|null} 连那块工作区一起返回——
49
+ * 调用方就不用再拿球心去找工作区了(半隐时球心本来就在屏幕外,找不到)。
50
+ */
51
+ function edgeToHide(pos, workAreas, ballSize = BALL_SIZE, snap = HIDE_SNAP) {
52
+ for (const area of workAreas) {
53
+ const cy = pos.y + ballSize / 2
54
+ // 球得落在这块屏的**纵向**范围内,否则它贴的是另一块屏的边
55
+ if (cy < area.y || cy > area.y + area.height) continue
56
+ const leftGap = pos.x - area.x
57
+ const rightGap = (area.x + area.width) - (pos.x + ballSize)
58
+ if (leftGap <= snap && leftGap >= -ballSize) return { edge: 'left', area }
59
+ if (rightGap <= snap && rightGap >= -ballSize) return { edge: 'right', area }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /** 半隐时窗口该放哪:**贴着工作区的左/右边,整窗留着**(切边由图给,见上)。 */
65
+ function hiddenPosition(pos, edge, workArea, ballSize = BALL_SIZE) {
66
+ if (edge === 'left') return { x: workArea.x, y: pos.y }
67
+ if (edge === 'right') return { x: workArea.x + workArea.width - ballSize, y: pos.y }
68
+ return { x: pos.x, y: pos.y }
69
+ }
70
+
71
+ /** 从半隐恢复:完全露出来,并且按平时的规矩留出 `EDGE_MARGIN`,别贴在边上。 */
72
+ function revealedPosition(pos, edge, workArea, ballSize = BALL_SIZE, margin = EDGE_MARGIN) {
73
+ if (edge === 'left') return { x: workArea.x + margin, y: pos.y }
74
+ if (edge === 'right') return { x: workArea.x + workArea.width - ballSize - margin, y: pos.y }
75
+ return { x: pos.x, y: pos.y }
76
+ }
77
+
32
78
  /** 把一个 `size x size` 的方块挪进给定工作区(只挪,不改尺寸) */
33
79
  function clampInto(x, y, size, workArea) {
34
80
  const maxX = workArea.x + workArea.width - size
@@ -147,7 +193,7 @@ function ballRectInWindow(windowRect, anchor, ballSize = BALL_SIZE) {
147
193
  }
148
194
 
149
195
  module.exports = {
150
- BALL_SIZE, EDGE_MARGIN, BUBBLE_SIZE, BUBBLE_GAP,
196
+ BALL_SIZE, EDGE_MARGIN, BUBBLE_SIZE, BUBBLE_GAP, HIDE_SNAP,
151
197
  defaultBallPosition, clampInto, isReachable, resolveStartPosition, bubbleLayout,
152
- ballRectInWindow,
198
+ ballRectInWindow, edgeToHide, hiddenPosition, revealedPosition,
153
199
  }
@@ -12,11 +12,13 @@
12
12
  奇怪的方窗口,所以那边永远看不见球(`hidden` 属性与 CSS 里那条 `display:none`
13
13
  都是为它留的)。 -->
14
14
  <button id="ball" type="button" title="第二大脑(点击展开 · 可拖动)" hidden>
15
- <!-- 两层:底下一层是会动的光晕(背景),上面一层是吉祥物。
16
- 分开是为了让光晕能变而猫不动——直接给 #ball 设 background 的话,
17
- 光晕与猫共用同一层,动不了其中一个。 -->
18
- <span class="glow" aria-hidden="true"></span>
15
+ <!-- 两层:底下是球面(`mascot-base.png`,眼球位置是空的),上面是可以单独平移的虹膜。
16
+ 分开是为了让**眼珠能跟着鼠标动而猫不动**——直接给 #ball 设 background 的话,
17
+ 两者共用同一层,就动不了其中一个。
18
+ 注意底面用的是 `mascot-base.png` 而**不是** `mascot.png`:后者眼球是画死的,
19
+ 直接用会看到两个瞳仁(一个动的、一个不动的)。两张图零偏移叠起来与原图逐像素相同。 -->
19
20
  <span class="face" aria-hidden="true"></span>
21
+ <span class="iris" aria-hidden="true"></span>
20
22
  </button>
21
23
 
22
24
  <!-- 对话气泡。**单独一层**:它要有自己的圆角与底色,而展开时窗口比它大一圈
@@ -27,6 +29,9 @@
27
29
  <span class="brand">第二大脑</span>
28
30
  <span id="status" class="status">连接中…</span>
29
31
  <button id="collapse" type="button" class="ghost" hidden>收起</button>
32
+ <!-- "它记住了什么"(只读)。**不经过模型**:那条路由只返回数据,页面只渲染;
33
+ 模型看到的是另一份(带预算与脱敏)。点开时才拉一次,不轮询。 -->
34
+ <button id="memory" type="button" class="ghost" title="它记住了什么(只读)">记忆</button>
30
35
  </header>
31
36
 
32
37
  <main id="log" aria-live="polite"></main>
@@ -38,6 +43,15 @@
38
43
 
39
44
  <footer id="foot"></footer>
40
45
 
46
+ <section id="memory-panel" hidden>
47
+ <div class="memhead">
48
+ <span class="brand">它记住了什么</span>
49
+ <span id="memory-note" class="status"></span>
50
+ <button id="memory-close" type="button" class="ghost">关闭</button>
51
+ </div>
52
+ <div id="memory-list" class="memlist"></div>
53
+ </section>
54
+
41
55
  <div id="tail" aria-hidden="true"></div>
42
56
  </div>
43
57