magic-agent 2.4.2 → 2.6.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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +7 -10
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -24,18 +24,25 @@
24
24
  var H = null; // 宿主(index.html 注入)
25
25
  var els = {}; // 需求页内部元素
26
26
  var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
27
- var REQ_BUSY = false; // 「整理」进行中(防重复点击)
27
+ /* 整理进行中的状态:**按空间**各记一把锁(2026-10-24 用户:「点一个模块整理,
28
+ 其他整理也不能点了 不对」)。同一空间内的空间级 / 模块级整理互斥(共享 state 文件),
29
+ 不同空间互不影响;「全部整理」才是全局锁。 */
30
+ var REQ_BUSY = {}; // 空间 key → 该入口的进行中文案(空对象 = 没有单空间在整理)
31
+ var REQ_BATCH = false; // 「全部整理」进行中(它本来就要串行跑所有空间)
28
32
  var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
29
- var REQ_BUSY_KEY = ''; // 本次整理的组头(空间 key;空 = 全部整理,所有组头都置忙)
30
33
  var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
31
34
  var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
32
35
  var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
36
+ var REQ_GRAPH_WS = null; // 图谱视图:当前画的是哪个工作区(复用 #reqDetail 容器)
33
37
 
34
38
  /* 一级 / 二级两个容器的内容都在这里渲染 —— index.html 里只留空壳。 */
35
39
  var ROOT_HTML =
36
40
  '<div class="history__top">' +
37
41
  '<span class="history__title">需求</span>' +
38
- '<button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>' +
42
+ '<span class="history__topacts">' +
43
+ '<button class="history__disconnect reqs__graphbtn" id="btnReqGraph" type="button">图谱</button>' +
44
+ '<button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>' +
45
+ '</span>' +
39
46
  '</div>' +
40
47
  '<div class="history__list" id="reqList"></div>';
41
48
  var DETAIL_HTML =
@@ -45,16 +52,163 @@
45
52
  '<button class="history__disconnect" id="btnReqDetailSync" type="button" title="只整理这个模块">整理</button>' +
46
53
  '</div>' +
47
54
  '<div class="history__list" id="reqDetailList"></div>';
55
+ var GRAPH_HTML =
56
+ '<div class="history__top">' +
57
+ '<button class="topbar__icon" id="btnReqGraphBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
58
+ '<span class="history__title" id="reqGraphTitle">关系图谱</span>' +
59
+ '</div>' +
60
+ '<div class="reqgraph__pick" id="reqGraphPick"></div>' +
61
+ '<div class="reqgraph__stage" id="reqGraphStage"><div class="history__hint">读取中…</div></div>' +
62
+ '<div class="reqgraph__legend" id="reqGraphLegend"></div>';
48
63
 
49
- /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。 */
50
- function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
64
+ /* 路径归一:**统一分隔符 + 去尾部斜杠**。
65
+ ⚠️ 必须统一 '\\' → '/':桌面可能是 Windows,服务端给的是 D:\workspace\magic-agent,
66
+ 而自定义空间里存的是归一后的 D:/workspace/magic-agent —— 不统一就认不出是同一个
67
+ 空间(需求页会多出一个空间组、别名也贴不上)。
68
+ ⚠️ 任务页(index.html)里有一份同名实现 —— 改动两处要同步,否则两页口径就分叉了。 */
69
+ function reqNormDir(p) { return String(p || '').replace(/\\/g, '/').replace(/\/+$/, ''); }
70
+ /* 空间键(比较用):归一之上再修**盘符大小写**(Windows 上 d: 与 D: 是同一个盘,
71
+ 但路径其余部分大小写敏感 —— Linux 上 Foo 与 foo 是两个目录)。 */
72
+ function spaceKey(p) {
73
+ var s = reqNormDir(p);
74
+ return s.replace(/^([a-z]):/, function (m, d) { return d.toUpperCase() + ':'; });
75
+ }
76
+ /* 最后一级目录名(**不显示路径**):同时按 '/' 与 '\\' 切,Windows 路径也要切得动。 */
77
+ function baseName(p) {
78
+ var s = reqNormDir(p).split('/').filter(Boolean);
79
+ return s.pop() || String(p || '');
80
+ }
51
81
  /* 宿主能力的短名(函数体里沿用原 index 的写法,搬迁时不用到处加 H.) */
52
82
  function hEl(tag, cls, text) { return H.hEl(tag, cls, text); }
53
83
  function elSpan(cls, text) { return H.elSpan(cls, text); }
54
84
  function svgUse(id, size) { return H.svgUse(id, size); }
55
85
  function toast(msg) { return H.toast(msg); }
56
86
 
57
- function reqFeatureRow(f) {
87
+ /* 空间显示名:**自定义空间的别名优先**,没有别名就用服务端给的目录名。
88
+ 口径与任务页一致(index.html 的 spaceLabel)—— 同一个空间在两页不能一个叫别名
89
+ 一个叫目录名,用户会以为认错了空间(2026-10-24:自定义空间支持别名)。
90
+ ⚠️ 兜底**不能落到整条路径**(w.dir):任务页永远只显示最后一级文件夹名,
91
+ 需求页落成整条路径 = 用户看到的是 D:\workspace\magic-agent 这种长串,
92
+ 一行放不下也认不出是哪个项目(用户 2026-10-24:「否则显示最后1级的文件夹不要路径」)。 */
93
+ function reqSpaceLabel(w) {
94
+ var alias = '';
95
+ try { alias = H.getSpaceAlias ? String(H.getSpaceAlias(w.dir || w.id || '') || '') : ''; } catch (e) {}
96
+ return alias || w.name || baseName(w.dir || '') || w.id;
97
+ }
98
+
99
+ /* 相机徽标的判定(**纯函数**:DOM 之外的逻辑单独拎出来,好测)。
100
+ *
101
+ * 触发条件 = 归纳时从对话里提取到了页面地址(page_url):
102
+ * · 有地址、还没截过 → 「待截图」(点它去截);
103
+ * · 有地址、截过了 → 「N 图」(点它可以再截一张)。
104
+ * 没有 page_url 的功能点**不出这个徽标** —— 不给「点了必然失败」的死入口。
105
+ *
106
+ * 返回 null = 不画徽标;否则 {text, cls}。 */
107
+ function reqShotBadge(f) {
108
+ if (!f || !f.page_url) return null;
109
+ var n = Number(f.shots) || 0;
110
+ return {
111
+ text: n > 0 ? (n + ' 图') : '待截图',
112
+ cls: n > 0 ? 'reqshot__badge--has' : 'reqshot__badge--todo'
113
+ };
114
+ }
115
+
116
+ /* ══════════ 整理失败的原因:先给一句人话,原文折叠进「详情」══════════
117
+ * 服务端 last_error 存的是引擎原始报错:`HTTP 402: {"type":"error",...}`,
118
+ * 还常被截断(core 侧 240 字)。原样铺在页面上就是一屏乱码,用户看不出「该干嘛」;
119
+ * 这里只做两件事(2026-10-25 用户:「整理报错设计一下显示」):
120
+ * ① 认得出类别(额度 / 登录 / 限流 / 超时)→ 类别 + 一句原因 + 下一步;
121
+ * ② 认不出 → 剥掉 JSON 噪声,只留人能读的部分(**不编造原因**)。
122
+ * 原文始终留着给「详情」:排查要看的 request_id / http_code 都在里面。
123
+ * 纯函数,不碰 DOM —— 好单独验。 */
124
+ function reqErrJsonMessage(s) {
125
+ var m = /"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s);
126
+ if (!m) return '';
127
+ try { return String(JSON.parse('"' + m[1] + '"')); } catch (e) { return m[1]; }
128
+ }
129
+ /* 兜底文案:去掉 `HTTP 402:` 前缀、尾部 `(attempts=1, non-retryable)` 与 JSON 主体。
130
+ 没有 message 的 JSON 本身就是一坨代码,留着只会挤爆卡片(原文在「详情」里)。 */
131
+ function reqErrPlain(s) {
132
+ var t = String(s || '')
133
+ .replace(/^HTTP\s+\d{3}\s*[::]\s*/i, '')
134
+ .replace(/\s*\(attempts=\d+[^)]*\)\s*$/i, '')
135
+ .trim();
136
+ var i = t.indexOf('{');
137
+ if (i >= 0) t = t.slice(0, i).trim();
138
+ return t;
139
+ }
140
+ /* 按字符(不是 UTF-16 单元)截断,避免把 emoji / 生僻字截成半个。 */
141
+ function reqCut(s, n) {
142
+ var arr = Array.from(String(s == null ? '' : s));
143
+ return arr.length > n ? arr.slice(0, n).join('') + '…' : arr.join('');
144
+ }
145
+ /* 返回 {badge, msg, hint, raw}:badge 为空 = 认不出类别(不硬套标签)。 */
146
+ function reqErrInfo(raw) {
147
+ var text = String(raw || '').replace(/\s+/g, ' ').trim();
148
+ var info = { badge: '', msg: '', hint: '', raw: text };
149
+ if (!text) return info;
150
+ var status = Number((/HTTP\s+(\d{3})/i.exec(text) || [])[1]) || 0;
151
+ var type = ((/"type"\s*:\s*"([a-z_]+)"/i.exec(text) || [])[1] || '').toLowerCase();
152
+ var msg = reqErrJsonMessage(text);
153
+ if (status === 402 || type.indexOf('insufficient') >= 0 || type.indexOf('balance') >= 0) {
154
+ info.badge = '额度不足';
155
+ info.msg = msg || '引擎账号的额度已用尽,这一轮没有开始跑。';
156
+ info.hint = '报错来自引擎账号;补充额度后再点「整理」。';
157
+ } else if (status === 401 || status === 403 || type.indexOf('auth') >= 0 || type.indexOf('permission') >= 0) {
158
+ info.badge = '未授权';
159
+ info.msg = msg || '引擎拒绝了这个请求(登录状态或权限不对)。';
160
+ info.hint = '到「我的 → 引擎」重新登录 / 检查权限后再试。';
161
+ } else if (status === 429 || type.indexOf('rate_limit') >= 0 || /too many requests/i.test(text)) {
162
+ info.badge = '请求过频';
163
+ info.msg = msg || '引擎限流了,这一轮没有跑。';
164
+ info.hint = '隔一会儿再点「整理」。';
165
+ } else if (/timeout|timed out|deadline exceeded|超时/i.test(text)) {
166
+ info.badge = '超时';
167
+ info.msg = msg || '整理在限定时间内没跑完。';
168
+ info.hint = '可以进模块页单独整理,一次只跑一个模块。';
169
+ } else {
170
+ info.msg = msg || reqErrPlain(text);
171
+ }
172
+ if (!info.msg) info.msg = '服务端没有给出原因(原文见下)。';
173
+ return info;
174
+ }
175
+
176
+ /* 失败提示卡:类别 + 一句人话 + 下一步;原文默认收起(点「详情」看全文)。
177
+ 不再用 history__hint —— 那是居中灰字,长错误铺开就是一大坨,也说不清轻重。 */
178
+ function reqErrorCard(raw) {
179
+ var info = reqErrInfo(raw);
180
+ var box = hEl('div', 'reqerr');
181
+ var head = hEl('div', 'reqerr__head');
182
+ var ico = elSpan('reqerr__ico');
183
+ ico.appendChild(svgUse('#i-bang', 14));
184
+ head.appendChild(ico);
185
+ head.appendChild(elSpan('reqerr__t', '上次整理失败'));
186
+ if (info.badge) head.appendChild(elSpan('reqerr__badge', info.badge));
187
+ box.appendChild(head);
188
+ box.appendChild(hEl('div', 'reqerr__msg', reqCut(info.msg, 160)));
189
+ if (info.hint) box.appendChild(hEl('div', 'reqerr__hint', info.hint));
190
+ if (info.raw) {
191
+ var more = document.createElement('button');
192
+ more.type = 'button'; more.className = 'reqerr__more';
193
+ more.appendChild(elSpan('', '详情'));
194
+ var chev = elSpan('reqerr__chev');
195
+ chev.appendChild(svgUse('#i-chev-d', 12));
196
+ more.appendChild(chev);
197
+ var pre = hEl('pre', 'reqerr__raw', info.raw);
198
+ pre.hidden = true;
199
+ more.onclick = function () {
200
+ var open = pre.hidden;
201
+ pre.hidden = !open;
202
+ more.classList.toggle('is-open', open);
203
+ more.firstChild.textContent = open ? '收起' : '详情';
204
+ };
205
+ box.appendChild(more);
206
+ box.appendChild(pre);
207
+ }
208
+ return box;
209
+ }
210
+
211
+ function reqFeatureRow(f, ctx) {
58
212
  /* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
59
213
  标题(功能点名称)→ 归纳结论 / 需求描述 → 「N 需求点 · M 结论 · 来源 K」。
60
214
  为什么优先结论:这一页要回答的是「这个功能定了什么」,不是「记录了多少条」。 */
@@ -76,6 +230,33 @@
76
230
  if (!f.has_final) meta.push('未定稿');
77
231
  if (meta.length) col.appendChild(hEl('span', 'srow__s', meta.join(' · ')));
78
232
  b.appendChild(col);
233
+ /* 相机徽标:功能点**涉及页面改造**时出现(2026-10-25 用户:「需求文档整理时
234
+ 如果设计页面改造 调用内置浏览器mcp 对完成情况截图标注并放在文档中」)。
235
+ 判定见 reqShotBadge(纯函数,好测);core 不支持截图能力位时不画。 */
236
+ var shot = reqShotBadge(f);
237
+ if (shot && (!H.canShot || H.canShot())) {
238
+ var badge = hEl('span', 'reqshot__badge ' + shot.cls);
239
+ badge.appendChild(svgUse('#i-shot', 13));
240
+ badge.appendChild(elSpan('', shot.text));
241
+ badge.title = f.page_url;
242
+ /* 徽标在行**里面**:HTML 不允许 button 套 button,所以渲染成 span[role=button],
243
+ 点它时 stopPropagation —— 否则会顺手把整行也点了(整行 = 打开文档)。 */
244
+ badge.setAttribute('role', 'button');
245
+ badge.setAttribute('aria-label', '页面完成情况截图');
246
+ badge.onclick = function (ev) {
247
+ if (ev && ev.stopPropagation) ev.stopPropagation();
248
+ if (ev && ev.preventDefault) ev.preventDefault();
249
+ if (!H.openShot) { toast('当前版本不支持截图'); return; }
250
+ H.openShot({
251
+ ws: ctx && ctx.ws ? ctx.ws : '',
252
+ module: ctx && ctx.module ? ctx.module : (f.module || ''),
253
+ name: f.name || '',
254
+ caption: '',
255
+ url: f.page_url || ''
256
+ });
257
+ };
258
+ b.appendChild(badge);
259
+ }
79
260
  if (f.updated_at) {
80
261
  var d = new Date(Number(f.updated_at) || 0);
81
262
  if (!isNaN(d.getTime())) {
@@ -91,10 +272,15 @@
91
272
  一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
92
273
  结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
93
274
  idx = 排序后的位次(0 = 需求条数最多的那个)。 */
275
+ /* 空间 key:一级行、二级详情、整理锁**必须**用同一把(大小写 / 路径分隔符归一),
276
+ 否则「锁的是 A、按钮标记的是 B」,置忙永远对不上。 */
277
+ function reqWsKey(w) {
278
+ return spaceKey((w && (w.id || w.dir || w.name)) || '');
279
+ }
94
280
  function reqWorkspaceBlock(w, idx) {
95
281
  var wrap = hEl('div', '');
96
282
  var head = hEl('div', 'wsrow__head');
97
- var key = w.id || w.dir || w.name || '';
283
+ var key = reqWsKey(w);
98
284
  var count = Number(w.feature_count) || 0;
99
285
  /* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
100
286
  空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
@@ -102,7 +288,7 @@
102
288
  var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
103
289
  var tog = document.createElement('button');
104
290
  tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
105
- tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
291
+ tog.appendChild(elSpan('wsrow__l', reqSpaceLabel(w)));
106
292
  /* 模块数 + 功能点数:一行说清这个空间有多大,比只写空间名有用。
107
293
  用**中性色**(不是 wsrow__badge 那枚 queued 紫)—— 那是「等待中」的语义色,
108
294
  空间规模不是状态,套上会让人误以为这个空间在排队。 */
@@ -130,11 +316,7 @@
130
316
  wrap.appendChild(head);
131
317
  if (!open) return wrap;
132
318
 
133
- if (w.last_error) {
134
- var err = hEl('div', 'history__hint', '上次整理失败:' + w.last_error);
135
- err.style.color = 'var(--warning)';
136
- wrap.appendChild(err);
137
- }
319
+ if (w.last_error) wrap.appendChild(reqErrorCard(w.last_error));
138
320
  var shown = 0;
139
321
  (w.modules || []).forEach(function (m) {
140
322
  var fs = (m.features || []);
@@ -181,7 +363,10 @@
181
363
  mh.style.paddingLeft = '16px';
182
364
  mh.appendChild(elSpan('sechead__t', m.name + ' (' + fs.length + ')'));
183
365
  sec.appendChild(mh);
184
- fs.forEach(function (f) { sec.appendChild(reqFeatureRow(f)); });
366
+ fs.forEach(function (f) {
367
+ /* ctx 带上空间与模块:截图要按「哪个空间的哪个模块的哪个功能点」落到文档里。 */
368
+ sec.appendChild(reqFeatureRow(f, { ws: w.dir || w.id || '', module: m.name || '' }));
369
+ });
185
370
  frag.appendChild(sec);
186
371
  });
187
372
  if (!frag.childNodes.length) {
@@ -192,14 +377,21 @@
192
377
 
193
378
  /* 进二级:记下是哪个空间的哪个模块,只画这个模块的需求。 */
194
379
  function openReqDetail(w, mod) {
195
- var key = w.id || w.dir || w.name || '';
380
+ var key = reqWsKey(w);
196
381
  REQ_DETAIL = { key: key, mod: mod, ws: w };
382
+ REQ_GRAPH_WS = null;
383
+ /* 图谱视图与二级视图共用 #reqDetail 容器:从图谱点功能点进来时,
384
+ 容器里还是 GRAPH_HTML —— 先还原成二级页骨架再渲染。 */
385
+ if (!els.detail.querySelector('#reqDetailList')) {
386
+ els.detail.innerHTML = DETAIL_HTML;
387
+ mountDetail();
388
+ }
197
389
  renderRequirements();
198
390
  /* 空间在两次渲染之间被删掉了(会话全删 → 空间联动消失):
199
391
  renderRequirements 会把 REQ_DETAIL 清空,这时别停在空白的二级页,退回目录。 */
200
392
  if (!REQ_DETAIL) { H.showRoot(); return; }
201
393
  H.showDetail();
202
- if (els.detailTitle) els.detailTitle.textContent = (w.name || w.dir || w.id) + ' · ' + mod;
394
+ if (els.detailTitle) els.detailTitle.textContent = reqSpaceLabel(w) + ' · ' + mod;
203
395
  /* 页里只有这一个模块,进来从顶部看起(旧的「滚到当前模块」已无意义)。 */
204
396
  if (els.detailList) els.detailList.scrollTop = 0;
205
397
  }
@@ -222,7 +414,7 @@
222
414
  拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
223
415
  var all = (data && data.workspaces) || [];
224
416
  var list = all.filter(function (w) {
225
- var dir = reqNormDir(w && w.dir);
417
+ var dir = spaceKey(w && w.dir);
226
418
  /* 根目录 / 归一后是空串 —— 「!dir 就放行」的老写法会把它漏进来,
227
419
  需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
228
420
  这里先卡一道:不是真空间目录的直接不画。 */
@@ -249,9 +441,9 @@
249
441
  if (!hit) { REQ_DETAIL = null; }
250
442
  else {
251
443
  REQ_DETAIL.ws = hit;
252
- if (els.detailTitle) els.detailTitle.textContent = (hit.name || hit.dir || hit.id) + ' · ' + REQ_DETAIL.mod;
444
+ if (els.detailTitle) els.detailTitle.textContent = reqSpaceLabel(hit) + ' · ' + REQ_DETAIL.mod;
253
445
  if (els.detailList) els.detailList.replaceChildren(reqDetailBlock(REQ_DETAIL));
254
- if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
446
+ reqPaintBusy();
255
447
  return;
256
448
  }
257
449
  }
@@ -270,7 +462,7 @@
270
462
  list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
271
463
  els.list.replaceChildren(frag);
272
464
  /* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
273
- if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
465
+ reqPaintBusy();
274
466
  }
275
467
 
276
468
  function loadRequirements(quiet) {
@@ -305,13 +497,13 @@
305
497
  需求页照收就会多出一堆 a1/t1/tmp 假空间。
306
498
  ⚠️ 根目录 / 与 /tmp 这类系统目录不是空间,哪怕历史上有会话在那儿跑过。 */
307
499
  var alive = {};
308
- (H.getRootDirs() || []).forEach(function (d) { if (d && d.path) alive[reqNormDir(d.path)] = 1; });
500
+ (H.getRootDirs() || []).forEach(function (d) { if (d && d.path) alive[spaceKey(d.path)] = 1; });
309
501
  (dirs || []).forEach(function (d) {
310
- if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[reqNormDir(d.path)] = 1;
502
+ if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[spaceKey(d.path)] = 1;
311
503
  });
312
- (H.getCustomSpaces() || []).forEach(function (p) { if (p) alive[reqNormDir(p)] = 1; });
504
+ (H.getCustomSpaces() || []).forEach(function (p) { if (p) alive[spaceKey(p)] = 1; });
313
505
  H.topLevelSessions(sess).forEach(function (s) {
314
- if (s && s.workspace && H.isRealSpaceDir(s.workspace)) alive[reqNormDir(s.workspace)] = 1;
506
+ if (s && s.workspace && H.isRealSpaceDir(s.workspace)) alive[spaceKey(s.workspace)] = 1;
315
507
  });
316
508
  REQ_ALIVE = alive;
317
509
  } else {
@@ -357,29 +549,43 @@
357
549
  });
358
550
  }
359
551
 
360
- /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用(防重复点)。
361
- 只有**真正在整理的那个入口**显示「整理中…」:
362
- · 点某个空间 / 某个模块的「整理」→ 只有这一组的文案变;
363
- · 点「全部整理」→ 所有组头一起变(那才是全部在跑)。
364
- 其余按钮只置灰、保持「整理」—— 否则点一个模块会让人以为所有空间都在整理。 */
365
- function reqSetBusy(on, label, busyKey) {
366
- if (busyKey === undefined) busyKey = REQ_BUSY_KEY;
367
- if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
368
- else REQ_BUSY_LABEL = '全部整理';
552
+ /* 置忙 / 解除 / 按状态重画。
553
+ 纪律(2026-10-24 用户:一个在整理,别的整理不该也不能被锁):
554
+ · 单个空间整理 → 只禁用**这个空间**的组头按钮与它二级页的「整理」;
555
+ 其他空间的「整理」照常可点(各空间 state 互相独立);
556
+ · 「全部整理」→ 所有按钮一起禁用(那确实是全部在跑)。
557
+ 顶栏「全部整理」与任何单个整理互斥:避免「边跑单个边点全部」把同一空间跑两遍。 */
558
+ function reqBusyFor(key) { return REQ_BATCH || !!REQ_BUSY[key]; }
559
+ function reqBusyAny() { return REQ_BATCH || Object.keys(REQ_BUSY).length > 0; }
560
+ function reqBeginBusy(key, label) {
561
+ if (key) REQ_BUSY[key] = label || '整理中…';
562
+ else REQ_BATCH = true;
563
+ if (label !== undefined) REQ_BUSY_LABEL = label;
564
+ reqPaintBusy();
565
+ }
566
+ function reqEndBusy(key) {
567
+ if (key) delete REQ_BUSY[key];
568
+ else { REQ_BATCH = false; REQ_BUSY_LABEL = '全部整理'; }
569
+ reqPaintBusy();
570
+ }
571
+ function reqPaintBusy() {
369
572
  var btn = els.sync;
370
573
  if (btn) {
371
- btn.disabled = !!on;
372
- if (label !== undefined) btn.textContent = label;
574
+ btn.disabled = reqBusyAny();
575
+ btn.textContent = REQ_BATCH ? (REQ_BUSY_LABEL || '整理中…') : '全部整理';
373
576
  }
374
577
  var dbtn = els.detailSync;
375
578
  if (dbtn) {
376
- dbtn.disabled = !!on;
377
- dbtn.textContent = on ? '整理中…' : '整理';
579
+ var dkey = REQ_DETAIL ? (REQ_DETAIL.key || reqWsKey(REQ_DETAIL.ws)) : '';
580
+ var busy = reqBusyFor(dkey);
581
+ dbtn.disabled = busy;
582
+ dbtn.textContent = busy ? '整理中…' : '整理';
378
583
  }
379
584
  Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
380
- b.disabled = !!on;
381
- if (!on) { b.textContent = '整理'; return; }
382
- b.textContent = (!busyKey || b.getAttribute('data-ws') === busyKey) ? '整理中…' : '整理';
585
+ var k = spaceKey(b.getAttribute('data-ws') || '');
586
+ var label = REQ_BUSY[k];
587
+ b.disabled = REQ_BATCH || !!label;
588
+ b.textContent = label || (REQ_BATCH ? '整理中…' : '整理');
383
589
  });
384
590
  }
385
591
 
@@ -390,15 +596,16 @@
390
596
  服务端按 state 判断走哪条路:没初始化过 → 先按空间目录反推基线;
391
597
  模块级不会触发首次初始化(目录基线天然是空间级的)。 */
392
598
  function consolidateRequirements(target, module) {
393
- if (REQ_BUSY || !target) return;
599
+ if (!target) return;
394
600
  var ws = target.dir || target.id || '';
395
601
  if (!ws) return;
396
602
  module = String(module || '').trim();
603
+ /* 锁粒度 = 空间:同一空间里空间级 / 模块级整理共享 state,不能并发;
604
+ 不同空间互不影响(点了 A 还能点 B)。 */
605
+ var targetKey = reqWsKey(target);
606
+ if (!targetKey || reqBusyFor(targetKey)) return;
397
607
  var firstTime = target.initialized === false && !module;
398
- var targetKey = target.id || target.dir || '';
399
- REQ_BUSY = true;
400
- REQ_BUSY_KEY = targetKey;
401
- reqSetBusy(true, firstTime ? '初始化中…' : '整理中…', targetKey);
608
+ reqBeginBusy(targetKey, firstTime ? '初始化中…' : '整理中…');
402
609
  reqPostConsolidate(ws, module)
403
610
  .then(function (d) {
404
611
  var prefix = module ? '模块「' + module + '」' : '';
@@ -406,16 +613,27 @@
406
613
  if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
407
614
  else toast('已按空间目录初始化:暂未识别到功能点');
408
615
  }
616
+ /* 模块去重(core 收尾时合并「表达同一件事」的已有模块):
617
+ 有合并就不是 noop —— 用户点「整理」想看到的就是这个反馈。 */
618
+ else if (d.merged) {
619
+ var extra = (d.updated || d.created)
620
+ ? ' · 更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0) : '';
621
+ toast((prefix || '整理完成') + ':合并 ' + d.merged + ' 个相似模块' + extra);
622
+ }
409
623
  else if (d.noop) toast(prefix ? prefix + '没有新的对话需要整理' : '没有新的对话需要整理');
410
624
  else if (d.updated || d.created) toast((prefix || '整理完成') + ':更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
411
625
  else toast(prefix ? prefix + '整理完成' : '整理完成');
412
626
  loadRequirements(true); // 后台刷新(不闪「读取中」)
413
627
  })
414
- .catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
628
+ .catch(function (e) {
629
+ /* 失败原因走与卡片同一套归类:toast 只放一句人话(原始 JSON 会撑爆 toast),
630
+ 再刷新一次清单 —— 服务端已把原因写进 state,卡片随即出现,可点「详情」看原文。 */
631
+ var info = reqErrInfo((e && e.message) || String(e || ''));
632
+ toast('整理失败:' + (info.badge || reqCut(info.msg, 40)));
633
+ loadRequirements(true);
634
+ })
415
635
  .then(function () {
416
- REQ_BUSY = false;
417
- REQ_BUSY_KEY = '';
418
- reqSetBusy(false, '全部整理');
636
+ reqEndBusy(targetKey);
419
637
  });
420
638
  }
421
639
 
@@ -425,10 +643,10 @@
425
643
  function reqAllWorkspaces() {
426
644
  var seen = {};
427
645
  var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
428
- var key = w.dir || w.id || '';
646
+ var key = reqWsKey(w);
429
647
  if (!key || seen[key]) return false;
430
648
  /* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
431
- var dir = reqNormDir(w.dir);
649
+ var dir = spaceKey(w.dir);
432
650
  if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
433
651
  seen[key] = true;
434
652
  return true;
@@ -442,20 +660,16 @@
442
660
  }
443
661
 
444
662
  function consolidateAllRequirements() {
445
- if (REQ_BUSY) return;
663
+ if (reqBusyAny()) return;
446
664
  var list = reqAllWorkspaces();
447
665
  if (!list.length) {
448
666
  toast('还没有可整理的空间');
449
667
  return;
450
668
  }
451
- REQ_BUSY = true;
452
- REQ_BUSY_KEY = ''; // 空 = 全部整理:所有组头一起显示「整理中…」
453
- reqSetBusy(true, '整理中 0/' + list.length);
669
+ reqBeginBusy('', '整理中 0/' + list.length);
454
670
  var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
455
671
  function done() {
456
- REQ_BUSY = false;
457
- REQ_BUSY_KEY = '';
458
- reqSetBusy(false, '全部整理');
672
+ reqEndBusy('');
459
673
  loadRequirements(true);
460
674
  if (fail) {
461
675
  toast('全部整理完成:成功 ' + ok + ' · 失败 ' + fail + (firstErr ? '(' + firstErr + ')' : ''));
@@ -467,8 +681,7 @@
467
681
  if (i >= list.length) { done(); return; }
468
682
  var w = list[i++];
469
683
  REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
470
- var btn = els.sync;
471
- if (btn) btn.textContent = REQ_BUSY_LABEL;
684
+ reqPaintBusy();
472
685
  reqPostConsolidate(w.dir || w.id || '', '')
473
686
  .then(function (d) {
474
687
  ok++;
@@ -477,13 +690,216 @@
477
690
  })
478
691
  .catch(function (e) {
479
692
  fail++;
480
- if (!firstErr) firstErr = (e && e.message) || String(e);
693
+ if (!firstErr) {
694
+ var info = reqErrInfo((e && e.message) || String(e || ''));
695
+ firstErr = info.badge || reqCut(info.msg, 24);
696
+ }
481
697
  })
482
698
  .then(step);
483
699
  }
484
700
  step();
485
701
  }
486
702
 
703
+ /* ══════════ 会话↔需求关系图谱(2026-10-25)══════════
704
+ * 数据:GET /desk/requirements/graph?ws=<id|目录>(core --requirements-graph,纯读不调模型)。
705
+ * 渲染:vendor 的 d3-force(力导向)+ 手绘 SVG —— 会话=圆、功能点=圆角矩形,
706
+ * 按模块着色;点功能点节点跳该模块的需求列表(复用二级页),点会话节点弹标题。
707
+ * 复用 #reqDetail 容器(第三态 REQ_GRAPH_WS):它本来就是「需求页的下钻视图」,
708
+ * 宿主 hideAll/showDetail 的显隐逻辑不用动。 */
709
+ function mountDetail() {
710
+ els.detailList = els.detail.querySelector('#reqDetailList');
711
+ els.detailTitle = els.detail.querySelector('#reqDetailTitle');
712
+ els.detailBack = els.detail.querySelector('#btnReqDetailBack');
713
+ els.detailSync = els.detail.querySelector('#btnReqDetailSync');
714
+ if (els.detailBack) els.detailBack.onclick = function () { closeReqDetail(); };
715
+ if (els.detailSync) els.detailSync.onclick = function () {
716
+ if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
717
+ consolidateRequirements(REQ_DETAIL.ws, REQ_DETAIL.mod);
718
+ };
719
+ }
720
+
721
+ function openReqGraph() {
722
+ var all = (REQ_DATA && REQ_DATA.workspaces) || [];
723
+ /* 图谱入口给「需求最多的那个空间」当默认(与列表排序口径一致),可顶部切换。 */
724
+ var w = all[0] || null;
725
+ REQ_GRAPH_WS = w;
726
+ REQ_DETAIL = null;
727
+ els.detail.innerHTML = GRAPH_HTML;
728
+ H.showDetail();
729
+ var back = els.detail.querySelector('#btnReqGraphBack');
730
+ if (back) back.onclick = function () {
731
+ REQ_GRAPH_WS = null;
732
+ els.detail.innerHTML = DETAIL_HTML;
733
+ mountDetail();
734
+ H.showRoot();
735
+ renderRequirements();
736
+ };
737
+ renderGraphPick();
738
+ if (w) loadGraph(w); else {
739
+ var st = els.detail.querySelector('#reqGraphStage');
740
+ if (st) st.innerHTML = '<div class="history__hint">还没有任何空间。在任务里聊几句,这里会出现关系图谱。</div>';
741
+ }
742
+ }
743
+
744
+ function graphWsList() {
745
+ var all = (REQ_DATA && REQ_DATA.workspaces) || [];
746
+ return all.filter(function (w) {
747
+ var dir = spaceKey(w && w.dir);
748
+ if (!H.isRealSpaceDir(dir)) return false;
749
+ return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
750
+ });
751
+ }
752
+
753
+ function renderGraphPick() {
754
+ var pick = els.detail.querySelector('#reqGraphPick');
755
+ if (!pick) return;
756
+ var list = graphWsList();
757
+ pick.replaceChildren.apply(pick, list.map(function (w) {
758
+ var b = hEl('button', 'reqgraph__chip' + (REQ_GRAPH_WS && REQ_GRAPH_WS.id === w.id ? ' is-on' : ''));
759
+ b.type = 'button';
760
+ b.textContent = reqSpaceLabel(w);
761
+ b.onclick = function () {
762
+ REQ_GRAPH_WS = w;
763
+ renderGraphPick();
764
+ loadGraph(w);
765
+ };
766
+ return b;
767
+ }));
768
+ }
769
+
770
+ function loadGraph(w) {
771
+ var st = els.detail.querySelector('#reqGraphStage');
772
+ if (!st) return;
773
+ st.innerHTML = '<div class="history__hint">读取中…</div>';
774
+ var hdr = H.getToken() ? { 'X-Magic-Token': H.getToken() } : {};
775
+ var where = encodeURIComponent(w.id || w.dir || '');
776
+ fetch(H.getBase() + '/desk/requirements/graph?ws=' + where, { headers: hdr })
777
+ .then(function (r) {
778
+ if (r.status === 501) return r.json().then(function (e) { throw new Error((e && e.error) || '当前 core 不支持需求聚合'); });
779
+ if (!r.ok) throw new Error('HTTP ' + r.status);
780
+ return r.json();
781
+ })
782
+ .then(function (d) {
783
+ if (REQ_GRAPH_WS !== w) return; // 用户已切走:旧响应作废
784
+ var g = d && d.workspaces && d.workspaces[0];
785
+ if (!g || !g.nodes || !g.nodes.length) {
786
+ st.innerHTML = '<div class="history__hint">这个空间还没有会话↔需求的关系。先点「整理」归纳一轮。</div>';
787
+ return;
788
+ }
789
+ renderGraphStage(g);
790
+ })
791
+ .catch(function (e) {
792
+ if (REQ_GRAPH_WS !== w) return;
793
+ st.innerHTML = '<div class="history__hint">读取失败:' + String((e && e.message) || e) + '</div>';
794
+ });
795
+ }
796
+
797
+ /* 模块 → 颜色(确定性 hash → HSL,明暗主题都看得清:饱和度/亮度取中间值)。 */
798
+ function graphModuleColor(mod) {
799
+ var h = 0, s = String(mod || '');
800
+ for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
801
+ return 'hsl(' + h + ',55%,58%)';
802
+ }
803
+
804
+ function renderGraphStage(g) {
805
+ var st = els.detail.querySelector('#reqGraphStage');
806
+ if (!st) return;
807
+ var cyLib = window.cytoscape;
808
+ if (!cyLib) {
809
+ st.innerHTML = '<div class="history__hint">图谱组件(cytoscape)没加载出来,刷新页面试试。</div>';
810
+ return;
811
+ }
812
+ /* fCoSE 布局扩展是可选的:没注册就回落内置 cose(同为力导向,效果稍逊)。 */
813
+ if (window.cytoscapeFcose && window.cytoscape.use) {
814
+ try { window.cytoscape.use(window.cytoscapeFcose); } catch (e) {}
815
+ }
816
+ st.replaceChildren();
817
+
818
+ var elsData = g.nodes.map(function (n) {
819
+ return {
820
+ group: 'nodes',
821
+ data: { id: n.id, label: n.title || n.id, kind: n.type, module: n.module || '' }
822
+ };
823
+ });
824
+ (g.edges || []).forEach(function (e) {
825
+ elsData.push({ group: 'edges', data: { id: e.source + '\u2192' + e.target, source: e.source, target: e.target } });
826
+ });
827
+
828
+ var cy = cyLib({
829
+ container: st,
830
+ elements: elsData,
831
+ style: [
832
+ { selector: 'node', style: {
833
+ label: 'data(label)', color: 'var(--ink)', 'font-size': 11,
834
+ 'text-valign': 'bottom', 'text-margin-y': 4,
835
+ 'background-color': '#888', 'border-width': 2, 'border-color': 'var(--raised)'
836
+ } },
837
+ { selector: 'node[kind = "session"]', style: {
838
+ shape: 'ellipse', width: 12, height: 12,
839
+ 'background-color': 'var(--muted)'
840
+ } },
841
+ { selector: 'node[kind = "feature"]', style: {
842
+ shape: 'round-rectangle', width: 'label', height: 28,
843
+ padding: '8px', 'background-color': 'data(color)',
844
+ 'font-size': 11
845
+ } },
846
+ { selector: 'edge', style: {
847
+ width: 1.5, 'line-color': 'var(--border)', 'curve-style': 'straight',
848
+ 'target-arrow-shape': 'none', opacity: 0.9
849
+ } },
850
+ { selector: 'node:active', style: { 'overlay-opacity': 0.1 } }
851
+ ],
852
+ layout: { name: 'preset', fit: false },
853
+ wheelSensitivity: 0.2, /* 触屏/滚轮缩放手感 */
854
+ boxSelectionEnabled: false,
855
+ autoungrabify: false
856
+ });
857
+
858
+ /* 节点配色:feature 按模块 hash 着色(data(color) 上文已引用)。 */
859
+ cy.nodes('[kind = "feature"]').forEach(function (n) {
860
+ n.data('color', graphModuleColor(n.data('module')));
861
+ });
862
+
863
+ /* 布局:fCoSE(专业力导向,注册失败回落内置 cose);定稿后 fit 到视口。 */
864
+ var layName = window.cytoscapeFcose ? 'fcose' : 'cose';
865
+ cy.layout({
866
+ name: layName, animate: false, randomize: true,
867
+ idealEdgeLength: 90, nodeRepulsion: 9000, nodeSeparation: 110,
868
+ padding: 30
869
+ }).run();
870
+ cy.fit(undefined, 30);
871
+ cy.userZoomingEnabled(true);
872
+ cy.userPanningEnabled(true);
873
+
874
+ /* 交互:点功能点 → 该模块需求列表(复用二级页);点会话 → 弹标题。 */
875
+ cy.on('tap', 'node[kind = "feature"]', function (evt) {
876
+ if (REQ_GRAPH_WS) openReqDetail(REQ_GRAPH_WS, evt.target.data('module') || '未分组');
877
+ });
878
+ cy.on('tap', 'node[kind = "session"]', function (evt) {
879
+ toast(evt.target.data('label'));
880
+ });
881
+
882
+ /* 图例:模块配色一览(前 6 个模块 + 会话)。 */
883
+ var lg = els.detail.querySelector('#reqGraphLegend');
884
+ if (lg) {
885
+ var mods = [];
886
+ g.nodes.forEach(function (n) { if (n.type === 'feature' && mods.indexOf(n.module) < 0) mods.push(n.module); });
887
+ var frag = document.createDocumentFragment();
888
+ frag.appendChild(hEl('span', 'reqgraph__lg reqgraph__lg--sess', '● 会话'));
889
+ mods.slice(0, 6).forEach(function (m) {
890
+ var it = hEl('span', 'reqgraph__lg');
891
+ it.style.color = graphModuleColor(m);
892
+ it.textContent = '● ' + (m || '未分组');
893
+ frag.appendChild(it);
894
+ });
895
+ lg.replaceChildren(frag);
896
+ }
897
+ /* 离开页面时销毁实例(WebView 里 canvas 实例常驻会占内存)。 */
898
+ els.graphCy = cy;
899
+ if (els.graphCyOld) { try { els.graphCyOld.destroy(); } catch (e) {} }
900
+ els.graphCyOld = cy;
901
+ }
902
+
487
903
  /* 挂载:index.html 在 DOM 就绪后调用一次。
488
904
  host 里的 getter 必须保持为函数 —— 切环境后 BASE/TOKEN/空间清单会变。 */
489
905
  function mount(host) {
@@ -495,24 +911,23 @@
495
911
  els.root = root;
496
912
  els.detail = detail;
497
913
  els.list = root.querySelector('#reqList');
498
- els.detailList = detail.querySelector('#reqDetailList');
499
- els.detailTitle = detail.querySelector('#reqDetailTitle');
500
914
  els.sync = root.querySelector('#btnReqSync');
501
- els.detailBack = detail.querySelector('#btnReqDetailBack');
502
- els.detailSync = detail.querySelector('#btnReqDetailSync');
915
+ els.graphBtn = root.querySelector('#btnReqGraph');
916
+ mountDetail();
503
917
  /* 两个动作:全部整理 / 返回一级;二级页的「整理」只作用于**当前模块**
504
918
  (不是整空间、更不是全部空间 —— 用户点哪个模块,就只整理哪个模块)。 */
505
919
  if (els.sync) els.sync.onclick = function () { consolidateAllRequirements(); };
506
- if (els.detailBack) els.detailBack.onclick = function () { closeReqDetail(); };
507
- if (els.detailSync) els.detailSync.onclick = function () {
508
- if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
509
- consolidateRequirements(REQ_DETAIL.ws, REQ_DETAIL.mod);
510
- };
920
+ if (els.graphBtn) els.graphBtn.onclick = function () { openReqGraph(); };
511
921
  }
512
922
 
513
923
  /* 进入「需求」tab:每次进来都回到一级目录并拉一次清单(只读,不触发归纳)。 */
514
924
  function show() {
515
925
  REQ_DETAIL = null;
926
+ REQ_GRAPH_WS = null;
927
+ if (els.detail && !els.detail.querySelector('#reqDetailList')) {
928
+ els.detail.innerHTML = DETAIL_HTML;
929
+ mountDetail();
930
+ }
516
931
  loadRequirements(false);
517
932
  }
518
933