magic-agent 2.1.5 → 2.3.2

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 (78) hide show
  1. package/CLIENT-CONTRACT.md +147 -11
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +17 -0
  4. package/client-ui/pc/index.html +5 -0
  5. package/client-ui/pc/js/05-task-list-render.js +28 -4
  6. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  7. package/client-ui/pc/js/93-rail.js +24 -2
  8. package/client-ui/shared/conv/03-chat-message.js +63 -3
  9. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  10. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  11. package/client-ui/shared/conv/19-attachments.js +75 -11
  12. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  13. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  14. package/client-ui/shared/conv/README.md +6 -0
  15. package/client-ui/shared/conv/conv.css +33 -1
  16. package/client-ui/shared/conversation.css +42 -4
  17. package/client-ui/shared/conversation.js +801 -102
  18. package/client-ui/shared/transport-http.js +199 -38
  19. package/client-ui-mobile/index.html +731 -581
  20. package/client-ui-mobile/reqs.css +39 -0
  21. package/client-ui-mobile/reqs.js +520 -0
  22. package/cmd/magic-client/main.go +1 -1
  23. package/internal/agent/capability.go +8 -1
  24. package/internal/agent/claude.go +5 -3
  25. package/internal/agent/codebuddy.go +6 -2
  26. package/internal/agent/codex.go +7 -2
  27. package/internal/agent/dsh_sdk.go +8 -0
  28. package/internal/agent/dsh_sdk_test.go +17 -0
  29. package/internal/agent/engine.go +20 -0
  30. package/internal/agent/engine_test.go +52 -0
  31. package/internal/agent/llmengine.go +14 -0
  32. package/internal/agent/llmengine_test.go +24 -0
  33. package/internal/agent/llmhttp.go +6 -0
  34. package/internal/agent/llmhttp_test.go +27 -0
  35. package/internal/agent/llmmodels.go +48 -0
  36. package/internal/agent/permission.go +5 -0
  37. package/internal/cli/ask.go +38 -17
  38. package/internal/cli/requirements.go +2 -0
  39. package/internal/cli/root.go +5 -0
  40. package/internal/cli/session.go +16 -14
  41. package/internal/cli/sessionlog.go +5 -0
  42. package/internal/cli/sessionlog_user_test.go +49 -0
  43. package/internal/cli/suggest.go +98 -0
  44. package/internal/cli/suggest_test.go +54 -0
  45. package/internal/cli/team.go +111 -24
  46. package/internal/client/requirements.go +27 -7
  47. package/internal/client/requirements_test.go +51 -0
  48. package/internal/client/service.go +35 -3
  49. package/internal/client/service_test.go +35 -0
  50. package/internal/client/suggest.go +131 -0
  51. package/internal/client/suggest_test.go +116 -0
  52. package/internal/requirements/consolidate.go +86 -24
  53. package/internal/requirements/requirements.go +9 -0
  54. package/internal/requirements/requirements_test.go +129 -0
  55. package/internal/requirements/store.go +11 -3
  56. package/internal/session/delete.go +58 -11
  57. package/internal/session/delete_test.go +140 -0
  58. package/internal/session/session.go +25 -0
  59. package/internal/session/session_mark_test.go +62 -0
  60. package/internal/suggest/suggest.go +532 -0
  61. package/internal/suggest/suggest_test.go +268 -0
  62. package/internal/team/board.go +107 -15
  63. package/internal/team/execute.go +1 -1
  64. package/internal/team/team.go +28 -19
  65. package/internal/team/team_test.go +68 -15
  66. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  67. package/npm/apk/version.json +3 -3
  68. package/npm/dist/darwin-arm64/magic-agent +0 -0
  69. package/npm/dist/darwin-arm64/magic-client +0 -0
  70. package/npm/dist/darwin-x64/magic-agent +0 -0
  71. package/npm/dist/darwin-x64/magic-client +0 -0
  72. package/npm/dist/linux-arm64/magic-agent +0 -0
  73. package/npm/dist/linux-arm64/magic-client +0 -0
  74. package/npm/dist/linux-x64/magic-agent +0 -0
  75. package/npm/dist/linux-x64/magic-client +0 -0
  76. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  77. package/npm/dist/win32-x64/magic-client.exe +0 -0
  78. package/package.json +6 -2
@@ -0,0 +1,39 @@
1
+ /* ══════════ 需求页(「需求」tab 子页面)══════════
2
+ 从 index.html 拆出:选择器只服务这一页(别的页面不引用)。
3
+ 一级目录(空间 → 模块)、二级模块需求列表、组头「整理」按钮。
4
+ CSS 变量(--ink / --muted / --subtle / --border / --faint / --primary-action /
5
+ --warning / --tint-wait)仍由 index.html 的 :root 统一提供。 */
6
+
7
+ /* 空间规模(几模块 · 几条):紧跟在空间名后面,中性色 —— 不是状态,别用徽标色。
8
+ 需求页专属:空间名改成自适应宽度,meta 才会贴在名字后面;meta 的 margin-right:auto
9
+ 把「待整理」徽标和箭头推到行尾。 */
10
+ .wsrow__open--req .wsrow__l{flex:0 1 auto}
11
+ .wsrow__open--req .wsrow__meta{margin-right:auto}
12
+ .wsrow__meta{flex:0 1 auto;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
13
+ /* 每个空间自己的「整理」:需求页会同时列出多个空间,顶部那一枚只认当前会话的空间。
14
+ 把入口放到组头,用户点哪个空间就初始化/增量哪个空间,不再猜「当前空间」是谁。 */
15
+ .wsrow__sync{flex:0 0 auto;height:32px;padding:0 10px;border-radius:var(--r-full);
16
+ font-size:12px;font-weight:500;color:var(--primary-action);background:var(--subtle);white-space:nowrap}
17
+ .wsrow__sync:active{opacity:.7}
18
+ .wsrow__sync:disabled{color:var(--muted);opacity:.6}
19
+ /* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
20
+ 后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
21
+ .wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
22
+
23
+ /* ══════════ 需求页:两级(空间→模块 → 需求列表)══════════
24
+ 一级是**目录**:空间是行,模块缩进挂在空间下(树形),功能点一律不画在这里 ——
25
+ 需求一多,一级就会被功能点撑成长列表,什么结构都看不出来。
26
+ 二级才是内容:点某个模块进去,**只看这个模块**的需求列表。 */
27
+ .reqtree__mod{position:relative;display:flex;align-items:center;gap:8px;width:100%;
28
+ padding:9px 10px 9px 30px;background:none;border:0;text-align:left;color:var(--ink)}
29
+ .reqtree__mod:active{background:var(--subtle)}
30
+ /* 连接线:从空间名垂下来的细线 + 每个模块前的一小截横线 —— 归属关系靠线说话,不靠缩进猜 */
31
+ .reqtree__mod::before{content:"";position:absolute;left:14px;top:0;bottom:0;width:1px;background:var(--border)}
32
+ .reqtree__mod::after{content:"";position:absolute;left:14px;top:50%;width:10px;height:1px;background:var(--border)}
33
+ .reqtree__modname{flex:1;min-width:0;font-size:14px;font-weight:500;color:var(--ink);
34
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
35
+ .reqtree__modcount{flex:0 0 auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
36
+ .reqtree__modgo{flex:0 0 auto;color:var(--faint);display:flex;align-items:center}
37
+ /* 二级视图的分组小节:只画当前模块,故这一节恒为高亮态 */
38
+ .reqdetail__sec--cur .sechead{color:var(--primary-action)}
39
+ .reqdetail__sec--cur .sechead__t{font-weight:800}
@@ -0,0 +1,520 @@
1
+ /* 需求页(移动端「需求」tab)—— 独立子页面模块。
2
+ *
3
+ * 为什么单独一个文件:这一页的 DOM、样式、渲染与「整理」动作原本全混在
4
+ * index.html 的主 IIFE 里(约 500 行),改需求页要在 5600 行的文件里翻半天。
5
+ * 现在 index 只留两个空容器(#reqs / #reqDetail)+ 一次 mount,本文件自包含:
6
+ * · 一级:空间目录(空间 → 模块,功能点不画);
7
+ * · 二级:点模块进去,**只看这个模块**的需求列表;
8
+ * · 「整理」:POST /desk/requirements/consolidate(全量 / 单空间 / 二级页单模块)。
9
+ *
10
+ * 与宿主(index.html)的边界:只通过 mount(host) 注入的能力交互 ——
11
+ * getBase / getToken(切环境后是活的,必须 getter,不能传快照)、
12
+ * getRootDirs / getCustomSpaces / getRunPref、isRealSpaceDir / topLevelSessions、
13
+ * hEl / elSpan / svgUse / toast / openReader / hideAll / setActive /
14
+ * showTabbar / hideTabbar。
15
+ * 数据形状见 CLIENT-CONTRACT §3.4.1:
16
+ * {ok, root, workspaces[{id,dir,name,pending,memory,
17
+ * modules:[{name,features:[{module,name,file,desc,points,conclusions,conclusion,
18
+ * sources,has_final,final_preview,updated_at}]}],feature_count,
19
+ * last_consolidated_at,last_error}]}
20
+ */
21
+ (function () {
22
+ 'use strict';
23
+
24
+ var H = null; // 宿主(index.html 注入)
25
+ var els = {}; // 需求页内部元素
26
+ var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
27
+ var REQ_BUSY = false; // 「整理」进行中(防重复点击)
28
+ var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
29
+ var REQ_BUSY_KEY = ''; // 本次整理的组头(空间 key;空 = 全部整理,所有组头都置忙)
30
+ var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
31
+ var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
32
+ var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
33
+
34
+ /* 一级 / 二级两个容器的内容都在这里渲染 —— index.html 里只留空壳。 */
35
+ var ROOT_HTML =
36
+ '<div class="history__top">' +
37
+ '<span class="history__title">需求</span>' +
38
+ '<button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>' +
39
+ '</div>' +
40
+ '<div class="history__list" id="reqList"></div>';
41
+ var DETAIL_HTML =
42
+ '<div class="history__top">' +
43
+ '<button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
44
+ '<span class="history__title" id="reqDetailTitle">需求</span>' +
45
+ '<button class="history__disconnect" id="btnReqDetailSync" type="button" title="只整理这个模块">整理</button>' +
46
+ '</div>' +
47
+ '<div class="history__list" id="reqDetailList"></div>';
48
+
49
+ /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。 */
50
+ function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
51
+ /* 宿主能力的短名(函数体里沿用原 index 的写法,搬迁时不用到处加 H.) */
52
+ function hEl(tag, cls, text) { return H.hEl(tag, cls, text); }
53
+ function elSpan(cls, text) { return H.elSpan(cls, text); }
54
+ function svgUse(id, size) { return H.svgUse(id, size); }
55
+ function toast(msg) { return H.toast(msg); }
56
+
57
+ function reqFeatureRow(f) {
58
+ /* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
59
+ 标题(功能点名称)→ 归纳结论 / 需求描述 → 「N 需求点 · M 结论 · 来源 K」。
60
+ 为什么优先结论:这一页要回答的是「这个功能定了什么」,不是「记录了多少条」。 */
61
+ var b = hEl('button', 'srow srow--req');
62
+ b.type = 'button';
63
+ var ico = hEl('span', 'srow__ico srow__ico--idle');
64
+ ico.appendChild(svgUse('#i-doc', 20));
65
+ b.appendChild(ico);
66
+ var col = hEl('span', 'srow__col');
67
+ col.appendChild(hEl('span', 'srow__t', f.name || '(未命名)'));
68
+ /* 先结论,再一句话描述,最后才是定稿预览。定稿预览是 markdown(可能带 `**目标**:`),
69
+ 直接塞进这一行会把星号原样显示出来;有 desc 时它更适合当副标题。 */
70
+ var sub = f.conclusion || f.desc || f.final_preview || '';
71
+ if (sub) col.appendChild(hEl('span', 'srow__s', sub));
72
+ var meta = [];
73
+ if (f.points) meta.push(f.points + ' 需求点');
74
+ if (f.conclusions) meta.push(f.conclusions + ' 结论');
75
+ if (f.sources) meta.push('来源 ' + f.sources);
76
+ if (!f.has_final) meta.push('未定稿');
77
+ if (meta.length) col.appendChild(hEl('span', 'srow__s', meta.join(' · ')));
78
+ b.appendChild(col);
79
+ if (f.updated_at) {
80
+ var d = new Date(Number(f.updated_at) || 0);
81
+ if (!isNaN(d.getTime())) {
82
+ b.appendChild(hEl('span', 'srow__time',
83
+ (d.getMonth() + 1) + '-' + ('0' + d.getDate()).slice(-2)));
84
+ }
85
+ }
86
+ b.onclick = function () { H.openReader(f.file); };
87
+ return b;
88
+ }
89
+
90
+ /* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
91
+ 一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
92
+ 结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
93
+ idx = 排序后的位次(0 = 需求条数最多的那个)。 */
94
+ function reqWorkspaceBlock(w, idx) {
95
+ var wrap = hEl('div', '');
96
+ var head = hEl('div', 'wsrow__head');
97
+ var key = w.id || w.dir || w.name || '';
98
+ var count = Number(w.feature_count) || 0;
99
+ /* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
100
+ 空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
101
+ 用户手动点过谁就按用户的来(REQ_OPEN 里有记录就不动它)。 */
102
+ var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
103
+ var tog = document.createElement('button');
104
+ tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
105
+ tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
106
+ /* 模块数 + 功能点数:一行说清这个空间有多大,比只写空间名有用。
107
+ 用**中性色**(不是 wsrow__badge 那枚 queued 紫)—— 那是「等待中」的语义色,
108
+ 空间规模不是状态,套上会让人误以为这个空间在排队。 */
109
+ var mods = (w.modules || []).filter(function (m) { return (m.features || []).length; });
110
+ tog.appendChild(elSpan('wsrow__meta', mods.length + ' 模块 · ' + count + ' 条'));
111
+ /* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
112
+ if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
113
+ /* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
114
+ 此时不提示 —— 不能把「不知道」画成「未初始化」。 */
115
+ if (w.initialized === false) tog.appendChild(elSpan('wsrow__badge wsrow__badge--init', '未初始化'));
116
+ var chev = elSpan('wsrow__chev' + (open ? ' is-open' : ''));
117
+ chev.appendChild(svgUse('#i-chev-r', 16));
118
+ tog.appendChild(chev);
119
+ tog.onclick = function () { REQ_OPEN[key] = !open; renderRequirements(); };
120
+ head.appendChild(tog);
121
+ /* 组头右侧的「整理」:只作用于这个空间(初始化它 / 归纳它的新会话)。 */
122
+ var sync = document.createElement('button');
123
+ sync.type = 'button'; sync.className = 'wsrow__sync'; sync.textContent = '整理';
124
+ sync.setAttribute('data-ws', key);
125
+ sync.onclick = function (ev) {
126
+ if (ev && ev.stopPropagation) ev.stopPropagation();
127
+ consolidateRequirements(w);
128
+ };
129
+ head.appendChild(sync);
130
+ wrap.appendChild(head);
131
+ if (!open) return wrap;
132
+
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
+ }
138
+ var shown = 0;
139
+ (w.modules || []).forEach(function (m) {
140
+ var fs = (m.features || []);
141
+ if (!fs.length) return;
142
+ shown += fs.length;
143
+ /* 模块 = 目录里的一行(缩进 + 连接线归属到空间),点它进二级看需求列表。
144
+ 未归类(name 为空)的老数据没有模块名可写 —— 这种情况直接不画这一行,
145
+ 功能点仍能在二级里看到(core 把空 module 排在最后)。 */
146
+ if (!m.name) return;
147
+ var row = document.createElement('button');
148
+ row.type = 'button'; row.className = 'reqtree__mod';
149
+ row.appendChild(elSpan('reqtree__modname', m.name));
150
+ row.appendChild(elSpan('reqtree__modcount', String(fs.length)));
151
+ var go = elSpan('reqtree__modgo');
152
+ go.appendChild(svgUse('#i-chev-r', 16));
153
+ row.appendChild(go);
154
+ row.onclick = function () { openReqDetail(w, m.name); };
155
+ wrap.appendChild(row);
156
+ });
157
+ /* 空间还在但还没有文档:别画成一片空白,给出这一组的下一步(整理)。 */
158
+ if (!shown) {
159
+ var tip = Number(w.pending) > 0
160
+ ? '有 ' + w.pending + ' 条对话待整理'
161
+ : (w.initialized === false ? '还没有需求文档,点右侧「整理」按空间生成' : '还没有需求文档');
162
+ var hint = hEl('div', 'history__hint', tip);
163
+ hint.style.padding = '6px 12px 12px';
164
+ wrap.appendChild(hint);
165
+ }
166
+ return wrap;
167
+ }
168
+
169
+ /* 二级:**只看当前模块**的需求列表(2026-10-22 用户定稿:点哪个模块进来就只看哪个模块,
170
+ 别把同空间其他模块的需求也倒进来)。模块名是这一页唯一的筛子;找不到(刚被删 / 改名)
171
+ 时如实说这个模块没有,不退化成整空间 —— 否则用户会以为点错了。 */
172
+ function reqDetailBlock(d) {
173
+ var w = d.ws;
174
+ var frag = document.createDocumentFragment();
175
+ (w.modules || []).forEach(function (m) {
176
+ if (m.name !== d.mod) return;
177
+ var fs = (m.features || []);
178
+ if (!fs.length) return;
179
+ var sec = hEl('div', 'reqdetail__sec reqdetail__sec--cur');
180
+ var mh = hEl('div', 'sechead');
181
+ mh.style.paddingLeft = '16px';
182
+ mh.appendChild(elSpan('sechead__t', m.name + ' (' + fs.length + ')'));
183
+ sec.appendChild(mh);
184
+ fs.forEach(function (f) { sec.appendChild(reqFeatureRow(f)); });
185
+ frag.appendChild(sec);
186
+ });
187
+ if (!frag.childNodes.length) {
188
+ frag.appendChild(hEl('div', 'history__hint', '这个模块还没有需求文档'));
189
+ }
190
+ return frag;
191
+ }
192
+
193
+ /* 进二级:记下是哪个空间的哪个模块,只画这个模块的需求。 */
194
+ function openReqDetail(w, mod) {
195
+ var key = w.id || w.dir || w.name || '';
196
+ REQ_DETAIL = { key: key, mod: mod, ws: w };
197
+ renderRequirements();
198
+ /* 空间在两次渲染之间被删掉了(会话全删 → 空间联动消失):
199
+ renderRequirements 会把 REQ_DETAIL 清空,这时别停在空白的二级页,退回目录。 */
200
+ if (!REQ_DETAIL) { H.showRoot(); return; }
201
+ H.showDetail();
202
+ if (els.detailTitle) els.detailTitle.textContent = (w.name || w.dir || w.id) + ' · ' + mod;
203
+ /* 页里只有这一个模块,进来从顶部看起(旧的「滚到当前模块」已无意义)。 */
204
+ if (els.detailList) els.detailList.scrollTop = 0;
205
+ }
206
+
207
+ /* 退二级回一级目录。 */
208
+ function closeReqDetail() {
209
+ REQ_DETAIL = null;
210
+ H.showRoot();
211
+ renderRequirements();
212
+ }
213
+
214
+ function renderRequirements() {
215
+ if (!els.list) return;
216
+ var data = REQ_DATA;
217
+ if (!data) return;
218
+ /* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
219
+ (用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
220
+ 也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
221
+ 这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
222
+ 拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
223
+ var all = (data && data.workspaces) || [];
224
+ var list = all.filter(function (w) {
225
+ var dir = reqNormDir(w && w.dir);
226
+ /* 根目录 / 归一后是空串 —— 「!dir 就放行」的老写法会把它漏进来,
227
+ 需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
228
+ 这里先卡一道:不是真空间目录的直接不画。 */
229
+ if (!H.isRealSpaceDir(dir)) return false;
230
+ return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
231
+ });
232
+ /* 排序:**需求条数多的排前面**(用户 2026-10-22)。
233
+ core 给的顺序是「最后归纳时间倒序」,但那是给「最近在动的」用的;
234
+ 需求页要回答的是「哪块需求最多」,条数多的才是用户要先看的那个。
235
+ 条数一样时按名字排 —— 同一条数下顺序要稳定,不能每次刷新都在跳。 */
236
+ list.sort(function (a, b) {
237
+ var ca = Number(a && a.feature_count) || 0;
238
+ var cb = Number(b && b.feature_count) || 0;
239
+ if (ca !== cb) return cb - ca;
240
+ return String((a && a.name) || '').localeCompare(String((b && b.name) || ''));
241
+ });
242
+ /* 二级视图:只画这个模块的需求列表。空间可能已被会话删除联动掉
243
+ (list 里没有它)—— 那就退回一级目录,不要留一页空白。 */
244
+ if (REQ_DETAIL) {
245
+ var hit = null;
246
+ for (var i = 0; i < list.length; i++) {
247
+ if ((list[i].id || list[i].dir || list[i].name || '') === REQ_DETAIL.key) { hit = list[i]; break; }
248
+ }
249
+ if (!hit) { REQ_DETAIL = null; }
250
+ else {
251
+ REQ_DETAIL.ws = hit;
252
+ if (els.detailTitle) els.detailTitle.textContent = (hit.name || hit.dir || hit.id) + ' · ' + REQ_DETAIL.mod;
253
+ if (els.detailList) els.detailList.replaceChildren(reqDetailBlock(REQ_DETAIL));
254
+ if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
255
+ return;
256
+ }
257
+ }
258
+ var frag = document.createDocumentFragment();
259
+ if (!list.length) {
260
+ /* 一个空间都没有(会话全删了 / 还没聊过)。 */
261
+ frag.appendChild(hEl('div', 'history__hint',
262
+ REQ_ALIVE
263
+ ? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
264
+ : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
265
+ els.list.replaceChildren(frag);
266
+ return;
267
+ }
268
+ /* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
269
+ 见 reqWorkspaceBlock 的 open 判定。 */
270
+ list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
271
+ els.list.replaceChildren(frag);
272
+ /* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
273
+ if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
274
+ }
275
+
276
+ function loadRequirements(quiet) {
277
+ if (!els.list) return;
278
+ if (!quiet && !REQ_DATA) els.list.innerHTML = '<div class="history__hint">读取中…</div>';
279
+ var hdr = H.getToken() ? { 'X-Magic-Token': H.getToken() } : {};
280
+ var pReq = fetch(H.getBase() + '/desk/requirements', { headers: hdr })
281
+ .then(function (r) {
282
+ if (r.status === 501) {
283
+ /* 老 core 没有这个能力位:**说清楚**,不要给一页空白
284
+ (空白看起来像「没有需求」,是最难查的一种)。 */
285
+ return r.json().then(function (e) { throw new Error((e && e.error) || '当前 core 不支持需求聚合'); });
286
+ }
287
+ if (!r.ok) throw new Error('HTTP ' + r.status);
288
+ return r.json();
289
+ });
290
+ /* 会话清单与空间目录:用来判断「这个空间在任务页还在不在」。
291
+ 两者都允许失败 —— 拿不到就不筛空间(见 renderRequirements)。 */
292
+ var pSess = fetch(H.getBase() + '/desk/sessions', { headers: hdr })
293
+ .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
294
+ var pDirs = fetch(H.getBase() + '/desk/dirs', { headers: hdr })
295
+ .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
296
+ Promise.all([pReq, pSess, pDirs])
297
+ .then(function (a) {
298
+ var sess = a[1], dirs = (a[2] && a[2].dirs) || null;
299
+ if (Array.isArray(sess) || Array.isArray(dirs)) {
300
+ /* 与任务页**同一套空间键**(用户 2026-10-22:「4个空间需求里显示的不一样」
301
+ 「会话中全部删除后需求中的空间还在 也要联动删除」「没有空间的不需要」):
302
+ ① 根目录下的 magic-* ② 自定义空间 ③ **顶层会话**的工作目录。
303
+ ⚠️ ③ 只能取**顶层**会话的 workspace:团队子代理的会话挂在父会话下面,
304
+ 它们的工作目录是 /…/run-xxx/a1 这种临时目录 —— 任务页不把它们列成空间,
305
+ 需求页照收就会多出一堆 a1/t1/tmp 假空间。
306
+ ⚠️ 根目录 / 与 /tmp 这类系统目录不是空间,哪怕历史上有会话在那儿跑过。 */
307
+ var alive = {};
308
+ (H.getRootDirs() || []).forEach(function (d) { if (d && d.path) alive[reqNormDir(d.path)] = 1; });
309
+ (dirs || []).forEach(function (d) {
310
+ if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[reqNormDir(d.path)] = 1;
311
+ });
312
+ (H.getCustomSpaces() || []).forEach(function (p) { if (p) alive[reqNormDir(p)] = 1; });
313
+ H.topLevelSessions(sess).forEach(function (s) {
314
+ if (s && s.workspace && H.isRealSpaceDir(s.workspace)) alive[reqNormDir(s.workspace)] = 1;
315
+ });
316
+ REQ_ALIVE = alive;
317
+ } else {
318
+ REQ_ALIVE = null; // 清单没拿到:不筛,宁可多显示
319
+ }
320
+ REQ_DATA = a[0] || { workspaces: [] };
321
+ renderRequirements();
322
+ })
323
+ .catch(function (e) {
324
+ if (quiet && REQ_DATA) return; // 后台刷新失败不覆盖已有画面
325
+ els.list.innerHTML = '';
326
+ els.list.appendChild(hEl('div', 'history__hint', '读取需求失败:' + ((e && e.message) || e)));
327
+ });
328
+ }
329
+
330
+ /* 「整理」:手动触发一轮(POST /desk/requirements/consolidate)。
331
+ 服务端按 state 判断走哪条路:
332
+ · 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
333
+ · 已初始化 → 只消费上次整理之后的新会话。
334
+ 入口有三个:
335
+ · 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
336
+ · 二级页右上角「整理」——只整理当前模块(body 带 module,不推进空间游标);
337
+ · 一级页右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
338
+ 三个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
339
+ function reqPostConsolidate(ws, module) {
340
+ var hdr = { 'Content-Type': 'application/json' };
341
+ if (H.getToken()) hdr['X-Magic-Token'] = H.getToken();
342
+ return fetch(H.getBase() + '/desk/requirements/consolidate', {
343
+ method: 'POST', headers: hdr,
344
+ body: JSON.stringify({
345
+ workspace: ws || '',
346
+ module: module || '',
347
+ engine: H.getRunPref().engine || '',
348
+ model: (H.getRunPref().engine && H.getRunPref().model) || ''
349
+ })
350
+ })
351
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
352
+ .then(function (x) {
353
+ var d = x.d || {};
354
+ if (!x.ok) throw new Error(d.error || ('HTTP ' + (x.d && x.d.status)));
355
+ if (d.error) throw new Error(d.error);
356
+ return d;
357
+ });
358
+ }
359
+
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 = '全部整理';
369
+ var btn = els.sync;
370
+ if (btn) {
371
+ btn.disabled = !!on;
372
+ if (label !== undefined) btn.textContent = label;
373
+ }
374
+ var dbtn = els.detailSync;
375
+ if (dbtn) {
376
+ dbtn.disabled = !!on;
377
+ dbtn.textContent = on ? '整理中…' : '整理';
378
+ }
379
+ 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) ? '整理中…' : '整理';
383
+ });
384
+ }
385
+
386
+ /* 整理一个空间(module 为空)或一个模块(module 非空)。
387
+ · 空间级:消费该空间游标之后的新会话,可能同时更新多个模块;
388
+ · 模块级:只更新这个模块,**不推进空间游标** —— 别的模块的需求仍留在
389
+ pending,等空间级「整理」/下一轮自动归纳处理,不会被这一下吞掉。
390
+ 服务端按 state 判断走哪条路:没初始化过 → 先按空间目录反推基线;
391
+ 模块级不会触发首次初始化(目录基线天然是空间级的)。 */
392
+ function consolidateRequirements(target, module) {
393
+ if (REQ_BUSY || !target) return;
394
+ var ws = target.dir || target.id || '';
395
+ if (!ws) return;
396
+ module = String(module || '').trim();
397
+ 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);
402
+ reqPostConsolidate(ws, module)
403
+ .then(function (d) {
404
+ var prefix = module ? '模块「' + module + '」' : '';
405
+ if (d.bootstrap) {
406
+ if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
407
+ else toast('已按空间目录初始化:暂未识别到功能点');
408
+ }
409
+ else if (d.noop) toast(prefix ? prefix + '没有新的对话需要整理' : '没有新的对话需要整理');
410
+ else if (d.updated || d.created) toast((prefix || '整理完成') + ':更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
411
+ else toast(prefix ? prefix + '整理完成' : '整理完成');
412
+ loadRequirements(true); // 后台刷新(不闪「读取中」)
413
+ })
414
+ .catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
415
+ .then(function () {
416
+ REQ_BUSY = false;
417
+ REQ_BUSY_KEY = '';
418
+ reqSetBusy(false, '全部整理');
419
+ });
420
+ }
421
+
422
+ /* 全部整理:按空间串行跑,避免多个初始化同时读目录把机器拖垮。
423
+ 顺序 = 有待整理的 → 未初始化的 → 其它(最近动过的仍在前),先做便宜/该做的。
424
+ 每个空间单独一次 HTTP(各自 10 分钟超时);某个失败不影响后面的。 */
425
+ function reqAllWorkspaces() {
426
+ var seen = {};
427
+ var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
428
+ var key = w.dir || w.id || '';
429
+ if (!key || seen[key]) return false;
430
+ /* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
431
+ var dir = reqNormDir(w.dir);
432
+ if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
433
+ seen[key] = true;
434
+ return true;
435
+ });
436
+ function rank(w) {
437
+ if (Number(w.pending) > 0) return 0;
438
+ if (w.initialized === false) return 1;
439
+ return 2;
440
+ }
441
+ return list.sort(function (a, b) { return rank(a) - rank(b); });
442
+ }
443
+
444
+ function consolidateAllRequirements() {
445
+ if (REQ_BUSY) return;
446
+ var list = reqAllWorkspaces();
447
+ if (!list.length) {
448
+ toast('还没有可整理的空间');
449
+ return;
450
+ }
451
+ REQ_BUSY = true;
452
+ REQ_BUSY_KEY = ''; // 空 = 全部整理:所有组头一起显示「整理中…」
453
+ reqSetBusy(true, '整理中 0/' + list.length);
454
+ var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
455
+ function done() {
456
+ REQ_BUSY = false;
457
+ REQ_BUSY_KEY = '';
458
+ reqSetBusy(false, '全部整理');
459
+ loadRequirements(true);
460
+ if (fail) {
461
+ toast('全部整理完成:成功 ' + ok + ' · 失败 ' + fail + (firstErr ? '(' + firstErr + ')' : ''));
462
+ } else {
463
+ toast('全部整理完成:' + ok + ' 个空间 · 新建 ' + created + ' · 更新 ' + updated);
464
+ }
465
+ }
466
+ function step() {
467
+ if (i >= list.length) { done(); return; }
468
+ var w = list[i++];
469
+ REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
470
+ var btn = els.sync;
471
+ if (btn) btn.textContent = REQ_BUSY_LABEL;
472
+ reqPostConsolidate(w.dir || w.id || '', '')
473
+ .then(function (d) {
474
+ ok++;
475
+ created += Number(d.created) || 0;
476
+ updated += Number(d.updated) || 0;
477
+ })
478
+ .catch(function (e) {
479
+ fail++;
480
+ if (!firstErr) firstErr = (e && e.message) || String(e);
481
+ })
482
+ .then(step);
483
+ }
484
+ step();
485
+ }
486
+
487
+ /* 挂载:index.html 在 DOM 就绪后调用一次。
488
+ host 里的 getter 必须保持为函数 —— 切环境后 BASE/TOKEN/空间清单会变。 */
489
+ function mount(host) {
490
+ H = host;
491
+ var root = H.rootEl, detail = H.detailEl;
492
+ if (!root || !detail) return;
493
+ root.innerHTML = ROOT_HTML;
494
+ detail.innerHTML = DETAIL_HTML;
495
+ els.root = root;
496
+ els.detail = detail;
497
+ els.list = root.querySelector('#reqList');
498
+ els.detailList = detail.querySelector('#reqDetailList');
499
+ els.detailTitle = detail.querySelector('#reqDetailTitle');
500
+ els.sync = root.querySelector('#btnReqSync');
501
+ els.detailBack = detail.querySelector('#btnReqDetailBack');
502
+ els.detailSync = detail.querySelector('#btnReqDetailSync');
503
+ /* 两个动作:全部整理 / 返回一级;二级页的「整理」只作用于**当前模块**
504
+ (不是整空间、更不是全部空间 —— 用户点哪个模块,就只整理哪个模块)。 */
505
+ 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
+ };
511
+ }
512
+
513
+ /* 进入「需求」tab:每次进来都回到一级目录并拉一次清单(只读,不触发归纳)。 */
514
+ function show() {
515
+ REQ_DETAIL = null;
516
+ loadRequirements(false);
517
+ }
518
+
519
+ window.MagicReqs = { mount: mount, show: show };
520
+ })();
@@ -126,7 +126,7 @@ func main() {
126
126
  if strings.TrimSpace(*uiDir) != "" || strings.TrimSpace(*mobileDir) != "" {
127
127
  fmt.Fprintf(os.Stderr, "magic-client: 移动端界面(远程 HTML,改界面免重打 APK) http://127.0.0.1:%d/m/?token=%s\n", port, tok)
128
128
  }
129
- fmt.Fprintf(os.Stderr, "magic-client: 接口 /desk/{health,contract,engines,modules,sessions,profile,asks,ask,ask/{id}/control} + 业务转发 /api/*(都要 X-Magic-Token)\n")
129
+ fmt.Fprintf(os.Stderr, "magic-client: 接口 /desk/{health,contract,engines,modules,sessions,profile,suggest,asks,ask,ask/{id}/control} + 业务转发 /api/*(都要 X-Magic-Token)\n")
130
130
  var lanAddrs []string
131
131
  if *lan {
132
132
  // 绑到局域网就把可用地址全打出来:一台机器常有多个网卡,猜错比多打两行麻烦。
@@ -149,6 +149,13 @@ const (
149
149
  FeatureSessionEvents = "feature.session.events"
150
150
  // FeatureSessionControl `--stream --control`:stdin NDJSON 控制通道(打断 / 收工 / 回审批)。
151
151
  FeatureSessionControl = "feature.session.control"
152
+ // FeatureSessionHosted `--hosted`:这一轮的用户提问由客户端「会话托管」自动发出。
153
+ //
154
+ // 为什么归 core:托管本身是**纯客户端策略**(执行仍走普通 ask),但「这句是托管的」
155
+ // 要写进会话历史,回放时才能把气泡画成「托管发送」而不是「我」—— 历史在 core 手里,
156
+ // 所以标识只能由 core 落。客户端据此决定要不要把 hosted 透传给 core;老 core 没有
157
+ // 这个位时插件忽略该字段(实时气泡仍由客户端自己标),不阻断发送。
158
+ FeatureSessionHosted = "feature.session.hosted"
152
159
  // FeatureSessionTeam `--team`:团队模式(主引擎拆任务、子引擎分头执行、主引擎汇总)。
153
160
  //
154
161
  // 为什么归 core 而不是某个引擎:它是**会话层的一种运行模式**,任何能当主引擎的
@@ -164,5 +171,5 @@ const (
164
171
 
165
172
  // CoreFeatures 返回 core 自身提供的通道能力(字典序,稳定)。
166
173
  func CoreFeatures() []string {
167
- return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionTeam}
174
+ return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionHosted, FeatureSessionTeam}
168
175
  }
@@ -238,9 +238,11 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
238
238
  if m := stripModelPrefix(req.Model); m != "" {
239
239
  args = append(args, "--model", m)
240
240
  }
241
- // 推理档位:claude 的 --effort 只认 low/medium/high/xhigh/max(无 minimal),
242
- // 传 minimal 时降级为 low,避免 clap 直接拒绝整轮调用。
243
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
241
+ // 推理 / 思考:建议这类短任务显式关闭(--thinking disabled);
242
+ // 其余调用仍按 --effort 档位。两者互斥,关闭时不传 effort,避免又落回默认高档。
243
+ if req.NoThinking {
244
+ args = append(args, "--thinking", "disabled")
245
+ } else if lv := ReasoningLevel(req.Reasoning); lv != "" {
244
246
  if lv == "minimal" {
245
247
  lv = "low"
246
248
  }
@@ -375,8 +375,12 @@ func (c codebuddyCore) buildArgsBase(req Request) []string {
375
375
  args = append(args, "--model", c.model)
376
376
  }
377
377
  // 推理档位:codebuddy 的 --effort 认 minimal/low/medium/high/xhigh/max。
378
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
379
- args = append(args, "--effort", lv)
378
+ // NoThinking 走官方 settings alwaysThinkingEnabled=false(见 agentSettingsPayload),
379
+ // 不传 --effort off(该值不在 --effort 合法枚举里)。
380
+ if !req.NoThinking {
381
+ if lv := ReasoningLevel(req.Reasoning); lv != "" {
382
+ args = append(args, "--effort", lv)
383
+ }
380
384
  }
381
385
  // 统一参数矩阵:`--settings` 只接受一份载荷,故 MaxTokens 的 env 注入与
382
386
  // 四档模型的 sandbox / autoMode / permissions 合并进同一个 JSON。
@@ -540,8 +540,13 @@ func (e *CodexEngine) buildArgs(req Request, prompt, outFile, schemaFile string)
540
540
  }
541
541
  }
542
542
  // 推理档位:`-c` 在 `exec` 与 `exec resume` 下都可用(同 model 覆写),
543
- // 故不按子命令分流。codex 的取值:minimal/low/medium/high/xhigh/max。
544
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
543
+ // 故不按子命令分流。codex 的取值:none/minimal/low/medium/high/xhigh/max。
544
+ // 建议这类短任务用 none 明确关闭思考(不支持 none 的模型会在服务端报错,
545
+ // 由建议层降级到 llm,不会拖住用户会话)。
546
+ if req.NoThinking {
547
+ args = append(args, "-c", "model_reasoning_effort="+tomlBasicString("none"),
548
+ "-c", "model_reasoning_summary="+tomlBasicString("none"))
549
+ } else if lv := ReasoningLevel(req.Reasoning); lv != "" {
545
550
  args = append(args, "-c", "model_reasoning_effort="+tomlBasicString(lv))
546
551
  }
547
552
  // SystemPrompt → -c developer_instructions=<TOML 字符串>