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.
- package/CLIENT-CONTRACT.md +147 -11
- package/README.md +10 -4
- package/client-ui/pc/app.css +17 -0
- package/client-ui/pc/index.html +5 -0
- package/client-ui/pc/js/05-task-list-render.js +28 -4
- package/client-ui/pc/js/07-chat-stream-render.js +19 -8
- package/client-ui/pc/js/93-rail.js +24 -2
- package/client-ui/shared/conv/03-chat-message.js +63 -3
- package/client-ui/shared/conv/06-ask-answer.js +30 -7
- package/client-ui/shared/conv/09-stream-apply.js +16 -0
- package/client-ui/shared/conv/19-attachments.js +75 -11
- package/client-ui/shared/conv/27-conv-ledger.js +1 -1
- package/client-ui/shared/conv/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +33 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +801 -102
- package/client-ui/shared/transport-http.js +199 -38
- package/client-ui-mobile/index.html +731 -581
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +520 -0
- package/cmd/magic-client/main.go +1 -1
- package/internal/agent/capability.go +8 -1
- package/internal/agent/claude.go +5 -3
- package/internal/agent/codebuddy.go +6 -2
- package/internal/agent/codex.go +7 -2
- package/internal/agent/dsh_sdk.go +8 -0
- package/internal/agent/dsh_sdk_test.go +17 -0
- package/internal/agent/engine.go +20 -0
- package/internal/agent/engine_test.go +52 -0
- package/internal/agent/llmengine.go +14 -0
- package/internal/agent/llmengine_test.go +24 -0
- package/internal/agent/llmhttp.go +6 -0
- package/internal/agent/llmhttp_test.go +27 -0
- package/internal/agent/llmmodels.go +48 -0
- package/internal/agent/permission.go +5 -0
- package/internal/cli/ask.go +38 -17
- package/internal/cli/requirements.go +2 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/sessionlog.go +5 -0
- package/internal/cli/sessionlog_user_test.go +49 -0
- package/internal/cli/suggest.go +98 -0
- package/internal/cli/suggest_test.go +54 -0
- package/internal/cli/team.go +111 -24
- package/internal/client/requirements.go +27 -7
- package/internal/client/requirements_test.go +51 -0
- package/internal/client/service.go +35 -3
- package/internal/client/service_test.go +35 -0
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/requirements/consolidate.go +86 -24
- package/internal/requirements/requirements.go +9 -0
- package/internal/requirements/requirements_test.go +129 -0
- package/internal/requirements/store.go +11 -3
- package/internal/session/delete.go +58 -11
- package/internal/session/delete_test.go +140 -0
- package/internal/session/session.go +25 -0
- package/internal/session/session_mark_test.go +62 -0
- package/internal/suggest/suggest.go +532 -0
- package/internal/suggest/suggest_test.go +268 -0
- package/internal/team/board.go +107 -15
- package/internal/team/execute.go +1 -1
- package/internal/team/team.go +28 -19
- package/internal/team/team_test.go +68 -15
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +6 -2
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
<!-- 对话内核那套 DOM(.qa-*)的**共用皮肤**:三端同一份(见 shared/conv/conv.css 的文件头)。
|
|
33
33
|
里面的取值全走令牌,本页只负责给令牌(下面的 :root)—— 皮肤本身不定义颜色。 -->
|
|
34
34
|
<link rel="stylesheet" href="shared/conv/conv.css">
|
|
35
|
+
<!-- 需求页(「需求」tab)是独立子页面:样式在 reqs.css、结构/逻辑在 reqs.js。
|
|
36
|
+
本文件只给两个空容器与一次 mount,不再混入这一页的实现。 -->
|
|
37
|
+
<link rel="stylesheet" href="reqs.css">
|
|
35
38
|
<style>
|
|
36
39
|
/* ===== 令牌:ZCode 安卓端那一套,映射到共享组件的颜色契约 =====
|
|
37
40
|
⚠️ 默认 = 暗色:跟 ZCode 的 DarkColorScheme 逐字段对齐。 */
|
|
@@ -64,6 +67,12 @@
|
|
|
64
67
|
/* 状态色底(半透明叠加色,明暗主题通用) */
|
|
65
68
|
--tint-run:rgba(59,130,246,.18); --tint-wait:rgba(245,158,11,.20); --tint-done:rgba(34,197,94,.18);
|
|
66
69
|
--tint-queued:rgba(139,92,246,.18); --queued:#8B5CF6;
|
|
70
|
+
/* 异常终态两档(2026-10-23 用户:「现在没有异常状态的颜色」):
|
|
71
|
+
· 失败(failed / gone)→ 红:这条没跑完就没了,要人看一眼;
|
|
72
|
+
· 已取消 / 已打断(cancelled / interrupted)→ 石褐:人为收工,不是故障,
|
|
73
|
+
所以不上红,但也绝不能和「正常结束」的绿或兜底的白同色。 */
|
|
74
|
+
--tint-fail:rgba(239,68,68,.18); --fail:#F87171;
|
|
75
|
+
--tint-stop:rgba(120,113,108,.24); --stop:#A8A29E;
|
|
67
76
|
|
|
68
77
|
/* ── 掌天瓶绿液(可选令牌,shared/conversation.css 用 `var(--q-bottle*, 兜底)` 取)──
|
|
69
78
|
瓶是**玉**不是玻璃(见 conversation.js 的形制说明),所以颜色分两组:
|
|
@@ -109,6 +118,9 @@
|
|
|
109
118
|
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
110
119
|
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
111
120
|
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
121
|
+
/* 浅底上异常终态要更重(半透明红 / 石褐在 #F8FAFC 上否则会糊成灰) */
|
|
122
|
+
--tint-fail:rgba(220,38,38,.13); --fail:#DC2626;
|
|
123
|
+
--tint-stop:rgba(120,113,108,.16); --stop:#78716C;
|
|
112
124
|
/* 浅底上玉胎要更浓:`.42` 那档实测会被冲成白瓷,读不出「翠」。浓到 .66 又像实心塑料。 */
|
|
113
125
|
--q-bottle-jade-hi:rgba(146,231,184,.62);
|
|
114
126
|
--q-bottle-jade:rgba(96,205,152,.60);
|
|
@@ -134,6 +146,9 @@ html[data-theme="light"]{color-scheme:light;
|
|
|
134
146
|
--accent:#2563EB; --warning:#D97706; --success:#16A34A;
|
|
135
147
|
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
136
148
|
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
149
|
+
/* 浅底上异常终态要更重(同 prefers-color-scheme:light 那一份) */
|
|
150
|
+
--tint-fail:rgba(220,38,38,.13); --fail:#DC2626;
|
|
151
|
+
--tint-stop:rgba(120,113,108,.16); --stop:#78716C;
|
|
137
152
|
/* 浅底上玉胎要更浓(同上一条注释) */
|
|
138
153
|
--q-bottle-jade-hi:rgba(146,231,184,.62);
|
|
139
154
|
--q-bottle-jade:rgba(96,205,152,.60);
|
|
@@ -285,6 +300,11 @@ textarea{font:inherit}
|
|
|
285
300
|
.srow__ico--run{background:var(--tint-run);color:var(--primary-action)}
|
|
286
301
|
.srow__ico--wait{background:var(--tint-wait);color:var(--warning)}
|
|
287
302
|
.srow__ico--done{background:var(--tint-done);color:var(--success)}
|
|
303
|
+
/* 异常终态:以前和 idle 一起落在灰白兜底档,用户看不出「这条是挂了 / 被停了,
|
|
304
|
+
还是只是没跑过」(2026-10-23 用户:「现在没有异常状态的颜色」)。
|
|
305
|
+
失败(failed / gone)= 红;已取消 / 已打断(cancelled / interrupted)= 石褐。 */
|
|
306
|
+
.srow__ico--fail{background:var(--tint-fail);color:var(--fail)}
|
|
307
|
+
.srow__ico--stop{background:var(--tint-stop);color:var(--stop)}
|
|
288
308
|
.srow__ico--idle{background:var(--subtle);color:var(--muted)}
|
|
289
309
|
/* 等待中(串行排队):紫色 + 一圈**呼吸**的环。
|
|
290
310
|
与「运行中」的扫光刻意分开 —— 扫光在转(在动),这圈只明暗(在等);
|
|
@@ -313,11 +333,25 @@ textarea{font:inherit}
|
|
|
313
333
|
.srow__s{display:flex;align-items:center;gap:5px;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden}
|
|
314
334
|
.srow__stext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
315
335
|
.srow__time{flex:0 0 auto;font-size:12px;color:var(--muted)}
|
|
336
|
+
/* 运行中**自动临时置顶**的小图钉(2026-10-22):让「它凭什么在最上面」有据可查。
|
|
337
|
+
手动置顶靠「置顶」分段表达,不重复画;跑完 statusOf 不再是 run,图钉随之消失。 */
|
|
338
|
+
.srow__auto{flex:0 0 auto;display:flex;align-items:center;color:var(--primary-action);opacity:.75}
|
|
316
339
|
/* 子会话(团队模式拆出的子任务,2026-10-22):**缩进挂在主会话下面**,不占顶层一行。
|
|
317
340
|
左侧一道竖线 + 小一档图标 —— 读起来是「这条主会话拆出去的活」;点开仍是它自己
|
|
318
|
-
那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。
|
|
341
|
+
那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。
|
|
342
|
+
⚠️ 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
|
|
343
|
+
计数徽标(有几个子会话 / 是否有子任务在跑),点它才展开。 */
|
|
319
344
|
.sblock{min-width:0}
|
|
320
345
|
.sblock__kids{margin:0 8px 6px 30px;padding-left:8px;border-left:2px solid var(--border)}
|
|
346
|
+
.srow__kids{flex:0 0 auto;display:inline-flex;align-items:center;gap:3px;height:20px;padding:0 5px;
|
|
347
|
+
border:1px solid var(--border);border-radius:999px;background:var(--subtle);color:var(--muted);
|
|
348
|
+
font-size:11px;font-weight:600;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none}
|
|
349
|
+
.srow__kids:active{opacity:.7}
|
|
350
|
+
.srow__kids svg{transition:transform .15s ease}
|
|
351
|
+
.srow__kids.is-open svg{transform:rotate(90deg)}
|
|
352
|
+
/* 有子任务在跑:徽标转成运行色 + 一个小圆点(折叠时也能看出「下面有活在跑」)。 */
|
|
353
|
+
.srow__kids.is-run{color:var(--running);border-color:currentColor}
|
|
354
|
+
.srow__kids-dot{width:6px;height:6px;border-radius:50%;background:var(--running)}
|
|
321
355
|
.srow--sub{padding:9px 8px 9px 4px;gap:10px}
|
|
322
356
|
.srow--sub .srow__ico{width:32px;height:32px}
|
|
323
357
|
.srow--sub .srow__ico svg{width:16px;height:16px}
|
|
@@ -365,12 +399,6 @@ textarea{font:inherit}
|
|
|
365
399
|
.wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
|
|
366
400
|
.wsrow__open:active{background:var(--subtle)}
|
|
367
401
|
.wsrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
368
|
-
/* 空间规模(几模块 · 几条):紧跟在空间名后面,中性色 —— 不是状态,别用徽标色。
|
|
369
|
-
需求页专属:空间名改成自适应宽度,meta 才会贴在名字后面;meta 的 margin-right:auto
|
|
370
|
-
把「待整理」徽标和箭头推到行尾。 */
|
|
371
|
-
.wsrow__open--req .wsrow__l{flex:0 1 auto}
|
|
372
|
-
.wsrow__open--req .wsrow__meta{margin-right:auto}
|
|
373
|
-
.wsrow__meta{flex:0 1 auto;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
374
402
|
/* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致) */
|
|
375
403
|
.wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
|
|
376
404
|
color:var(--queued);background:var(--tint-queued);white-space:nowrap}
|
|
@@ -378,33 +406,6 @@ textarea{font:inherit}
|
|
|
378
406
|
.wsrow__chev.is-open{transform:rotate(90deg)}
|
|
379
407
|
.wsrow__add{flex:0 0 auto;width:36px;height:36px;border-radius:var(--r-full);display:flex;align-items:center;justify-content:center;color:var(--muted)}
|
|
380
408
|
.wsrow__add:active{background:var(--subtle)}
|
|
381
|
-
/* 每个空间自己的「整理」:需求页会同时列出多个空间,顶部那一枚只认当前会话的空间。
|
|
382
|
-
把入口放到组头,用户点哪个空间就初始化/增量哪个空间,不再猜「当前空间」是谁。 */
|
|
383
|
-
.wsrow__sync{flex:0 0 auto;height:32px;padding:0 10px;border-radius:var(--r-full);
|
|
384
|
-
font-size:12px;font-weight:500;color:var(--primary-action);background:var(--subtle);white-space:nowrap}
|
|
385
|
-
.wsrow__sync:active{opacity:.7}
|
|
386
|
-
.wsrow__sync:disabled{color:var(--muted);opacity:.6}
|
|
387
|
-
/* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
|
|
388
|
-
后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
|
|
389
|
-
.wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
|
|
390
|
-
|
|
391
|
-
/* ══════════ 需求页:两级(空间→模块 → 需求列表)══════════
|
|
392
|
-
一级是**目录**:空间是行,模块缩进挂在空间下(树形),功能点一律不画在这里 ——
|
|
393
|
-
需求一多,一级就会被功能点撑成长列表,什么结构都看不出来。
|
|
394
|
-
二级才是内容:点某个模块进去,看**该空间下所有模块**的需求列表。 */
|
|
395
|
-
.reqtree__mod{position:relative;display:flex;align-items:center;gap:8px;width:100%;
|
|
396
|
-
padding:9px 10px 9px 30px;background:none;border:0;text-align:left;color:var(--ink)}
|
|
397
|
-
.reqtree__mod:active{background:var(--subtle)}
|
|
398
|
-
/* 连接线:从空间名垂下来的细线 + 每个模块前的一小截横线 —— 归属关系靠线说话,不靠缩进猜 */
|
|
399
|
-
.reqtree__mod::before{content:"";position:absolute;left:14px;top:0;bottom:0;width:1px;background:var(--border)}
|
|
400
|
-
.reqtree__mod::after{content:"";position:absolute;left:14px;top:50%;width:10px;height:1px;background:var(--border)}
|
|
401
|
-
.reqtree__modname{flex:1;min-width:0;font-size:14px;font-weight:500;color:var(--ink);
|
|
402
|
-
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
403
|
-
.reqtree__modcount{flex:0 0 auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
404
|
-
.reqtree__modgo{flex:0 0 auto;color:var(--faint);display:flex;align-items:center}
|
|
405
|
-
/* 二级视图的分组小节:当前所在模块高亮,其余弱化 */
|
|
406
|
-
.reqdetail__sec--cur .sechead{color:var(--primary-action)}
|
|
407
|
-
.reqdetail__sec--cur .sechead__t{font-weight:800}
|
|
408
409
|
/* 长按会话行的浮动菜单 */
|
|
409
410
|
.rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
|
|
410
411
|
border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
|
|
@@ -722,6 +723,9 @@ button.setrow{font-family:inherit}
|
|
|
722
723
|
.qa-todo-ico{width:14px;height:14px;border-width:1.5px}
|
|
723
724
|
.qa-todo-ico svg{width:9px;height:9px}
|
|
724
725
|
.qa-todo-meta{font-size:11px;max-width:52%}
|
|
726
|
+
.qa-todo-active{font-size:11px;max-width:56%}
|
|
727
|
+
.qa-todo-st{font-size:11.5px}
|
|
728
|
+
.qa-todo-dur{font-size:11.5px}
|
|
725
729
|
.qa-todo-toggle{width:26px;height:26px}
|
|
726
730
|
.qa-todo-toggle svg{width:14px;height:14px}
|
|
727
731
|
|
|
@@ -984,13 +988,20 @@ button.setrow{font-family:inherit}
|
|
|
984
988
|
<button class="seg__b" data-v="serial">串行</button>
|
|
985
989
|
</span>
|
|
986
990
|
</div>
|
|
991
|
+
<!-- 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」;
|
|
992
|
+
同日再定:「跟团队模式一样改成挂在会话上,子会话继承,不再是全局」)。
|
|
993
|
+
所以这里**没有**全局开关 —— 与团队模式同一处入口:会话页右上角 → 任务运行设置。 -->
|
|
987
994
|
<!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
|
|
988
995
|
两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
|
|
989
996
|
<button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v" id="defaultEngine">自动(首个可用)</span></button>
|
|
990
997
|
<button class="setrow" id="btnDefaultModel"><span class="setrow__l">默认模型</span><span class="setrow__v" id="defaultModel">跟随引擎</span></button>
|
|
991
|
-
|
|
998
|
+
<!-- 建议引擎 / 模型(2026-10-23 用户:「是选择引擎和模型 跟默认引擎那种抽屉一样」)。
|
|
999
|
+
两行点开同一个「引擎 → 模型」二列抽屉;**默认不开启**:引擎空 = 组件不请求
|
|
1000
|
+
/desk/suggest、不画建议、不补历史建议、托管不自动跑。选了引擎才启用。 -->
|
|
1001
|
+
<button class="setrow" id="btnSuggestEngine"><span class="setrow__l">建议引擎</span><span class="setrow__v" id="suggestEngine">不开启</span></button>
|
|
1002
|
+
<button class="setrow" id="btnSuggestModel"><span class="setrow__l">建议模型</span><span class="setrow__v" id="suggestModel">跟随引擎</span></button>
|
|
1003
|
+
<!-- 2026-10-22 用户:「触感反馈 / 语言」这两项不需要 —— 已移除(设备级偏好只剩主题/字体)。 -->
|
|
992
1004
|
<div class="setrow"><span class="setrow__l">网络代理</span><button class="tgl" id="tglVpn" aria-label="网络代理"></button></div>
|
|
993
|
-
<div class="setrow"><span class="setrow__l">语言</span><span class="setrow__v">跟随系统</span></div>
|
|
994
1005
|
<button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
|
|
995
1006
|
</div>
|
|
996
1007
|
<!-- 团队模式(2026-10-21):开关**按会话**给,放会话页右上角的运行设置里(与权限模式并排),
|
|
@@ -1037,7 +1048,8 @@ button.setrow{font-family:inherit}
|
|
|
1037
1048
|
|
|
1038
1049
|
<!-- ══════════ 会话页 → 任务运行设置(独立子页面)══════════
|
|
1039
1050
|
会话页右上角的调节键点进来(2026-10-21 用户:「运行设置在任务对话框里 不也应该是独立页面吗」)。
|
|
1040
|
-
|
|
1051
|
+
五项都是**当前这条会话**的运行参数:设备只读,其余点开还是原来的选择器(选择器只有一份)。
|
|
1052
|
+
会话托管(2026-10-23)也在这里:它跟团队模式一样**属于这条会话**,不再是「我的」里的全局开关。 -->
|
|
1041
1053
|
<div class="history" id="runSettingsPage" hidden>
|
|
1042
1054
|
<div class="history__top">
|
|
1043
1055
|
<button class="topbar__icon" id="btnRunSettingsBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
@@ -1049,35 +1061,19 @@ button.setrow{font-family:inherit}
|
|
|
1049
1061
|
<button class="setrow" id="btnRsWorkspace"><span class="setrow__l">工作空间</span><span class="setrow__v" id="rsWorkspace">默认(不属于任何空间)</span></button>
|
|
1050
1062
|
<button class="setrow" id="btnRsPermission"><span class="setrow__l">权限模式</span><span class="setrow__v" id="rsPermission">沿用默认</span></button>
|
|
1051
1063
|
<button class="setrow" id="btnRsTeam"><span class="setrow__l">团队模式</span><span class="setrow__v" id="rsTeam">关闭</span></button>
|
|
1064
|
+
<button class="setrow" id="btnRsHosting"><span class="setrow__l">会话托管</span><span class="setrow__v" id="rsHosting">关闭</span></button>
|
|
1052
1065
|
</div>
|
|
1066
|
+
<div class="history__hint" style="text-align:left">会话托管:每轮成功结束自动发送最合适的建议(最多连续 12 轮)。只作用于当前会话;团队子会话默认继承主会话的开关。</div>
|
|
1053
1067
|
</div>
|
|
1054
1068
|
</div>
|
|
1055
1069
|
|
|
1056
1070
|
<!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
二级 = 点某个模块进去,看**该空间下所有模块**的需求列表(当前模块高亮)。
|
|
1061
|
-
点一行用既有的阅读器打开那篇 markdown。
|
|
1062
|
-
⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
|
|
1063
|
-
(用户 2026-10-22 定稿)。 -->
|
|
1064
|
-
<div class="history" id="reqs" hidden>
|
|
1065
|
-
<div class="history__top">
|
|
1066
|
-
<span class="history__title">需求</span>
|
|
1067
|
-
<button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>
|
|
1068
|
-
</div>
|
|
1069
|
-
<div class="history__list" id="reqList"></div>
|
|
1070
|
-
</div>
|
|
1071
|
+
**独立子页面**:内容、样式、逻辑都在 reqs.js / reqs.css,本文件只留两个空容器
|
|
1072
|
+
(隐藏/显示由底部 tab 控制)。数据形状、两级口径见 reqs.js 文件头。 -->
|
|
1073
|
+
<div class="history" id="reqs" hidden></div>
|
|
1071
1074
|
|
|
1072
|
-
<!--
|
|
1073
|
-
<div class="history" id="reqDetail" hidden>
|
|
1074
|
-
<div class="history__top">
|
|
1075
|
-
<button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
1076
|
-
<span class="history__title" id="reqDetailTitle">需求</span>
|
|
1077
|
-
<button class="history__disconnect" id="btnReqDetailSync" type="button">整理</button>
|
|
1078
|
-
</div>
|
|
1079
|
-
<div class="history__list" id="reqDetailList"></div>
|
|
1080
|
-
</div>
|
|
1075
|
+
<!-- 需求的二级视图:只看所选模块的需求列表(从一级目录点模块进来;内容由 reqs.js 渲染) -->
|
|
1076
|
+
<div class="history" id="reqDetail" hidden></div>
|
|
1081
1077
|
|
|
1082
1078
|
<!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
|
|
1083
1079
|
<div class="history" id="discover" hidden>
|
|
@@ -1203,7 +1199,7 @@ button.setrow{font-family:inherit}
|
|
|
1203
1199
|
<nav class="tabbar" id="tabbar" hidden>
|
|
1204
1200
|
<button class="tab" type="button" data-tab="tasks" aria-label="任务"><span class="icw"><svg width="25" height="25"><use href="#i-task"/></svg></span></button>
|
|
1205
1201
|
<!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
|
|
1206
|
-
老 core 上点进来会由
|
|
1202
|
+
老 core 上点进来会由 reqs.js 的 501 分支给出明确文案,
|
|
1207
1203
|
不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
|
|
1208
1204
|
<button class="tab" type="button" data-tab="reqs" aria-label="需求"><span class="icw"><svg width="25" height="25"><use href="#i-req"/></svg></span></button>
|
|
1209
1205
|
<span class="tabfab">
|
|
@@ -1237,6 +1233,9 @@ button.setrow{font-family:inherit}
|
|
|
1237
1233
|
<script src="shared/host-glue.js"></script>
|
|
1238
1234
|
<script src="shared/conversation.js"></script>
|
|
1239
1235
|
<script src="shared/transport-http.js"></script>
|
|
1236
|
+
<!-- 需求页(独立子页面模块):只在这里加载并注册 window.MagicReqs,
|
|
1237
|
+
真正的 DOM/逻辑都在 reqs.js,主 IIFE 末尾 mount 时把宿主能力注入进去。 -->
|
|
1238
|
+
<script src="reqs.js"></script>
|
|
1240
1239
|
<script>
|
|
1241
1240
|
(function () {
|
|
1242
1241
|
'use strict';
|
|
@@ -1266,13 +1265,9 @@ button.setrow{font-family:inherit}
|
|
|
1266
1265
|
var segTheme = document.getElementById('segTheme');
|
|
1267
1266
|
var segFont = document.getElementById('segFont');
|
|
1268
1267
|
var segMode = document.getElementById('segMode');
|
|
1269
|
-
var tglHaptic = document.getElementById('tglHaptic');
|
|
1270
1268
|
var tabbarEl = document.getElementById('tabbar');
|
|
1271
1269
|
var reqsEl = document.getElementById('reqs');
|
|
1272
|
-
var reqListEl = document.getElementById('reqList');
|
|
1273
1270
|
var reqDetailEl = document.getElementById('reqDetail');
|
|
1274
|
-
var reqDetailTitle = document.getElementById('reqDetailTitle');
|
|
1275
|
-
var reqDetailListEl = document.getElementById('reqDetailList');
|
|
1276
1271
|
var discoverEl = document.getElementById('discover');
|
|
1277
1272
|
var teamPageEl = document.getElementById('teamPage');
|
|
1278
1273
|
var runSettingsPageEl = document.getElementById('runSettingsPage');
|
|
@@ -1309,6 +1304,8 @@ button.setrow{font-family:inherit}
|
|
|
1309
1304
|
/* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
|
|
1310
1305
|
提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
|
|
1311
1306
|
var RUN_PREF = loadRunPref();
|
|
1307
|
+
/* 建议引擎 / 模型(「我的」):引擎空 = 不开启建议功能(默认)。与默认引擎分开存,互不影响。 */
|
|
1308
|
+
var SUGGEST = loadSuggestPref();
|
|
1312
1309
|
var ENGINES = []; // 最近一次从 /desk/engines?models=1 拿到的清单
|
|
1313
1310
|
var ENGINES_STALE_AT = ''; // 非空 = 这份清单是插件侧的兜底缓存(探测超时/失败),值是缓存时间
|
|
1314
1311
|
/* ── 团队模式(2026-10-21)──
|
|
@@ -1432,6 +1429,87 @@ button.setrow{font-family:inherit}
|
|
|
1432
1429
|
saveTeamPref();
|
|
1433
1430
|
}
|
|
1434
1431
|
|
|
1432
|
+
/* ── 会话托管(2026-10-23,按会话)──
|
|
1433
|
+
与团队模式同一套形状:`sessions` = sid → 开/关,`fresh` = 新会话还没拿到 session_id
|
|
1434
|
+
时的临时开关。旧版 `magic.hosting` 是设备级全局值,不迁移(见上面的注释)。 */
|
|
1435
|
+
var HOSTING_KEY = 'magic.hosting.sessions.v1';
|
|
1436
|
+
function loadHostingPref() {
|
|
1437
|
+
var o = { sessions: {}, fresh: false };
|
|
1438
|
+
try {
|
|
1439
|
+
var m = JSON.parse(localStorage.getItem(HOSTING_KEY) || '{}');
|
|
1440
|
+
if (m && typeof m === 'object' && !Array.isArray(m)) {
|
|
1441
|
+
Object.keys(m).forEach(function (sid) { o.sessions[sid] = !!m[sid]; });
|
|
1442
|
+
}
|
|
1443
|
+
} catch (e) {}
|
|
1444
|
+
/* 旧版全局开关:属于「改一条会话,所有会话都变」的旧语义,清掉不迁移。 */
|
|
1445
|
+
try { localStorage.removeItem('magic.hosting'); } catch (e) {}
|
|
1446
|
+
return o;
|
|
1447
|
+
}
|
|
1448
|
+
function saveHostingPref() {
|
|
1449
|
+
/* 与团队模式同一条容量护栏:最多 400 条,超出按 key 顺序丢(只影响容量)。 */
|
|
1450
|
+
var ids = Object.keys(HOSTING.sessions || {});
|
|
1451
|
+
if (ids.length > 400) {
|
|
1452
|
+
ids.slice(0, ids.length - 400).forEach(function (sid) { delete HOSTING.sessions[sid]; });
|
|
1453
|
+
}
|
|
1454
|
+
try { localStorage.setItem(HOSTING_KEY, JSON.stringify(HOSTING.sessions || {})); } catch (e) {}
|
|
1455
|
+
schedulePushPrefs();
|
|
1456
|
+
paintRunSettings();
|
|
1457
|
+
}
|
|
1458
|
+
/* 按任意 id(session_id / run_id)找会话对象:父指针与本地表可能各用一个别名。 */
|
|
1459
|
+
function sessionByAnyId(key) {
|
|
1460
|
+
var k = String(key || '');
|
|
1461
|
+
if (!k) return null;
|
|
1462
|
+
for (var i = 0; i < LAST_SESSIONS.length; i++) {
|
|
1463
|
+
var s = LAST_SESSIONS[i];
|
|
1464
|
+
if (!s) continue;
|
|
1465
|
+
if (sidOf(s) === k || String(s.session_id || '') === k || String(s.run_id || '') === k) return s;
|
|
1466
|
+
}
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
/* 这条会话的显式记录(session_id / run_id 任一命中即算)。 */
|
|
1470
|
+
function hostingRecordOf(s) {
|
|
1471
|
+
if (!s) return null;
|
|
1472
|
+
var keys = [sidOf(s), s.session_id, s.run_id];
|
|
1473
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1474
|
+
var k = keys[i];
|
|
1475
|
+
if (k && Object.prototype.hasOwnProperty.call(HOSTING.sessions, String(k))) {
|
|
1476
|
+
return HOSTING.sessions[String(k)];
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
/* 这条会话的托管开关。自己有记录就用自己那条;没有就**沿父链继承**(用户 2026-10-23:
|
|
1482
|
+
「子会话继承」):团队子会话默认跟主会话一致,主会话改开关,没单独设过的子会话也跟着变。
|
|
1483
|
+
子会话里手动改一次 = 写下自己的记录,从此不再继承(显式覆盖 > 继承)。
|
|
1484
|
+
新会话(还没有 sid)用 `fresh`,不跨会话继承。 */
|
|
1485
|
+
function hostingOnFor(sid, s) {
|
|
1486
|
+
if (!sid) return !!HOSTING.fresh;
|
|
1487
|
+
var cur = s || sessionByAnyId(sid);
|
|
1488
|
+
var seen = {};
|
|
1489
|
+
for (var depth = 0; cur && depth < 16; depth++) {
|
|
1490
|
+
var key = sidOf(cur) || String(sid);
|
|
1491
|
+
if (seen[key]) break; // 脏数据成环:最多走 16 层并去重
|
|
1492
|
+
seen[key] = 1;
|
|
1493
|
+
var rec = hostingRecordOf(cur);
|
|
1494
|
+
if (rec !== null) return !!rec;
|
|
1495
|
+
var pk = parentKeyOf(cur);
|
|
1496
|
+
if (!pk) break;
|
|
1497
|
+
/* 父会话可能已经不在清单里(被清理 / 只给了指针),仍然认它的显式记录。 */
|
|
1498
|
+
if (Object.prototype.hasOwnProperty.call(HOSTING.sessions, String(pk))) {
|
|
1499
|
+
return !!HOSTING.sessions[String(pk)];
|
|
1500
|
+
}
|
|
1501
|
+
cur = sessionByAnyId(pk);
|
|
1502
|
+
}
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
/* 新会话的开关要等 core 给出 session_id 才落得下来(见 mountConversation 的 onEvent)。 */
|
|
1506
|
+
function bindFreshHosting(sid) {
|
|
1507
|
+
if (!HOSTING.fresh || !sid) return;
|
|
1508
|
+
HOSTING.sessions[String(sid)] = true;
|
|
1509
|
+
HOSTING.fresh = false;
|
|
1510
|
+
saveHostingPref();
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1435
1513
|
/* ══════════ 环境(多连接)══════════
|
|
1436
1514
|
2026-10-22 用户:「移动端要支持环境切换 可以有多个连接」。
|
|
1437
1515
|
一台手机可以记住多台桌面(家里 / 公司 / 另一台机器),任务页标题就是切换入口。
|
|
@@ -1643,8 +1721,9 @@ button.setrow{font-family:inherit}
|
|
|
1643
1721
|
—— 内存态全部重新初始化,不留上一台机器的影子。
|
|
1644
1722
|
⚠️ 分桶键用 host:port 而不是 url|token:token 会随 App 重启变,按 token 分桶等于每次都丢现场。 */
|
|
1645
1723
|
var ENV_SCOPE_KEYS = [
|
|
1646
|
-
'magic.ws', 'magic.taskmode', 'magic.engine', 'magic.model',
|
|
1647
|
-
'magic.
|
|
1724
|
+
'magic.ws', 'magic.taskmode', 'magic.hosting.sessions.v1', 'magic.engine', 'magic.model',
|
|
1725
|
+
'magic.suggest.engine', 'magic.suggest.model',
|
|
1726
|
+
'magic.theme', 'magic.font', 'magic.notify',
|
|
1648
1727
|
'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
|
|
1649
1728
|
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
1650
1729
|
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
|
|
@@ -1697,9 +1776,10 @@ button.setrow{font-family:inherit}
|
|
|
1697
1776
|
所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
|
|
1698
1777
|
· 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
|
|
1699
1778
|
· 本地任何一处改了,防抖推回远端。
|
|
1700
|
-
|
|
1779
|
+
纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。 */
|
|
1701
1780
|
var PREF_SYNC_KEYS = [
|
|
1702
|
-
'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
|
|
1781
|
+
'magic.engine', 'magic.model', 'magic.suggest.engine', 'magic.suggest.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
|
|
1782
|
+
'magic.hosting.sessions.v1',
|
|
1703
1783
|
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
1704
1784
|
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
|
|
1705
1785
|
];
|
|
@@ -2071,23 +2151,23 @@ button.setrow{font-family:inherit}
|
|
|
2071
2151
|
loadSessions();
|
|
2072
2152
|
}
|
|
2073
2153
|
function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
|
|
2074
|
-
/*
|
|
2154
|
+
/* 需求页:内容与逻辑在 reqs.js(独立子页面模块),这里只负责切页。
|
|
2155
|
+
进页面即拉一次清单(GET /desk/requirements,只读、不调模型);
|
|
2075
2156
|
「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
|
|
2076
2157
|
两者代价差三个数量级,绝不能混成一个动作。 */
|
|
2077
2158
|
function showReqs() {
|
|
2078
2159
|
hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
|
|
2079
|
-
/*
|
|
2080
|
-
|
|
2081
|
-
loadRequirements(false);
|
|
2160
|
+
/* 从二级退回来时不要还卡在详情态:reqs.js 的 show() 会回一级目录并拉清单。 */
|
|
2161
|
+
if (window.MagicReqs && window.MagicReqs.show) window.MagicReqs.show();
|
|
2082
2162
|
}
|
|
2083
2163
|
function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
|
|
2084
2164
|
function showMine() {
|
|
2085
2165
|
hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
|
|
2086
2166
|
mineDevice.textContent = histSubtitle || '本机';
|
|
2167
|
+
paintSuggestPref();
|
|
2087
2168
|
var ns = document.getElementById('notifyState');
|
|
2088
2169
|
if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
|
|
2089
2170
|
markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
|
|
2090
|
-
tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
|
|
2091
2171
|
paintRunPref();
|
|
2092
2172
|
refreshVpn();
|
|
2093
2173
|
/* 版本号:先用已拉到的值立刻画(列表页的 /desk/health 顺手带回来的),
|
|
@@ -2634,15 +2714,14 @@ button.setrow{font-family:inherit}
|
|
|
2634
2714
|
});
|
|
2635
2715
|
}
|
|
2636
2716
|
|
|
2637
|
-
/* ══════════ 偏好:主题 /
|
|
2717
|
+
/* ══════════ 偏好:主题 / 字体(本地持久化)══════════ */
|
|
2638
2718
|
function loadPrefs() {
|
|
2639
2719
|
try {
|
|
2640
2720
|
return {
|
|
2641
2721
|
theme: localStorage.getItem('magic.theme') || 'system',
|
|
2642
|
-
font: localStorage.getItem('magic.font') || '1'
|
|
2643
|
-
haptic: localStorage.getItem('magic.haptic') || 'on'
|
|
2722
|
+
font: localStorage.getItem('magic.font') || '1'
|
|
2644
2723
|
};
|
|
2645
|
-
} catch (e) { return { theme: 'system', font: '1'
|
|
2724
|
+
} catch (e) { return { theme: 'system', font: '1' }; }
|
|
2646
2725
|
}
|
|
2647
2726
|
function savePref(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
|
2648
2727
|
function applyThemePref(v) {
|
|
@@ -2651,24 +2730,149 @@ button.setrow{font-family:inherit}
|
|
|
2651
2730
|
}
|
|
2652
2731
|
/* 字体大小:组件尺寸是 px,改 root font-size 无效;用 body zoom 整体缩放(WebView 支持)。 */
|
|
2653
2732
|
function applyFontPref(v) { try { document.body.style.zoom = (v && v !== '1') ? v : ''; } catch (e) {} }
|
|
2654
|
-
function applyHapticPref(v) { window.MAGIC_HAPTICS = (v !== 'off'); }
|
|
2655
2733
|
/* 任务模式:parallel(默认,全部并行)| serial(同一空间同时只跑一个新任务)。 */
|
|
2656
2734
|
var TASK_MODE = 'parallel';
|
|
2657
2735
|
try { TASK_MODE = localStorage.getItem('magic.taskmode') || 'parallel'; } catch (e) {}
|
|
2736
|
+
/* 会话托管(2026-10-23):跟团队模式一样**挂在会话上**(用户:「跟团队模式一样改成
|
|
2737
|
+
挂在会话上,子会话继承,不再是全局」)。`sessions` 是 sid → 开/关;`fresh` 只临时装
|
|
2738
|
+
「新会话还没拿到 session_id 时」的开关(首帧带回 id 就落到 `sessions` 上)。
|
|
2739
|
+
旧版是全设备一个 `magic.hosting` —— 那正是「改一条会话,所有会话都变」的来源,
|
|
2740
|
+
不迁移(把全局值抄给每条会话等于把 bug 换个地方),直接清掉。 */
|
|
2741
|
+
var HOSTING = loadHostingPref();
|
|
2658
2742
|
var PREFS = loadPrefs();
|
|
2659
|
-
applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
|
|
2743
|
+
applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
|
|
2660
2744
|
paintRunPref();
|
|
2661
2745
|
paintTeamPref(); // 团队配置卡片(能力位要到连上桌面后才亮,见 loadSessions)
|
|
2662
2746
|
|
|
2663
2747
|
/* 挂 conversation:传输层由宿主注入,组件不连后端(见 CLIENT-CONTRACT.md 3.2.1)。
|
|
2664
2748
|
sid 非空 = 打开该会话(组件会走 readSession 回放历史);s = 该会话对象(标题取 prompt_head);空 = 新会话。 */
|
|
2749
|
+
/* 「同一个会话」要认 run_id ↔ session_id 的别名。
|
|
2750
|
+
会话刚起(团队任务 / 新会话首轮)时清单里只有 run_id,session_id 是 core 首帧之后
|
|
2751
|
+
才写回的;同一个会话因此会先后以两个 id 出现。只比字面会把「返回列表再点进来」
|
|
2752
|
+
判成另一条会话 —— 组件被销毁、内存队列清空,排队的追问就整条消失
|
|
2753
|
+
(2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
|
|
2754
|
+
function sameConvId(a, b) {
|
|
2755
|
+
if (!a || !b) return false;
|
|
2756
|
+
a = String(a); b = String(b);
|
|
2757
|
+
if (a === b) return true;
|
|
2758
|
+
for (var i = 0; i < LAST_SESSIONS.length; i++) {
|
|
2759
|
+
var s = LAST_SESSIONS[i];
|
|
2760
|
+
var ids = [s.session_id, s.run_id].filter(Boolean).map(String);
|
|
2761
|
+
if (ids.indexOf(a) >= 0 && ids.indexOf(b) >= 0) return true;
|
|
2762
|
+
}
|
|
2763
|
+
return false;
|
|
2764
|
+
}
|
|
2765
|
+
/* 这条会话里「已落库、还没轮到发」的条目,能接回组件的那些:
|
|
2766
|
+
· owner==='conv' —— 组件内排队(见 queuePushed),主人就是组件;
|
|
2767
|
+
· owner==='host' —— 重启恢复(restoreQueue)交给宿主的,但宿主**还没开始发**
|
|
2768
|
+
(!p.dispatching):用户这时点进会话,让组件接管更符合预期 ——
|
|
2769
|
+
否则画布上永远不显示「排队中」,只有列表角标。
|
|
2770
|
+
owner==='gate' 的绝不碰:那是串行门挡住的**当前这一轮**(气泡已由 fireTurn 画了),
|
|
2771
|
+
由 beforeAsk 的 Promise 自己放行,接管会双发。 */
|
|
2772
|
+
function pendingOfConv(sid) {
|
|
2773
|
+
return PENDING.filter(function (p) {
|
|
2774
|
+
if (p.gone || p.stalled || p.dispatching) return false;
|
|
2775
|
+
if (p.owner !== 'conv' && p.owner !== 'host') return false;
|
|
2776
|
+
return sameConvId(p.session, sid);
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
function adoptPendingFor(sid) {
|
|
2780
|
+
if (!conv || typeof conv.adoptQueue !== 'function' || !sid) return 0;
|
|
2781
|
+
/* ⚠️ 2026-10-23(用户:「排队的和自动建议发送串会话了」):`p.session` **空**时不许接。
|
|
2782
|
+
sameConvId('', sid) 这类「空串当通配」的匹配,正是把上一条会话的排队追问灌进
|
|
2783
|
+
当前会话的入口 —— 新会话首句排队那一刻 state.session 还是空的,落库就是空串。
|
|
2784
|
+
那种条目交给原主(enqueueDispatch 按 session 空 = 新会话下发),
|
|
2785
|
+
不该被任何一条**已有 id 的会话**认领。 */
|
|
2786
|
+
var list = pendingOfConv(sid).filter(function (p) { return !!p.session; });
|
|
2787
|
+
if (!list.length) return 0;
|
|
2788
|
+
var items = list.map(function (p) {
|
|
2789
|
+
/* 接管:宿主那条 enqueueDispatch 在真正 fireQueued 之前会检查 owner,
|
|
2790
|
+
看到它已归组件就放手(见 enqueueDispatch)—— 两边不会各发一次。 */
|
|
2791
|
+
p.owner = 'conv';
|
|
2792
|
+
/* 恢复出来的条目沿用**宿主持久化 id**:组件 take / drop 时回传的就是它,
|
|
2793
|
+
宿主据此删对行(QPEND 里重新登记一次,值指向自己)。 */
|
|
2794
|
+
QPEND[p.id] = p.id;
|
|
2795
|
+
return {
|
|
2796
|
+
id: p.id, prompt: p.prompt,
|
|
2797
|
+
/* 归属会话要一并交给组件:它据此在发出前再核一次(见 flushQueue),
|
|
2798
|
+
宿主这边的筛选只是第一道。 */
|
|
2799
|
+
session: p.session,
|
|
2800
|
+
attachments: (p.attachments || []).slice(),
|
|
2801
|
+
atts: (p.atts || []).slice(),
|
|
2802
|
+
hosted: !!p.hosted
|
|
2803
|
+
};
|
|
2804
|
+
});
|
|
2805
|
+
try { return conv.adoptQueue(items) || 0; } catch (e) { return 0; }
|
|
2806
|
+
}
|
|
2807
|
+
function adoptAfterHistory(sid, s) {
|
|
2808
|
+
var c = conv;
|
|
2809
|
+
if (!c) return;
|
|
2810
|
+
var p;
|
|
2811
|
+
try { p = c.setSession(sid, s); } catch (e) { p = null; }
|
|
2812
|
+
Promise.resolve(p).then(function () {
|
|
2813
|
+
/* 迟到的历史:这期间用户可能已经切走 / 又换了会话 —— 不许把旧队列灌进新画布。 */
|
|
2814
|
+
if (conv !== c || !sameConvId(sid, convSid)) return;
|
|
2815
|
+
adoptPendingFor(sid);
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
/* 会话托管的开关:**按当前这条会话**落盘(用户 2026-10-23:「跟团队模式一样改成
|
|
2819
|
+
挂在会话上,子会话继承,不再是全局」)。开关的权威值在这里,组件只执行。
|
|
2820
|
+
reason 非空 = 组件自己暂停的(失败 / 被停 / 到上限 / 用户点了「托管中」标记),
|
|
2821
|
+
这里要如实弹一句;宿主自己拨开关时由调用处给更具体的提示。 */
|
|
2822
|
+
function convSessionKey() {
|
|
2823
|
+
var raw = (conv && conv.state && conv.state.session) || convSid || '';
|
|
2824
|
+
var s = raw ? sessionByAnyId(raw) : null;
|
|
2825
|
+
return s ? (sidOf(s) || String(raw || '')) : String(raw || '');
|
|
2826
|
+
}
|
|
2827
|
+
function writeHostingFor(on) {
|
|
2828
|
+
var key = convSessionKey();
|
|
2829
|
+
if (key) {
|
|
2830
|
+
var s = sessionByAnyId(key);
|
|
2831
|
+
/* 同一会话的 run_id ↔ session_id 别名只留一条,避免两个键各说一套。 */
|
|
2832
|
+
if (s) {
|
|
2833
|
+
[s.session_id, s.run_id].forEach(function (k) {
|
|
2834
|
+
if (k && String(k) !== key) delete HOSTING.sessions[String(k)];
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
HOSTING.sessions[key] = !!on;
|
|
2838
|
+
HOSTING.fresh = false;
|
|
2839
|
+
} else {
|
|
2840
|
+
HOSTING.fresh = !!on;
|
|
2841
|
+
}
|
|
2842
|
+
saveHostingPref();
|
|
2843
|
+
}
|
|
2844
|
+
function onHostingToggleFromConv(on, reason) {
|
|
2845
|
+
writeHostingFor(!!on);
|
|
2846
|
+
if (reason) toast('会话托管已暂停:' + reason);
|
|
2847
|
+
}
|
|
2848
|
+
/* 运行设置里那一行:直接翻当前会话的开关(不弹选择器 —— 托管只有开 / 关两档)。 */
|
|
2849
|
+
function toggleHostingHere() {
|
|
2850
|
+
if (!SUGGEST.engine) { toast('先在「我的 → 建议引擎」里选一个引擎,才能开启会话托管'); return; }
|
|
2851
|
+
if (!conv) { toast('会话还没准备好'); return; }
|
|
2852
|
+
var on = typeof conv.hostingOn === 'function' ? !!conv.hostingOn() : false;
|
|
2853
|
+
var next = !on;
|
|
2854
|
+
try { conv.setHosting(next); } catch (e) {}
|
|
2855
|
+
writeHostingFor(next);
|
|
2856
|
+
toast(next
|
|
2857
|
+
? '会话托管已开启:每轮结束自动发送最合适的建议,提问卡自动选第一项继续'
|
|
2858
|
+
: '会话托管已关闭');
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2665
2861
|
function mountConversation(sid, s) {
|
|
2666
2862
|
/* 同一条会话、同一个空间:组件还活着,画面也还在 —— 直接把这一屏端回来。
|
|
2667
2863
|
以前这里无条件 destroy() + create() + innerHTML='',于是「返回列表 → 再点进来」
|
|
2668
2864
|
每次都要重拉全量历史、重新建 DOM、重新回放(长会话 = 又慢又费流量)。
|
|
2669
|
-
标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。
|
|
2670
|
-
|
|
2671
|
-
|
|
2865
|
+
标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。
|
|
2866
|
+
⚠️ 判「同一条会话」要认 run_id ↔ session_id 的别名(见 sameConvId):
|
|
2867
|
+
只比字面的话,团队任务/新会话在 session_id 回来前后拿到的是两个 id,
|
|
2868
|
+
同一屏会被判成两条会话 → 销毁重建 → 内存里排队的追问连气泡一起消失
|
|
2869
|
+
(2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
|
|
2870
|
+
if (conv && sid && WS === convWs && sameConvId(sid, convSid)) {
|
|
2871
|
+
convSid = sid; // 列表那边可能已经从 run_id 切到 session_id
|
|
2872
|
+
/* same:true —— 上面已经确认是同一屏会话。组件按字面判 id 会把别名当成
|
|
2873
|
+
「换了会话」→ clearQueue() 删掉排队追问(本次报障的病根之一)。 */
|
|
2874
|
+
try { conv.setSession(sid, s, { same: true }); } catch (e) {}
|
|
2875
|
+
if (typeof conv.setSuggestEngine === 'function') conv.setSuggestEngine(SUGGEST.engine, SUGGEST.model);
|
|
2672
2876
|
window.magicConv = conv;
|
|
2673
2877
|
return;
|
|
2674
2878
|
}
|
|
@@ -2677,14 +2881,18 @@ button.setrow{font-family:inherit}
|
|
|
2677
2881
|
convSid = sid || null;
|
|
2678
2882
|
convWs = WS;
|
|
2679
2883
|
/* 每次重建组件都丢掉「上一条还没落定的新会话」的临时开关 —— 否则用户在新会话里
|
|
2680
|
-
|
|
2681
|
-
老会话的开关由 teamOnFor 从它自己那份记录里取;新会话从「关」起。 */
|
|
2884
|
+
开了团队 / 托管但没发消息,转去打开一条历史会话时,那条会话的首帧会把开关误记成开启。
|
|
2885
|
+
老会话的开关由 teamOnFor / hostingOnFor 从它自己那份记录里取;新会话从「关」起。 */
|
|
2682
2886
|
TEAM.fresh = false;
|
|
2887
|
+
HOSTING.fresh = false;
|
|
2683
2888
|
try {
|
|
2684
2889
|
var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN, client: 'mobile' });
|
|
2685
2890
|
conv = window.MagicConversation.create({
|
|
2686
2891
|
el: convEl, transport: transport, titleEl: titleEl, workspace: WS,
|
|
2687
2892
|
engine: RUN_PREF.engine, model: RUN_PREF.model,
|
|
2893
|
+
/* 建议功能默认关闭:只有「我的 → 建议引擎」里显式选了引擎才传非空值;
|
|
2894
|
+
模型为空 = 引擎默认(core 对 llm 会自动挑最快变体)。 */
|
|
2895
|
+
suggestEngine: SUGGEST.engine, suggestModel: SUGGEST.model,
|
|
2688
2896
|
showPermission: false, showAttach: true, beforeAsk: gateAsk,
|
|
2689
2897
|
onInterrupt: cancelWaitingAsk,
|
|
2690
2898
|
/* 组件内排队也要落库(2026-10-21 用户:「非团队的串行排队也没落库」):
|
|
@@ -2695,6 +2903,10 @@ button.setrow{font-family:inherit}
|
|
|
2695
2903
|
onQueueTake: queueTaken,
|
|
2696
2904
|
onQueueDrop: queueDropped,
|
|
2697
2905
|
onPick: rememberRunPick,
|
|
2906
|
+
/* 会话托管:开关初值按**这条会话**给(自己没有记录就沿父链继承,见 hostingOnFor);
|
|
2907
|
+
组件自己暂停时回调这里把这条会话的开关关掉。 */
|
|
2908
|
+
hosting: hostingOnFor(sid, s),
|
|
2909
|
+
onHostingChange: onHostingToggleFromConv,
|
|
2698
2910
|
/* 团队模式:开关初值按**这条会话**给;配置是设备偏好(每轮现读,改配置立刻生效)。
|
|
2699
2911
|
features 也给函数:能力清单是异步拉到的(/desk/health),
|
|
2700
2912
|
查到之后开关才会亮出来,不必重新挂载组件。 */
|
|
@@ -2711,9 +2923,12 @@ button.setrow{font-family:inherit}
|
|
|
2711
2923
|
};
|
|
2712
2924
|
},
|
|
2713
2925
|
features: function () { return CORE_FEATURES; },
|
|
2714
|
-
/* 新会话的开关先记在 TEAM.fresh:core 的首帧带回 session_id
|
|
2715
|
-
|
|
2716
|
-
onEvent: function (ev) {
|
|
2926
|
+
/* 新会话的开关先记在 TEAM.fresh / HOSTING.fresh:core 的首帧带回 session_id 时,
|
|
2927
|
+
当场绑到这条会话上(否则返回列表再点进来就找不到它了)。 */
|
|
2928
|
+
onEvent: function (ev) {
|
|
2929
|
+
bindFreshTeam(ev && ev.session_id);
|
|
2930
|
+
bindFreshHosting(ev && ev.session_id);
|
|
2931
|
+
},
|
|
2717
2932
|
/* 菜单里的「定时任务」还在,但底部那个 tab 已经删了(2026-10-22 用户:
|
|
2718
2933
|
「定时任务tab先删除」)。现在点它给一句说明,而不是跳去一张只有一个占位符的页 ——
|
|
2719
2934
|
原先那页的正文就是这句说明,页面本身没有承载任何数据。
|
|
@@ -2726,7 +2941,10 @@ button.setrow{font-family:inherit}
|
|
|
2726
2941
|
}
|
|
2727
2942
|
}
|
|
2728
2943
|
});
|
|
2729
|
-
|
|
2944
|
+
/* 历史画完再把落库的排队条目接回画布(见 adoptPendingFor):抢在回放之前插气泡,
|
|
2945
|
+
会被组件的 clearThread 一起清掉;而且那一刻「这一轮还在不在跑」已经由
|
|
2946
|
+
loadHistory / follow 落定,flush 的时机才是对的。 */
|
|
2947
|
+
if (sid) adoptAfterHistory(sid, s);
|
|
2730
2948
|
window.magicConv = conv;
|
|
2731
2949
|
} catch (e) {
|
|
2732
2950
|
var d = document.createElement('div');
|
|
@@ -2735,6 +2953,39 @@ button.setrow{font-family:inherit}
|
|
|
2735
2953
|
convEl.appendChild(d);
|
|
2736
2954
|
}
|
|
2737
2955
|
}
|
|
2956
|
+
/* ── 进详情时补拉这一条的状态(2026-10-23 用户:「白色会话进会话详情时应该再拉一波更新状态」)──
|
|
2957
|
+
列表里的 status 是**进列表那一刻**的快照;而 core 的 `--sessions` 读侧还会把「记录写着
|
|
2958
|
+
在跑、进程其实已经不在」的行当场归一成 gone。用户点开一条**没有颜色**的会话(既不是
|
|
2959
|
+
运行中 / 等审批,也不是已完成 / 失败 / 已取消)时,多半就是这份快照已经过期。
|
|
2960
|
+
这里只补这一条:拉一次登记表、按 id 别名(session_id ↔ run_id)对上就地更新,
|
|
2961
|
+
状态真的变了才重绘。失败静默 —— 它是补刀,不是主路径。 */
|
|
2962
|
+
function refreshRowStatus(sid) {
|
|
2963
|
+
if (!sid || !TOKEN) return;
|
|
2964
|
+
var seq = CONN_SEQ;
|
|
2965
|
+
fetch(BASE + '/desk/sessions', { headers: { 'X-Magic-Token': TOKEN } })
|
|
2966
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
2967
|
+
.then(function (list) {
|
|
2968
|
+
if (seq !== CONN_SEQ || !Array.isArray(list)) return;
|
|
2969
|
+
var row = null, cur = null, i;
|
|
2970
|
+
for (i = 0; i < list.length; i++) {
|
|
2971
|
+
var x = list[i];
|
|
2972
|
+
if (!x) continue;
|
|
2973
|
+
if (sameConvId(x.session_id, sid) || sameConvId(x.run_id, sid)) { row = x; break; }
|
|
2974
|
+
}
|
|
2975
|
+
if (!row) return;
|
|
2976
|
+
for (i = 0; i < LAST_SESSIONS.length; i++) {
|
|
2977
|
+
if (sameConvId(sidOf(LAST_SESSIONS[i]), sid)) { cur = LAST_SESSIONS[i]; break; }
|
|
2978
|
+
}
|
|
2979
|
+
if (!cur) return;
|
|
2980
|
+
var was = statusOf(cur);
|
|
2981
|
+
if (row.status) cur.status = row.status;
|
|
2982
|
+
if (row.state) cur.state = row.state;
|
|
2983
|
+
cur.alive = row.alive === true;
|
|
2984
|
+
if (row.updated_at) cur.updated_at = row.updated_at;
|
|
2985
|
+
if (statusOf(cur) !== was) renderSessions(LAST_SESSIONS);
|
|
2986
|
+
})
|
|
2987
|
+
.catch(function () { /* 拉不到就保持原样 */ });
|
|
2988
|
+
}
|
|
2738
2989
|
function openSession(sid, s) {
|
|
2739
2990
|
/* ⚠️ 一律以会话自己的归属为准,**不能**只在其有 workspace 时才改 WS(2026-10-21):
|
|
2740
2991
|
原写法 `if (s && s.workspace) { WS = s.workspace; }` 会让「打开一条默认空间的会话」
|
|
@@ -2744,6 +2995,9 @@ button.setrow{font-family:inherit}
|
|
|
2744
2995
|
saveWs();
|
|
2745
2996
|
showSession(s && s.workspace ? basename(s.workspace) : histSubtitle);
|
|
2746
2997
|
mountConversation(sid, s);
|
|
2998
|
+
/* 灰白档 = 状态未知(或列表快照过期):进详情时补拉一次,见 refreshRowStatus。
|
|
2999
|
+
其它档(运行中 / 等审批 / 已完成 / 失败 / 已取消)列表已经说清楚了,不必再打一发。 */
|
|
3000
|
+
if (statusOf(s) === 'idle') refreshRowStatus(sid);
|
|
2747
3001
|
}
|
|
2748
3002
|
/* ws:不传 = 默认(不属于任何空间);传字符串 = 在该空间里新建(列表里空间组的「+」)。
|
|
2749
3003
|
⚠️ 「新建任务」大按钮必须从**默认**起(2026-10-21 用户:「大的新建会话 空间应该是
|
|
@@ -2906,7 +3160,9 @@ button.setrow{font-family:inherit}
|
|
|
2906
3160
|
});
|
|
2907
3161
|
}
|
|
2908
3162
|
/* ── 会话列表:**置顶 / 任务 / 空间** 三段(对齐截图)──
|
|
2909
|
-
置顶:本地 pin
|
|
3163
|
+
置顶:本地 pin(长按会话行切换)+ **运行中自动临时置顶**(2026-10-22:跑着的那条
|
|
3164
|
+
自己进「置顶」段,不写 PINNED,跑完自动归位;见 isAutoPinned);
|
|
3165
|
+
任务:最近会话(默认前 8,可「展开更多」);
|
|
2910
3166
|
空间:会话里出现过的 workspace(点行=按空间筛选任务,+=在该空间新建)。 */
|
|
2911
3167
|
var LAST_SESSIONS = [];
|
|
2912
3168
|
var SEC_COLLAPSED = {};
|
|
@@ -2960,6 +3216,10 @@ button.setrow{font-family:inherit}
|
|
|
2960
3216
|
return !(pk && pk !== sidOf(s));
|
|
2961
3217
|
});
|
|
2962
3218
|
}
|
|
3219
|
+
/* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。
|
|
3220
|
+
任务页 / 需求页共用(需求页已拆到 reqs.js,那边自带一份同名实现;这里留着
|
|
3221
|
+
是因为 isRealSpaceDir / isTeamRunDir 还服务于任务页的空间分组)。 */
|
|
3222
|
+
function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
|
|
2963
3223
|
/* 一个目录算不算「空间」:得是**用户认得的工作区**。
|
|
2964
3224
|
根目录 / 不是空间(哪怕历史上有会话在那儿跑过)—— 需求页显示它只会让人
|
|
2965
3225
|
以为「/ 是个项目」(用户 2026-10-22:「没有空间的不需要」)。
|
|
@@ -3045,6 +3305,30 @@ button.setrow{font-family:inherit}
|
|
|
3045
3305
|
|
|
3046
3306
|
function sidOf(s) { return s.session_id || s.run_id; }
|
|
3047
3307
|
function isPinned(s) { return PINNED.indexOf(sidOf(s)) >= 0; }
|
|
3308
|
+
/* 运行中会话**自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
|
|
3309
|
+
判据只看当前是不是在跑(`statusOf === 'run'`),**不写 PINNED、不落 prefs** ——
|
|
3310
|
+
跑完下一次 renderSessions 自然归位,用户自己的手动置顶关系一点不动。
|
|
3311
|
+
子会话在跑也算:子行默认折叠在父块里,只提子行的话首屏根本看不见,
|
|
3312
|
+
所以把父块一起提到「置顶」段(父行本身不跑时不画图钉,计数徽标会亮运行色,见 sessionRow)。
|
|
3313
|
+
⚠️ 与手动置顶分开判:手动置顶仍只认 PINNED,自动的只在排序 / 分段时叠加。 */
|
|
3314
|
+
function isAutoPinned(s) {
|
|
3315
|
+
if (!s) return false;
|
|
3316
|
+
if (statusOf(s) === 'run') return true;
|
|
3317
|
+
var kids = CHILD_OF[sidOf(s)] || [];
|
|
3318
|
+
for (var i = 0; i < kids.length; i++) { if (statusOf(kids[i]) === 'run') return true; }
|
|
3319
|
+
return false;
|
|
3320
|
+
}
|
|
3321
|
+
/* 列表分段用:手动置顶 ∪ 运行中自动置顶。 */
|
|
3322
|
+
function isListPinned(s) { return isPinned(s) || isAutoPinned(s); }
|
|
3323
|
+
/* 置顶段 / 搜索结果的排序:**人工置顶在前 → 运行中自动置顶 → 普通**,同类按 updated_at 倒序。
|
|
3324
|
+
用户 2026-10-22 定稿:「应该在人工置顶的后面」—— 自动置顶只是临时把在跑的活提上来,
|
|
3325
|
+
不能压过用户自己钉的会话;跑完 isAutoPinned 变 false,它回到普通分段。 */
|
|
3326
|
+
function pinnedOrder(a, b) {
|
|
3327
|
+
var ra = isPinned(a) ? 2 : (isAutoPinned(a) ? 1 : 0);
|
|
3328
|
+
var rb = isPinned(b) ? 2 : (isAutoPinned(b) ? 1 : 0);
|
|
3329
|
+
return (rb - ra)
|
|
3330
|
+
|| String(b.updated_at || b.started_at || '').localeCompare(String(a.updated_at || a.started_at || ''));
|
|
3331
|
+
}
|
|
3048
3332
|
function togglePin(s) {
|
|
3049
3333
|
var id = sidOf(s), i = PINNED.indexOf(id);
|
|
3050
3334
|
if (i >= 0) PINNED.splice(i, 1); else PINNED.unshift(id);
|
|
@@ -3090,10 +3374,46 @@ button.setrow{font-family:inherit}
|
|
|
3090
3374
|
.then(function () { TITLES[id] = name; renderSessions(LAST_SESSIONS); })
|
|
3091
3375
|
.catch(function (e) { window.alert('重命名失败:' + (e && e.message ? e.message : e)); });
|
|
3092
3376
|
}
|
|
3093
|
-
/*
|
|
3377
|
+
/* 删除一棵会话树:删主会话时,挂在它下面的**子任务会话**也要从本地列表一起消失。
|
|
3378
|
+
服务端(session.Delete)已经级联删了登记记录 / 日志 / 标题 / 状态;这里只是不让
|
|
3379
|
+
用户在看到下一次轮询刷新之前,还点得进那些已经不存在了的子行。
|
|
3380
|
+
认亲按 run_id / session_id 双向别名折算(与 renderSessions 的 KEY2SID 同口径):
|
|
3381
|
+
子会话创建时主会话可能还没拿到 session_id,parent 指针里存的是 run_id。 */
|
|
3382
|
+
function deleteClosureOf(id, list) {
|
|
3383
|
+
var del = {};
|
|
3384
|
+
del[id] = 1;
|
|
3385
|
+
function mark(s) {
|
|
3386
|
+
if (!s) return false;
|
|
3387
|
+
var sid = sidOf(s);
|
|
3388
|
+
if (!sid) return false;
|
|
3389
|
+
var changed = !del[sid] || (s.run_id && !del[s.run_id]) || (s.session_id && !del[s.session_id]);
|
|
3390
|
+
del[sid] = 1;
|
|
3391
|
+
if (s.run_id) del[s.run_id] = 1;
|
|
3392
|
+
if (s.session_id) del[s.session_id] = 1;
|
|
3393
|
+
return changed;
|
|
3394
|
+
}
|
|
3395
|
+
(list || []).forEach(function (s) { if (del[sidOf(s)]) mark(s); });
|
|
3396
|
+
var changed = true;
|
|
3397
|
+
while (changed) {
|
|
3398
|
+
changed = false;
|
|
3399
|
+
(list || []).forEach(function (s) {
|
|
3400
|
+
var sid = sidOf(s);
|
|
3401
|
+
if (!sid || del[sid]) return;
|
|
3402
|
+
var pk = parentKeyOf(s);
|
|
3403
|
+
if (pk && del[pk]) changed = mark(s) || changed;
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
3406
|
+
return del;
|
|
3407
|
+
}
|
|
3408
|
+
/* 删除:DELETE /desk/session/{id}(登记表记录 + 事件日志 + 子任务会话),然后刷新列表。 */
|
|
3094
3409
|
function deleteSessionRow(s) {
|
|
3095
3410
|
var id = sidOf(s);
|
|
3096
|
-
|
|
3411
|
+
/* 文案如实说明「会连子任务一起删」:这是物理删除,提示里不能只提主会话。 */
|
|
3412
|
+
var kidCount = (CHILD_OF[id] || []).length;
|
|
3413
|
+
var ask = kidCount
|
|
3414
|
+
? '删除这个会话及其 ' + kidCount + ' 个子任务会话?此操作不可恢复。'
|
|
3415
|
+
: '删除这个会话?此操作不可恢复。';
|
|
3416
|
+
if (!window.confirm(ask)) return;
|
|
3097
3417
|
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3098
3418
|
fetch(BASE + '/desk/session/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr })
|
|
3099
3419
|
.then(function (r) {
|
|
@@ -3101,10 +3421,11 @@ button.setrow{font-family:inherit}
|
|
|
3101
3421
|
return r.json().catch(function () { return {}; });
|
|
3102
3422
|
})
|
|
3103
3423
|
.then(function () {
|
|
3104
|
-
var
|
|
3105
|
-
|
|
3424
|
+
var del = deleteClosureOf(id, LAST_SESSIONS);
|
|
3425
|
+
PINNED = PINNED.filter(function (pid) { return !del[pid]; }); savePinned();
|
|
3426
|
+
Object.keys(TITLES).forEach(function (tid) { if (del[tid]) delete TITLES[tid]; });
|
|
3106
3427
|
/* 本地移除 + 静默重绘:不走 loadSessions(那会先塞「读取中…」再重建 = 整页闪) */
|
|
3107
|
-
LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return sidOf(x)
|
|
3428
|
+
LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return !del[sidOf(x)]; });
|
|
3108
3429
|
renderSessions(LAST_SESSIONS);
|
|
3109
3430
|
})
|
|
3110
3431
|
.catch(function (e) { window.alert('删除失败:' + (e && e.message ? e.message : e)); });
|
|
@@ -3128,12 +3449,24 @@ button.setrow{font-family:inherit}
|
|
|
3128
3449
|
return h;
|
|
3129
3450
|
}
|
|
3130
3451
|
/* 会话状态:由 /desk/sessions 的 status(会话级统一状态,见 core 的 session-status.json)
|
|
3131
|
-
映射;老版本 CLI 不给 status 时回落 state。
|
|
3452
|
+
映射;老版本 CLI 不给 status 时回落 state。
|
|
3453
|
+
词表与 core 的 session.Record.State 一一对应(不再是「除了三种都算灰色」):
|
|
3454
|
+
running / alive → run 蓝 + 扫光环
|
|
3455
|
+
waiting_approval → wait 琥珀(活着的等待)
|
|
3456
|
+
done / idle → done 绿(idle = 常驻会话本轮跑完、进程还在等追加,
|
|
3457
|
+
与 PC 左栏的「已完成」同口径)
|
|
3458
|
+
failed / gone → fail 红(gone = 记录写着在跑、进程已不在)
|
|
3459
|
+
cancelled / interrupted → stop 石褐(人为收工 / 被打断,不是故障)
|
|
3460
|
+
其它(空 / 陌生值) → idle 灰白兜底
|
|
3461
|
+
⚠️ 2026-10-23 之前 failed / cancelled / gone 全落进 idle,列表上是一片白 ——
|
|
3462
|
+
用户分不清「正常结束」和「挂了」(报障:「这两个会话为什么是白色」)。 */
|
|
3132
3463
|
function statusOf(s) {
|
|
3133
3464
|
var st = String((s && (s.status || s.state)) || '');
|
|
3134
3465
|
if (st === 'running' || s.alive === true) return 'run';
|
|
3135
3466
|
if (st === 'waiting_approval') return 'wait';
|
|
3136
|
-
if (st === 'done') return 'done';
|
|
3467
|
+
if (st === 'done' || st === 'idle') return 'done';
|
|
3468
|
+
if (st === 'failed' || st === 'gone') return 'fail';
|
|
3469
|
+
if (st === 'cancelled' || st === 'interrupted' || st === 'stopped') return 'stop';
|
|
3137
3470
|
return 'idle';
|
|
3138
3471
|
}
|
|
3139
3472
|
/* 会话行图标:团队会话=双人图形(mode:"team"),其余**设备形状按来源端**
|
|
@@ -3215,20 +3548,30 @@ button.setrow{font-family:inherit}
|
|
|
3215
3548
|
}
|
|
3216
3549
|
/* 子会话(团队模式拆出的子任务,2026-10-22):core 在 /desk/sessions 里给出
|
|
3217
3550
|
parent_session_id / parent_run_id(见 internal/session 的 Record)。子任务是**普通会话**
|
|
3218
|
-
(自己的登记 +
|
|
3551
|
+
(自己的登记 + 自己的日志,点进去看完整过程),列表里只做三件事:
|
|
3219
3552
|
· 不占顶层一行 —— 缩进挂在主会话下面(CHILD_OF:父 key → 子会话数组);
|
|
3553
|
+
· 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
|
|
3554
|
+
计数徽标,点它才展开;展开状态按会话 id 记在 KIDS_OPEN 里,刷新列表不收回;
|
|
3220
3555
|
· 不进「任务 / 空间」分组(它们的 workspace 是主会话里的任务目录,不该自成一个空间)。
|
|
3221
3556
|
⚠️ 父会话不在列表里(被删 / 老 core 没给父字段)→ 回落成顶层一行,
|
|
3222
3557
|
宁可多一行也不能让子会话凭空消失。
|
|
3223
3558
|
⚠️ 主会话可能还没拿到 session_id,parent_session_id 同时给了 parent_run_id 兜底。 */
|
|
3224
|
-
var CHILD_OF = {}, CHILD_IDS = {};
|
|
3559
|
+
var CHILD_OF = {}, CHILD_IDS = {}, KIDS_OPEN = {};
|
|
3225
3560
|
function parentKeyOf(s) { return (s && (s.parent_session_id || s.parent_run_id)) || ''; }
|
|
3561
|
+
function kidsOpenOf(id) { return !!KIDS_OPEN[id]; }
|
|
3562
|
+
function toggleKids(id) {
|
|
3563
|
+
if (!id) return;
|
|
3564
|
+
KIDS_OPEN[id] = !KIDS_OPEN[id];
|
|
3565
|
+
renderSessions(LAST_SESSIONS);
|
|
3566
|
+
}
|
|
3226
3567
|
function sessionBlock(s) {
|
|
3227
3568
|
var wrap = document.createElement('div');
|
|
3228
3569
|
wrap.className = 'sblock';
|
|
3229
3570
|
wrap.appendChild(s.pendingWait ? pendingRow(s) : sessionRow(s, !!CHILD_IDS[sidOf(s)]));
|
|
3230
|
-
var
|
|
3231
|
-
|
|
3571
|
+
var sid = sidOf(s);
|
|
3572
|
+
var kids = CHILD_OF[sid] || [];
|
|
3573
|
+
/* 默认不展开:只有用户点过父行上的计数徽标(KIDS_OPEN[sid])才挂子行。 */
|
|
3574
|
+
if (kids.length && kidsOpenOf(sid)) {
|
|
3232
3575
|
var box = document.createElement('div');
|
|
3233
3576
|
box.className = 'sblock__kids';
|
|
3234
3577
|
kids.forEach(function (k) { box.appendChild(sessionRow(k, true)); });
|
|
@@ -3252,6 +3595,49 @@ button.setrow{font-family:inherit}
|
|
|
3252
3595
|
col.appendChild(subEl);
|
|
3253
3596
|
/* 排队与团队标记都不在这里做:两者都做在图标里(见 sessionIcon),行尾不再挂角标。 */
|
|
3254
3597
|
b.appendChild(col);
|
|
3598
|
+
/* 子会话计数徽标(默认折叠开关):点一下展开 / 收回,**不触发打开会话**。
|
|
3599
|
+
没有子会话的普通行不画它,保持列表干净;有子任务在跑时亮运行色 + 圆点,
|
|
3600
|
+
这样折叠状态下也能看出「父会话下面还有活在跑」。 */
|
|
3601
|
+
var kids = CHILD_OF[sid] || [];
|
|
3602
|
+
if (!sub && kids.length) {
|
|
3603
|
+
var kidsRunning = false;
|
|
3604
|
+
for (var ki = 0; ki < kids.length; ki++) {
|
|
3605
|
+
if (statusOf(kids[ki]) === 'run') { kidsRunning = true; break; }
|
|
3606
|
+
}
|
|
3607
|
+
var opened = kidsOpenOf(sid);
|
|
3608
|
+
var chip = elSpan('srow__kids' + (opened ? ' is-open' : '') + (kidsRunning ? ' is-run' : ''));
|
|
3609
|
+
chip.setAttribute('role', 'button');
|
|
3610
|
+
chip.setAttribute('tabindex', '0');
|
|
3611
|
+
chip.setAttribute('aria-expanded', opened ? 'true' : 'false');
|
|
3612
|
+
chip.setAttribute('aria-label', (opened ? '收起 ' : '展开 ') + kids.length + ' 个子会话');
|
|
3613
|
+
chip.title = kidsRunning
|
|
3614
|
+
? kids.length + ' 个子会话 · 有任务在跑(点' + (opened ? '收起' : '展开') + ')'
|
|
3615
|
+
: kids.length + ' 个子会话(点' + (opened ? '收起' : '展开') + ')';
|
|
3616
|
+
if (kidsRunning) chip.appendChild(elSpan('srow__kids-dot'));
|
|
3617
|
+
chip.appendChild(elSpan('srow__kids-n', kids.length + ' 子会话'));
|
|
3618
|
+
chip.appendChild(svgUse('#i-chev-r', 12));
|
|
3619
|
+
var toggleKid = function (e) {
|
|
3620
|
+
if (e) { e.preventDefault(); e.stopPropagation(); }
|
|
3621
|
+
toggleKids(sid);
|
|
3622
|
+
};
|
|
3623
|
+
/* 父行有长按菜单与点击打开:徽标上的指针 / 点击事件都要截住,别让它们穿到父行。 */
|
|
3624
|
+
chip.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
|
|
3625
|
+
chip.addEventListener('pointerup', function (e) { e.stopPropagation(); });
|
|
3626
|
+
chip.addEventListener('click', toggleKid);
|
|
3627
|
+
chip.addEventListener('keydown', function (e) {
|
|
3628
|
+
if (e.key === 'Enter' || e.key === ' ') toggleKid(e);
|
|
3629
|
+
});
|
|
3630
|
+
b.appendChild(chip);
|
|
3631
|
+
}
|
|
3632
|
+
/* 运行中自动置顶的行带一枚小图钉(手动置顶由「置顶」分段说明,不重复画):
|
|
3633
|
+
只有**这行自己**在跑才画 —— 父行因某个子会话在跑而被提上来时,行上不画图钉
|
|
3634
|
+
(计数徽标会亮运行色),否则会误读成「父会话在跑」。 */
|
|
3635
|
+
if (run && !isPinned(s)) {
|
|
3636
|
+
var autoPin = elSpan('srow__auto');
|
|
3637
|
+
autoPin.title = '运行中 · 自动临时置顶(跑完自动归位)';
|
|
3638
|
+
autoPin.appendChild(svgUse('#i-pin', 14));
|
|
3639
|
+
b.appendChild(autoPin);
|
|
3640
|
+
}
|
|
3255
3641
|
var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
|
|
3256
3642
|
b.appendChild(timeEl);
|
|
3257
3643
|
if (run) { RUN_ROWS[sid] = { subEl: subEl, timeEl: timeEl, s: s }; refreshProgress(sid); }
|
|
@@ -3373,7 +3759,10 @@ button.setrow{font-family:inherit}
|
|
|
3373
3759
|
|
|
3374
3760
|
/* 搜索态:不分组、不出「分组视图」行,命中项平铺(对齐截图 2)。 */
|
|
3375
3761
|
if (searching) {
|
|
3376
|
-
var matched = query ? rows.filter(taskMatches) : rows;
|
|
3762
|
+
var matched = (query ? rows.filter(taskMatches) : rows).slice();
|
|
3763
|
+
/* 与分组列表同一口径:运行中的自动临时置顶排最前(搜索是临时态,跑完同样归位)。 */
|
|
3764
|
+
/* 与分组列表同一口径:人工置顶在前,运行中的自动临时置顶紧随其后(搜索是临时态,跑完归位)。 */
|
|
3765
|
+
matched.sort(pinnedOrder);
|
|
3377
3766
|
if (!matched.length && query) {
|
|
3378
3767
|
var miss = document.createElement('div'); miss.className = 'history__hint';
|
|
3379
3768
|
miss.textContent = '没有匹配「' + TASK_QUERY + '」的任务。';
|
|
@@ -3389,9 +3778,11 @@ button.setrow{font-family:inherit}
|
|
|
3389
3778
|
return;
|
|
3390
3779
|
}
|
|
3391
3780
|
|
|
3392
|
-
/*
|
|
3393
|
-
|
|
3394
|
-
var
|
|
3781
|
+
/* 置顶:手动 pin + **运行中自动临时置顶**(不写 PINNED,跑完归位);
|
|
3782
|
+
只在这里显示,置顶项不再出现在「任务 / 空间」里(去重)。 */
|
|
3783
|
+
var pinned = rows.filter(isListPinned);
|
|
3784
|
+
var rest = rows.filter(function (s) { return !isListPinned(s); });
|
|
3785
|
+
pinned.sort(pinnedOrder);
|
|
3395
3786
|
|
|
3396
3787
|
/* 任务:**不属于任何空间**的普通任务(且未置顶)+ 默认空间(不指定)的串行等待任务。
|
|
3397
3788
|
显式选了空间的等待任务归到下面的「空间」组(用户 2026-10-20:「串行等待中的任务
|
|
@@ -3605,451 +3996,6 @@ button.setrow{font-family:inherit}
|
|
|
3605
3996
|
if (e.key === 'Escape' && readerEl && !readerEl.hidden) { e.preventDefault(); closeReader(); }
|
|
3606
3997
|
}, true);
|
|
3607
3998
|
|
|
3608
|
-
/* ══════════ 需求页(会话自动聚合成的功能点文档)══════════
|
|
3609
|
-
数据形状见 CLIENT-CONTRACT §3.4.1:{ok, root, workspaces[{id,dir,name,pending,memory,
|
|
3610
|
-
modules:[{name,features:[{module,name,file,desc,points,conclusions,conclusion,
|
|
3611
|
-
sources,has_final,final_preview,updated_at}]}],feature_count,last_consolidated_at,last_error}]}
|
|
3612
|
-
|
|
3613
|
-
三个口径(照抄契约,别自己发明):
|
|
3614
|
-
· 列表只读:`loadRequirements` 只打 GET /desk/requirements,**不触发归纳**;
|
|
3615
|
-
· 「整理」才归纳:POST /desk/requirements/consolidate(可能跑几十秒,按钮态要变);
|
|
3616
|
-
· 一篇文档就是一个绝对路径:点行直接进既有阅读器(markdown,不另做渲染)。 */
|
|
3617
|
-
var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
|
|
3618
|
-
var REQ_BUSY = false; // 「整理」进行中(防重复点击)
|
|
3619
|
-
var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
|
|
3620
|
-
var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
|
|
3621
|
-
var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
|
|
3622
|
-
var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
|
|
3623
|
-
/* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。 */
|
|
3624
|
-
function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
|
|
3625
|
-
|
|
3626
|
-
function reqFeatureRow(f) {
|
|
3627
|
-
/* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
|
|
3628
|
-
标题(功能点名称)→ 归纳结论 / 需求描述 → 「N 需求点 · M 结论 · 来源 K」。
|
|
3629
|
-
为什么优先结论:这一页要回答的是「这个功能定了什么」,不是「记录了多少条」。 */
|
|
3630
|
-
var b = hEl('button', 'srow srow--req');
|
|
3631
|
-
b.type = 'button';
|
|
3632
|
-
var ico = hEl('span', 'srow__ico srow__ico--idle');
|
|
3633
|
-
ico.appendChild(svgUse('#i-doc', 20));
|
|
3634
|
-
b.appendChild(ico);
|
|
3635
|
-
var col = hEl('span', 'srow__col');
|
|
3636
|
-
col.appendChild(hEl('span', 'srow__t', f.name || '(未命名)'));
|
|
3637
|
-
/* 先结论,再一句话描述,最后才是定稿预览。定稿预览是 markdown(可能带 `**目标**:`),
|
|
3638
|
-
直接塞进这一行会把星号原样显示出来;有 desc 时它更适合当副标题。 */
|
|
3639
|
-
var sub = f.conclusion || f.desc || f.final_preview || '';
|
|
3640
|
-
if (sub) col.appendChild(hEl('span', 'srow__s', sub));
|
|
3641
|
-
var meta = [];
|
|
3642
|
-
if (f.points) meta.push(f.points + ' 需求点');
|
|
3643
|
-
if (f.conclusions) meta.push(f.conclusions + ' 结论');
|
|
3644
|
-
if (f.sources) meta.push('来源 ' + f.sources);
|
|
3645
|
-
if (!f.has_final) meta.push('未定稿');
|
|
3646
|
-
if (meta.length) col.appendChild(hEl('span', 'srow__s', meta.join(' · ')));
|
|
3647
|
-
b.appendChild(col);
|
|
3648
|
-
if (f.updated_at) {
|
|
3649
|
-
var d = new Date(Number(f.updated_at) || 0);
|
|
3650
|
-
if (!isNaN(d.getTime())) {
|
|
3651
|
-
b.appendChild(hEl('span', 'srow__time',
|
|
3652
|
-
(d.getMonth() + 1) + '-' + ('0' + d.getDate()).slice(-2)));
|
|
3653
|
-
}
|
|
3654
|
-
}
|
|
3655
|
-
b.onclick = function () { openReader(f.file); };
|
|
3656
|
-
return b;
|
|
3657
|
-
}
|
|
3658
|
-
|
|
3659
|
-
/* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
|
|
3660
|
-
一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
|
|
3661
|
-
结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
|
|
3662
|
-
idx = 排序后的位次(0 = 需求条数最多的那个)。 */
|
|
3663
|
-
function reqWorkspaceBlock(w, idx) {
|
|
3664
|
-
var wrap = hEl('div', '');
|
|
3665
|
-
var head = hEl('div', 'wsrow__head');
|
|
3666
|
-
var key = w.id || w.dir || w.name || '';
|
|
3667
|
-
var count = Number(w.feature_count) || 0;
|
|
3668
|
-
/* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
|
|
3669
|
-
空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
|
|
3670
|
-
用户手动点过谁就按用户的来(REQ_OPEN 里有记录就不动它)。 */
|
|
3671
|
-
var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
|
|
3672
|
-
var tog = document.createElement('button');
|
|
3673
|
-
tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
|
|
3674
|
-
tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
|
|
3675
|
-
/* 模块数 + 功能点数:一行说清这个空间有多大,比只写空间名有用。
|
|
3676
|
-
用**中性色**(不是 wsrow__badge 那枚 queued 紫)—— 那是「等待中」的语义色,
|
|
3677
|
-
空间规模不是状态,套上会让人误以为这个空间在排队。 */
|
|
3678
|
-
var mods = (w.modules || []).filter(function (m) { return (m.features || []).length; });
|
|
3679
|
-
tog.appendChild(elSpan('wsrow__meta', mods.length + ' 模块 · ' + count + ' 条'));
|
|
3680
|
-
/* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
|
|
3681
|
-
if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
|
|
3682
|
-
/* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
|
|
3683
|
-
此时不提示 —— 不能把「不知道」画成「未初始化」。 */
|
|
3684
|
-
if (w.initialized === false) tog.appendChild(elSpan('wsrow__badge wsrow__badge--init', '未初始化'));
|
|
3685
|
-
var chev = elSpan('wsrow__chev' + (open ? ' is-open' : ''));
|
|
3686
|
-
chev.appendChild(svgUse('#i-chev-r', 16));
|
|
3687
|
-
tog.appendChild(chev);
|
|
3688
|
-
tog.onclick = function () { REQ_OPEN[key] = !open; renderRequirements(); };
|
|
3689
|
-
head.appendChild(tog);
|
|
3690
|
-
/* 组头右侧的「整理」:只作用于这个空间(初始化它 / 归纳它的新会话)。 */
|
|
3691
|
-
var sync = document.createElement('button');
|
|
3692
|
-
sync.type = 'button'; sync.className = 'wsrow__sync'; sync.textContent = '整理';
|
|
3693
|
-
sync.setAttribute('data-ws', key);
|
|
3694
|
-
sync.onclick = function (ev) {
|
|
3695
|
-
if (ev && ev.stopPropagation) ev.stopPropagation();
|
|
3696
|
-
consolidateRequirements(w);
|
|
3697
|
-
};
|
|
3698
|
-
head.appendChild(sync);
|
|
3699
|
-
wrap.appendChild(head);
|
|
3700
|
-
if (!open) return wrap;
|
|
3701
|
-
|
|
3702
|
-
if (w.last_error) {
|
|
3703
|
-
var err = hEl('div', 'history__hint', '上次整理失败:' + w.last_error);
|
|
3704
|
-
err.style.color = 'var(--warning)';
|
|
3705
|
-
wrap.appendChild(err);
|
|
3706
|
-
}
|
|
3707
|
-
var shown = 0;
|
|
3708
|
-
(w.modules || []).forEach(function (m) {
|
|
3709
|
-
var fs = (m.features || []);
|
|
3710
|
-
if (!fs.length) return;
|
|
3711
|
-
shown += fs.length;
|
|
3712
|
-
/* 模块 = 目录里的一行(缩进 + 连接线归属到空间),点它进二级看需求列表。
|
|
3713
|
-
未归类(name 为空)的老数据没有模块名可写 —— 这种情况直接不画这一行,
|
|
3714
|
-
功能点仍能在二级里看到(core 把空 module 排在最后)。 */
|
|
3715
|
-
if (!m.name) return;
|
|
3716
|
-
var row = document.createElement('button');
|
|
3717
|
-
row.type = 'button'; row.className = 'reqtree__mod';
|
|
3718
|
-
row.appendChild(elSpan('reqtree__modname', m.name));
|
|
3719
|
-
row.appendChild(elSpan('reqtree__modcount', String(fs.length)));
|
|
3720
|
-
var go = elSpan('reqtree__modgo');
|
|
3721
|
-
go.appendChild(svgUse('#i-chev-r', 16));
|
|
3722
|
-
row.appendChild(go);
|
|
3723
|
-
row.onclick = function () { openReqDetail(w, m.name); };
|
|
3724
|
-
wrap.appendChild(row);
|
|
3725
|
-
});
|
|
3726
|
-
/* 空间还在但还没有文档:别画成一片空白,给出这一组的下一步(整理)。 */
|
|
3727
|
-
if (!shown) {
|
|
3728
|
-
var tip = Number(w.pending) > 0
|
|
3729
|
-
? '有 ' + w.pending + ' 条对话待整理'
|
|
3730
|
-
: (w.initialized === false ? '还没有需求文档,点右侧「整理」按空间生成' : '还没有需求文档');
|
|
3731
|
-
var hint = hEl('div', 'history__hint', tip);
|
|
3732
|
-
hint.style.padding = '6px 12px 12px';
|
|
3733
|
-
wrap.appendChild(hint);
|
|
3734
|
-
}
|
|
3735
|
-
return wrap;
|
|
3736
|
-
}
|
|
3737
|
-
|
|
3738
|
-
/* 二级:某空间下**所有模块**的需求列表(用户 2026-10-22 定稿)。
|
|
3739
|
-
进来的那个模块高亮,滚到它那儿;其余模块照常列出来 —— 需求之间有交叉,
|
|
3740
|
-
只看当前模块会漏掉相关需求。 */
|
|
3741
|
-
function reqDetailBlock(d) {
|
|
3742
|
-
var w = d.ws;
|
|
3743
|
-
var frag = document.createDocumentFragment();
|
|
3744
|
-
(w.modules || []).forEach(function (m) {
|
|
3745
|
-
var fs = (m.features || []);
|
|
3746
|
-
if (!fs.length) return;
|
|
3747
|
-
var sec = hEl('div', 'reqdetail__sec' + (m.name === d.mod ? ' reqdetail__sec--cur' : ''));
|
|
3748
|
-
var mh = hEl('div', 'sechead');
|
|
3749
|
-
mh.style.paddingLeft = '16px';
|
|
3750
|
-
mh.appendChild(elSpan('sechead__t', (m.name || '未归类') + ' (' + fs.length + ')'));
|
|
3751
|
-
sec.appendChild(mh);
|
|
3752
|
-
fs.forEach(function (f) { sec.appendChild(reqFeatureRow(f)); });
|
|
3753
|
-
frag.appendChild(sec);
|
|
3754
|
-
});
|
|
3755
|
-
if (!frag.childNodes.length) {
|
|
3756
|
-
frag.appendChild(hEl('div', 'history__hint', '这个空间还没有需求文档'));
|
|
3757
|
-
}
|
|
3758
|
-
return frag;
|
|
3759
|
-
}
|
|
3760
|
-
|
|
3761
|
-
/* 进二级:记下是哪个空间的哪个模块,渲染后滚到那个模块。 */
|
|
3762
|
-
function openReqDetail(w, mod) {
|
|
3763
|
-
var key = w.id || w.dir || w.name || '';
|
|
3764
|
-
REQ_DETAIL = { key: key, mod: mod, ws: w };
|
|
3765
|
-
renderRequirements();
|
|
3766
|
-
/* 空间在两次渲染之间被删掉了(会话全删 → 空间联动消失):
|
|
3767
|
-
renderRequirements 会把 REQ_DETAIL 清空,这时别停在空白的二级页,退回目录。 */
|
|
3768
|
-
if (!REQ_DETAIL) { hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; return; }
|
|
3769
|
-
hideAll(); reqDetailEl.hidden = false; tabbarEl.hidden = true;
|
|
3770
|
-
if (reqDetailTitle) reqDetailTitle.textContent = (w.name || w.dir || w.id) + ' · ' + mod;
|
|
3771
|
-
/* 滚到当前模块:它高亮,但用户进来第一眼要看到的是自己点的那一块。 */
|
|
3772
|
-
if (reqDetailListEl) {
|
|
3773
|
-
var cur = reqDetailListEl.querySelector('.reqdetail__sec--cur');
|
|
3774
|
-
if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'start' });
|
|
3775
|
-
}
|
|
3776
|
-
}
|
|
3777
|
-
|
|
3778
|
-
/* 退二级回一级目录。 */
|
|
3779
|
-
function closeReqDetail() {
|
|
3780
|
-
REQ_DETAIL = null;
|
|
3781
|
-
hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false;
|
|
3782
|
-
renderRequirements();
|
|
3783
|
-
}
|
|
3784
|
-
|
|
3785
|
-
function renderRequirements() {
|
|
3786
|
-
if (!reqListEl) return;
|
|
3787
|
-
var data = REQ_DATA;
|
|
3788
|
-
if (!data) return;
|
|
3789
|
-
/* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
|
|
3790
|
-
(用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
|
|
3791
|
-
也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
|
|
3792
|
-
这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
|
|
3793
|
-
拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
|
|
3794
|
-
var all = (data && data.workspaces) || [];
|
|
3795
|
-
var list = all.filter(function (w) {
|
|
3796
|
-
var dir = reqNormDir(w && w.dir);
|
|
3797
|
-
/* 根目录 / 归一后是空串 —— 「!dir 就放行」的老写法会把它漏进来,
|
|
3798
|
-
需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
|
|
3799
|
-
这里先卡一道:不是真空间目录的直接不画。 */
|
|
3800
|
-
if (!isRealSpaceDir(dir)) return false;
|
|
3801
|
-
return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
|
|
3802
|
-
});
|
|
3803
|
-
/* 排序:**需求条数多的排前面**(用户 2026-10-22)。
|
|
3804
|
-
core 给的顺序是「最后归纳时间倒序」,但那是给「最近在动的」用的;
|
|
3805
|
-
需求页要回答的是「哪块需求最多」,条数多的才是用户要先看的那个。
|
|
3806
|
-
条数一样时按名字排 —— 同一条数下顺序要稳定,不能每次刷新都在跳。 */
|
|
3807
|
-
list.sort(function (a, b) {
|
|
3808
|
-
var ca = Number(a && a.feature_count) || 0;
|
|
3809
|
-
var cb = Number(b && b.feature_count) || 0;
|
|
3810
|
-
if (ca !== cb) return cb - ca;
|
|
3811
|
-
return String((a && a.name) || '').localeCompare(String((b && b.name) || ''));
|
|
3812
|
-
});
|
|
3813
|
-
/* 二级视图:只画这个空间的需求列表。空间可能已被会话删除联动掉
|
|
3814
|
-
(list 里没有它)—— 那就退回一级目录,不要留一页空白。 */
|
|
3815
|
-
if (REQ_DETAIL) {
|
|
3816
|
-
var hit = null;
|
|
3817
|
-
for (var i = 0; i < list.length; i++) {
|
|
3818
|
-
if ((list[i].id || list[i].dir || list[i].name || '') === REQ_DETAIL.key) { hit = list[i]; break; }
|
|
3819
|
-
}
|
|
3820
|
-
if (!hit) { REQ_DETAIL = null; }
|
|
3821
|
-
else {
|
|
3822
|
-
REQ_DETAIL.ws = hit;
|
|
3823
|
-
if (reqDetailTitle) reqDetailTitle.textContent = (hit.name || hit.dir || hit.id) + ' · ' + REQ_DETAIL.mod;
|
|
3824
|
-
if (reqDetailListEl) reqDetailListEl.replaceChildren(reqDetailBlock(REQ_DETAIL));
|
|
3825
|
-
if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
|
|
3826
|
-
return;
|
|
3827
|
-
}
|
|
3828
|
-
}
|
|
3829
|
-
var frag = document.createDocumentFragment();
|
|
3830
|
-
if (!list.length) {
|
|
3831
|
-
/* 一个空间都没有(会话全删了 / 还没聊过)。 */
|
|
3832
|
-
frag.appendChild(hEl('div', 'history__hint',
|
|
3833
|
-
REQ_ALIVE
|
|
3834
|
-
? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
|
|
3835
|
-
: '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
|
|
3836
|
-
reqListEl.replaceChildren(frag);
|
|
3837
|
-
return;
|
|
3838
|
-
}
|
|
3839
|
-
/* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
|
|
3840
|
-
见 reqWorkspaceBlock 的 open 判定。 */
|
|
3841
|
-
list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
|
|
3842
|
-
reqListEl.replaceChildren(frag);
|
|
3843
|
-
/* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
|
|
3844
|
-
if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
|
|
3845
|
-
}
|
|
3846
|
-
|
|
3847
|
-
function loadRequirements(quiet) {
|
|
3848
|
-
if (!reqListEl) return;
|
|
3849
|
-
if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
3850
|
-
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3851
|
-
var pReq = fetch(BASE + '/desk/requirements', { headers: hdr })
|
|
3852
|
-
.then(function (r) {
|
|
3853
|
-
if (r.status === 501) {
|
|
3854
|
-
/* 老 core 没有这个能力位:**说清楚**,不要给一页空白
|
|
3855
|
-
(空白看起来像「没有需求」,是最难查的一种)。 */
|
|
3856
|
-
return r.json().then(function (e) { throw new Error((e && e.error) || '当前 core 不支持需求聚合'); });
|
|
3857
|
-
}
|
|
3858
|
-
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3859
|
-
return r.json();
|
|
3860
|
-
});
|
|
3861
|
-
/* 会话清单与空间目录:用来判断「这个空间在任务页还在不在」。
|
|
3862
|
-
两者都允许失败 —— 拿不到就不筛空间(见 renderRequirements)。 */
|
|
3863
|
-
var pSess = fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
3864
|
-
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
|
3865
|
-
var pDirs = fetch(BASE + '/desk/dirs', { headers: hdr })
|
|
3866
|
-
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
|
3867
|
-
Promise.all([pReq, pSess, pDirs])
|
|
3868
|
-
.then(function (a) {
|
|
3869
|
-
var sess = a[1], dirs = (a[2] && a[2].dirs) || null;
|
|
3870
|
-
if (Array.isArray(sess) || Array.isArray(dirs)) {
|
|
3871
|
-
/* 与任务页**同一套空间键**(用户 2026-10-22:「4个空间需求里显示的不一样」
|
|
3872
|
-
「会话中全部删除后需求中的空间还在 也要联动删除」「没有空间的不需要」):
|
|
3873
|
-
① 根目录下的 magic-* ② 自定义空间 ③ **顶层会话**的工作目录。
|
|
3874
|
-
⚠️ ③ 只能取**顶层**会话的 workspace:团队子代理的会话挂在父会话下面,
|
|
3875
|
-
它们的工作目录是 /…/run-xxx/a1 这种临时目录 —— 任务页不把它们列成空间,
|
|
3876
|
-
需求页照收就会多出一堆 a1/t1/tmp 假空间。
|
|
3877
|
-
⚠️ 根目录 / 与 /tmp 这类系统目录不是空间,哪怕历史上有会话在那儿跑过。 */
|
|
3878
|
-
var alive = {};
|
|
3879
|
-
(ROOT_DIRS || []).forEach(function (d) { if (d && d.path) alive[reqNormDir(d.path)] = 1; });
|
|
3880
|
-
(dirs || []).forEach(function (d) {
|
|
3881
|
-
if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[reqNormDir(d.path)] = 1;
|
|
3882
|
-
});
|
|
3883
|
-
(CUSTOM_SPACES || []).forEach(function (p) { if (p) alive[reqNormDir(p)] = 1; });
|
|
3884
|
-
topLevelSessions(sess).forEach(function (s) {
|
|
3885
|
-
if (s && s.workspace && isRealSpaceDir(s.workspace)) alive[reqNormDir(s.workspace)] = 1;
|
|
3886
|
-
});
|
|
3887
|
-
REQ_ALIVE = alive;
|
|
3888
|
-
} else {
|
|
3889
|
-
REQ_ALIVE = null; // 清单没拿到:不筛,宁可多显示
|
|
3890
|
-
}
|
|
3891
|
-
REQ_DATA = a[0] || { workspaces: [] };
|
|
3892
|
-
renderRequirements();
|
|
3893
|
-
})
|
|
3894
|
-
.catch(function (e) {
|
|
3895
|
-
if (quiet && REQ_DATA) return; // 后台刷新失败不覆盖已有画面
|
|
3896
|
-
reqListEl.innerHTML = '';
|
|
3897
|
-
reqListEl.appendChild(hEl('div', 'history__hint', '读取需求失败:' + ((e && e.message) || e)));
|
|
3898
|
-
});
|
|
3899
|
-
}
|
|
3900
|
-
|
|
3901
|
-
/* 「整理」:手动触发一轮(POST /desk/requirements/consolidate)。
|
|
3902
|
-
服务端按 state 判断走哪条路:
|
|
3903
|
-
· 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
|
|
3904
|
-
· 已初始化 → 只消费上次整理之后的新会话。
|
|
3905
|
-
入口有两个:
|
|
3906
|
-
· 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
|
|
3907
|
-
· 右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
|
|
3908
|
-
两个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
|
|
3909
|
-
function reqPostConsolidate(ws) {
|
|
3910
|
-
var hdr = { 'Content-Type': 'application/json' };
|
|
3911
|
-
if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
|
|
3912
|
-
return fetch(BASE + '/desk/requirements/consolidate', {
|
|
3913
|
-
method: 'POST', headers: hdr,
|
|
3914
|
-
body: JSON.stringify({
|
|
3915
|
-
workspace: ws || '',
|
|
3916
|
-
engine: RUN_PREF.engine || '',
|
|
3917
|
-
model: (RUN_PREF.engine && RUN_PREF.model) || ''
|
|
3918
|
-
})
|
|
3919
|
-
})
|
|
3920
|
-
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
|
3921
|
-
.then(function (x) {
|
|
3922
|
-
var d = x.d || {};
|
|
3923
|
-
if (!x.ok) throw new Error(d.error || ('HTTP ' + (x.d && x.d.status)));
|
|
3924
|
-
if (d.error) throw new Error(d.error);
|
|
3925
|
-
return d;
|
|
3926
|
-
});
|
|
3927
|
-
}
|
|
3928
|
-
|
|
3929
|
-
/* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
|
|
3930
|
-
label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
|
|
3931
|
-
二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
|
|
3932
|
-
function reqSetBusy(on, label) {
|
|
3933
|
-
if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
|
|
3934
|
-
else REQ_BUSY_LABEL = '全部整理';
|
|
3935
|
-
var btn = document.getElementById('btnReqSync');
|
|
3936
|
-
if (btn) {
|
|
3937
|
-
btn.disabled = !!on;
|
|
3938
|
-
if (label !== undefined) btn.textContent = label;
|
|
3939
|
-
}
|
|
3940
|
-
var dbtn = document.getElementById('btnReqDetailSync');
|
|
3941
|
-
if (dbtn) {
|
|
3942
|
-
dbtn.disabled = !!on;
|
|
3943
|
-
dbtn.textContent = on ? '整理中…' : '整理';
|
|
3944
|
-
}
|
|
3945
|
-
Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
|
|
3946
|
-
b.disabled = !!on;
|
|
3947
|
-
b.textContent = on ? '整理中…' : '整理';
|
|
3948
|
-
});
|
|
3949
|
-
}
|
|
3950
|
-
|
|
3951
|
-
function consolidateRequirements(target) {
|
|
3952
|
-
if (REQ_BUSY || !target) return;
|
|
3953
|
-
var ws = target.dir || target.id || '';
|
|
3954
|
-
if (!ws) return;
|
|
3955
|
-
var firstTime = target.initialized === false;
|
|
3956
|
-
REQ_BUSY = true;
|
|
3957
|
-
reqSetBusy(true, firstTime ? '初始化中…' : '整理中…');
|
|
3958
|
-
var targetKey = target.id || target.dir || '';
|
|
3959
|
-
var groupBtn = null;
|
|
3960
|
-
Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
|
|
3961
|
-
if (!groupBtn && targetKey && b.getAttribute('data-ws') === targetKey) groupBtn = b;
|
|
3962
|
-
});
|
|
3963
|
-
/* 点的是哪个空间,就只让哪个组头显示「初始化中…」;其它组头保持「整理中…」。 */
|
|
3964
|
-
if (groupBtn) groupBtn.textContent = firstTime ? '初始化中…' : '整理中…';
|
|
3965
|
-
reqPostConsolidate(ws)
|
|
3966
|
-
.then(function (d) {
|
|
3967
|
-
if (d.bootstrap) {
|
|
3968
|
-
if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
|
|
3969
|
-
else toast('已按空间目录初始化:暂未识别到功能点');
|
|
3970
|
-
}
|
|
3971
|
-
else if (d.noop) toast('没有新的对话需要整理');
|
|
3972
|
-
else if (d.updated || d.created) toast('整理完成:更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
|
|
3973
|
-
else toast('整理完成');
|
|
3974
|
-
loadRequirements(true); // 后台刷新(不闪「读取中」)
|
|
3975
|
-
})
|
|
3976
|
-
.catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
|
|
3977
|
-
.then(function () {
|
|
3978
|
-
REQ_BUSY = false;
|
|
3979
|
-
reqSetBusy(false, '全部整理');
|
|
3980
|
-
});
|
|
3981
|
-
}
|
|
3982
|
-
|
|
3983
|
-
/* 全部整理:按空间串行跑,避免多个初始化同时读目录把机器拖垮。
|
|
3984
|
-
顺序 = 有待整理的 → 未初始化的 → 其它(最近动过的仍在前),先做便宜/该做的。
|
|
3985
|
-
每个空间单独一次 HTTP(各自 10 分钟超时);某个失败不影响后面的。 */
|
|
3986
|
-
function reqAllWorkspaces() {
|
|
3987
|
-
var seen = {};
|
|
3988
|
-
var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
|
|
3989
|
-
var key = w.dir || w.id || '';
|
|
3990
|
-
if (!key || seen[key]) return false;
|
|
3991
|
-
/* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
|
|
3992
|
-
var dir = reqNormDir(w.dir);
|
|
3993
|
-
if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
|
|
3994
|
-
seen[key] = true;
|
|
3995
|
-
return true;
|
|
3996
|
-
});
|
|
3997
|
-
function rank(w) {
|
|
3998
|
-
if (Number(w.pending) > 0) return 0;
|
|
3999
|
-
if (w.initialized === false) return 1;
|
|
4000
|
-
return 2;
|
|
4001
|
-
}
|
|
4002
|
-
return list.sort(function (a, b) { return rank(a) - rank(b); });
|
|
4003
|
-
}
|
|
4004
|
-
|
|
4005
|
-
function consolidateAllRequirements() {
|
|
4006
|
-
if (REQ_BUSY) return;
|
|
4007
|
-
var list = reqAllWorkspaces();
|
|
4008
|
-
if (!list.length) {
|
|
4009
|
-
toast('还没有可整理的空间');
|
|
4010
|
-
return;
|
|
4011
|
-
}
|
|
4012
|
-
REQ_BUSY = true;
|
|
4013
|
-
reqSetBusy(true, '整理中 0/' + list.length);
|
|
4014
|
-
var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
|
|
4015
|
-
function done() {
|
|
4016
|
-
REQ_BUSY = false;
|
|
4017
|
-
reqSetBusy(false, '全部整理');
|
|
4018
|
-
loadRequirements(true);
|
|
4019
|
-
if (fail) {
|
|
4020
|
-
toast('全部整理完成:成功 ' + ok + ' · 失败 ' + fail + (firstErr ? '(' + firstErr + ')' : ''));
|
|
4021
|
-
} else {
|
|
4022
|
-
toast('全部整理完成:' + ok + ' 个空间 · 新建 ' + created + ' · 更新 ' + updated);
|
|
4023
|
-
}
|
|
4024
|
-
}
|
|
4025
|
-
function step() {
|
|
4026
|
-
if (i >= list.length) { done(); return; }
|
|
4027
|
-
var w = list[i++];
|
|
4028
|
-
REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
|
|
4029
|
-
var btn = document.getElementById('btnReqSync');
|
|
4030
|
-
if (btn) btn.textContent = REQ_BUSY_LABEL;
|
|
4031
|
-
reqPostConsolidate(w.dir || w.id || '')
|
|
4032
|
-
.then(function (d) {
|
|
4033
|
-
ok++;
|
|
4034
|
-
created += Number(d.created) || 0;
|
|
4035
|
-
updated += Number(d.updated) || 0;
|
|
4036
|
-
})
|
|
4037
|
-
.catch(function (e) {
|
|
4038
|
-
fail++;
|
|
4039
|
-
if (!firstErr) firstErr = (e && e.message) || String(e);
|
|
4040
|
-
})
|
|
4041
|
-
.then(step);
|
|
4042
|
-
}
|
|
4043
|
-
step();
|
|
4044
|
-
}
|
|
4045
|
-
on('btnReqSync', function () { consolidateAllRequirements(); });
|
|
4046
|
-
/* 二级视图的两个动作:返回一级目录 / 整理**当前这个空间**(不是全部空间 ——
|
|
4047
|
-
二级页的作用域就是这个空间,标成「全部整理」会让人以为还是全局动作)。 */
|
|
4048
|
-
on('btnReqDetailBack', function () { closeReqDetail(); });
|
|
4049
|
-
on('btnReqDetailSync', function () {
|
|
4050
|
-
if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
|
|
4051
|
-
consolidateRequirements(REQ_DETAIL.ws);
|
|
4052
|
-
});
|
|
4053
3999
|
|
|
4054
4000
|
function openHostSheet(title, build) {
|
|
4055
4001
|
closeHostSheet();
|
|
@@ -4104,6 +4050,11 @@ button.setrow{font-family:inherit}
|
|
|
4104
4050
|
if (permBtn) permBtn.classList.toggle('setrow--danger', !!(conv && conv.permissionTier && conv.permissionTier() === 'full'));
|
|
4105
4051
|
var t = document.getElementById('rsTeam');
|
|
4106
4052
|
if (t) t.textContent = (conv && conv.teamLabel) ? conv.teamLabel() : '关闭';
|
|
4053
|
+
/* 会话托管(2026-10-23,按会话):与团队模式并列在同一张卡里。
|
|
4054
|
+
标签读**组件此刻的真实状态**(子会话可能是从父会话继承来的开启值)。 */
|
|
4055
|
+
var h = document.getElementById('rsHosting');
|
|
4056
|
+
if (h) h.textContent = !SUGGEST.engine ? '未配置建议引擎'
|
|
4057
|
+
: ((conv && typeof conv.hostingOn === 'function' && conv.hostingOn()) ? '开启' : '关闭');
|
|
4107
4058
|
/* 团队模式:与权限模式并列(用户 2026-10-21:「团队模式开关在界面右上角跟权限模式一样」)。
|
|
4108
4059
|
core 没有这个能力位时**不显示**这一行 —— 显示一个点了必然失败的开关是最坏的做法。 */
|
|
4109
4060
|
var showTeam = !conv || typeof conv.featureOn !== 'function' || conv.featureOn('feature.session.team');
|
|
@@ -4173,7 +4124,20 @@ button.setrow{font-family:inherit}
|
|
|
4173
4124
|
/* team:重发时原样还原团队配置(见 fireQueued)——
|
|
4174
4125
|
丢了它这条排队任务就会从「团队」静默降级成单任务。 */
|
|
4175
4126
|
team: info.team || null,
|
|
4127
|
+
/* hosted:托管自动发的那一轮被串行门挡住 / 重启恢复时要原样还原,
|
|
4128
|
+
否则重发出去的历史事件会退回「用户亲手发的」画法。 */
|
|
4129
|
+
hosted: !!info.hosted,
|
|
4176
4130
|
attachments: (info.attachments || []).slice(),
|
|
4131
|
+
/* atts:气泡缩略图的快照({name, remoteUrl},见 conversation.js 的 notifyQueue)。
|
|
4132
|
+
重进会话时 adoptPendingFor 用它把排队气泡原样画回来 —— 只存路径的话恢复出来会丢图。 */
|
|
4133
|
+
atts: (info.atts || []).slice(),
|
|
4134
|
+
/* owner:谁负责把这一条真正发出去。
|
|
4135
|
+
'gate' = 串行门(gateAsk,放行后由这一轮自己下发)
|
|
4136
|
+
'host' = 宿主(restoreQueue / 重启恢复,见 enqueueDispatch)
|
|
4137
|
+
'conv' = 会话组件(组件内排队,轮到它时组件自己 flush)
|
|
4138
|
+
组件被销毁重建后,它的 owner='conv' 条目还留在 PENDING 里,由
|
|
4139
|
+
adoptPendingFor 接回新实例 —— 状态只能落在条目上,不能只存在组件内存。 */
|
|
4140
|
+
owner: info.owner || 'gate',
|
|
4177
4141
|
hide: !!hide, dev: devKey(), at: Date.now() };
|
|
4178
4142
|
PENDING.push(p); saveQueue();
|
|
4179
4143
|
if (!hide) renderSessions(LAST_SESSIONS);
|
|
@@ -4194,11 +4158,12 @@ button.setrow{font-family:inherit}
|
|
|
4194
4158
|
(2026-10-21 用户:「非团队的串行排队也没落库」)。
|
|
4195
4159
|
这里把它接进同一个 PENDING:进队即写 magic.serial.queue.v1、轮到就删行、取消就清盘。
|
|
4196
4160
|
重开 App 后由 restoreQueue 直接下发(不挂会话页面,事件仍进会话日志,点开可回放)。 */
|
|
4197
|
-
var QPEND = {}; // 组件条目 id(q…)→ 本端 PENDING id(p…)
|
|
4161
|
+
var QPEND = {}; // 组件条目 id(q… / 重新接管后的 p…)→ 本端 PENDING id(p…)
|
|
4198
4162
|
function queuePushed(info) {
|
|
4199
4163
|
if (!info || !info.id || !info.prompt || QPEND[info.id]) return;
|
|
4200
4164
|
/* hide:用户此刻就在会话里,气泡已经画出来了 —— 列表那条「等待中」延后
|
|
4201
4165
|
PENDING_SHOW_MS 再出现(与 gateAsk 同一口径:空间本来就空闲时不会闪一条假排队)。 */
|
|
4166
|
+
info.owner = 'conv';
|
|
4202
4167
|
var p = addPending(wsKey(info.workspace), info, true);
|
|
4203
4168
|
QPEND[info.id] = p.id;
|
|
4204
4169
|
setTimeout(function () {
|
|
@@ -4243,7 +4208,10 @@ button.setrow{font-family:inherit}
|
|
|
4243
4208
|
session: p.session || '',
|
|
4244
4209
|
prompt: p.prompt || '', engine: p.engine || '', model: p.model || '',
|
|
4245
4210
|
permission: p.permission || '', team: p.team || null,
|
|
4246
|
-
attachments: p.attachments || [],
|
|
4211
|
+
attachments: p.attachments || [], atts: p.atts || [],
|
|
4212
|
+
/* owner 刻意不落盘:重启后所有残留条目都由 restoreQueue 交给宿主
|
|
4213
|
+
(enqueueDispatch),语义与 'host' 一致 —— 存下来反而会让「上次是
|
|
4214
|
+
组件排队」的那条永远等不到组件接管。 */
|
|
4247
4215
|
dev: p.dev, at: p.at };
|
|
4248
4216
|
});
|
|
4249
4217
|
try { localStorage.setItem(QUEUE_KEY, JSON.stringify(others.concat(mineRows))); } catch (e) {}
|
|
@@ -4330,6 +4298,35 @@ button.setrow{font-family:inherit}
|
|
|
4330
4298
|
check();
|
|
4331
4299
|
});
|
|
4332
4300
|
}
|
|
4301
|
+
/* 宿主下发的**追问**要等原会话这一轮收尾(同一条会话不许并发两轮)。
|
|
4302
|
+
与 waitForSlot 的区别:那条按**空间**串行、且只看 TASK_MODE;这条按**会话**,
|
|
4303
|
+
并行模式也生效 —— 恢复一条「排在上一轮后面的追问」时,正是它在挡住并发。
|
|
4304
|
+
sid 为空(新任务)直接放行;清单拿不到 / 会话已不在 / 等超上限也放行:
|
|
4305
|
+
排队是**保护**不是**闸门**,不能因为探测失败把用户的任务永久卡住。 */
|
|
4306
|
+
var SESSION_WAIT_MAX_MS = 30 * 60 * 1000;
|
|
4307
|
+
function waitSessionIdle(sid) {
|
|
4308
|
+
if (!sid) return Promise.resolve();
|
|
4309
|
+
var t0 = Date.now();
|
|
4310
|
+
return new Promise(function (resolve) {
|
|
4311
|
+
function check() {
|
|
4312
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
4313
|
+
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
4314
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
4315
|
+
.then(function (list) {
|
|
4316
|
+
if (!Array.isArray(list)) { resolve(); return; }
|
|
4317
|
+
var row = null;
|
|
4318
|
+
for (var i = 0; i < list.length; i++) {
|
|
4319
|
+
var s = list[i];
|
|
4320
|
+
if (sidOf(s) === sid || s.session_id === sid || s.run_id === sid) { row = s; break; }
|
|
4321
|
+
}
|
|
4322
|
+
if (!row || !serialBusy(row) || Date.now() - t0 >= SESSION_WAIT_MAX_MS) { resolve(); return; }
|
|
4323
|
+
setTimeout(check, SLOT_POLL_MS);
|
|
4324
|
+
})
|
|
4325
|
+
.catch(function () { resolve(); });
|
|
4326
|
+
}
|
|
4327
|
+
check();
|
|
4328
|
+
});
|
|
4329
|
+
}
|
|
4333
4330
|
/* 排队行的出现时机:**进队即落库**(关 App 也不能丢,这是用户报障的正题);
|
|
4334
4331
|
列表上那条「等待中」行则由**是否真的要等**决定可见性(见 gateAsk 的 markWaiting)。
|
|
4335
4332
|
这个常量只剩一处用处:组件内排队(queuePushed,同一会话上一轮还没收尾)在
|
|
@@ -4452,6 +4449,9 @@ button.setrow{font-family:inherit}
|
|
|
4452
4449
|
var list = loadQueue();
|
|
4453
4450
|
if (!list.length) return;
|
|
4454
4451
|
list.sort(function (a, b) { return (a.at || 0) - (b.at || 0); }); // 按当时进队顺序
|
|
4452
|
+
/* 重启恢复的条目一律归宿主下发:它们的主人(会话组件)已经不在了,
|
|
4453
|
+
再标成 'conv' 就没人会认领(见 adoptPendingFor 的判据)。 */
|
|
4454
|
+
list.forEach(function (p) { p.owner = 'host'; });
|
|
4455
4455
|
PENDING = list;
|
|
4456
4456
|
renderSessions(LAST_SESSIONS);
|
|
4457
4457
|
list.forEach(function (p) { enqueueDispatch(p); });
|
|
@@ -4461,11 +4461,20 @@ button.setrow{font-family:inherit}
|
|
|
4461
4461
|
var q = serialQ(ws);
|
|
4462
4462
|
/* 重新尝试(用户点「未能启动」那条):先把红字连原因一起摘掉,回到正常的排队态。 */
|
|
4463
4463
|
if (p.stalled) { p.stalled = false; p.stalledReason = ''; renderSessions(LAST_SESSIONS); }
|
|
4464
|
-
var mine = q.tail.then(function () { return waitForSlot(ws, q); })
|
|
4465
|
-
/*
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4464
|
+
var mine = q.tail.then(function () { return waitForSlot(ws, q); })
|
|
4465
|
+
/* 同一条会话同时只能跑一轮:宿主下发的「追问」必须等这一轮真的收尾。
|
|
4466
|
+
并行模式(waitForSlot 直接放行)下尤其重要 —— 否则新进程和还在跑的那一轮
|
|
4467
|
+
会同时往同一份会话日志里写,正文与思考区交错,是最难查的一类脏数据。 */
|
|
4468
|
+
.then(function () { return waitSessionIdle(p.session); })
|
|
4469
|
+
.then(function () {
|
|
4470
|
+
/* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
|
|
4471
|
+
if (!connected()) return false;
|
|
4472
|
+
/* 等待期间用户点进了这条会话,组件已经把它接管回去了(adoptPendingFor 把 owner
|
|
4473
|
+
改回 'conv')→ 让组件自己 flush,宿主这里放手。不检查的话同一条会被发两次。 */
|
|
4474
|
+
if (p.owner !== 'host') return false;
|
|
4475
|
+
p.dispatching = true; // 已交给 fireQueued:此后不许组件再接管(防双发)
|
|
4476
|
+
return fireQueued(p);
|
|
4477
|
+
});
|
|
4469
4478
|
q.tail = mine.catch(function () { return false; });
|
|
4470
4479
|
return mine.then(function (sent) {
|
|
4471
4480
|
if (!sent) return; // 没落盘:留在队列里(stalled),不删
|
|
@@ -4496,6 +4505,8 @@ button.setrow{font-family:inherit}
|
|
|
4496
4505
|
/* 团队配置是在发送那一刻快照下来的(见 addPending):重发必须带上,
|
|
4497
4506
|
否则这条排队任务会被当成普通单任务跑。 */
|
|
4498
4507
|
if (p.team) payload.team = p.team;
|
|
4508
|
+
/* 托管自动发的那一轮:重发也要把标识带上(历史回放才能继续画成「托管发送」)。 */
|
|
4509
|
+
if (p.hosted) payload.hosted = true;
|
|
4499
4510
|
if (p.attachments && p.attachments.length) payload.attachments = p.attachments;
|
|
4500
4511
|
return new Promise(function (resolve) {
|
|
4501
4512
|
var done = false, handle = null, t0 = Date.now();
|
|
@@ -4765,6 +4776,40 @@ button.setrow{font-family:inherit}
|
|
|
4765
4776
|
会话里输入框上方的级联切换也会回写这里(见 mountConversation 的 onPick)——
|
|
4766
4777
|
与 PC 端「选过一次就一直用」的语义一致,「我的」里显示的就是下次新建会用的那一组。
|
|
4767
4778
|
⚠️ 模型必须挂在引擎下:engine 为空时 model 一律清空,不留孤儿模型。 */
|
|
4779
|
+
/* 建议引擎:只认**显式配置过**的值;没有 / 空串 = 不开启建议功能(默认)。
|
|
4780
|
+
不能回落到 RUN_PREF.engine —— 那等于「配了默认编码引擎就自动有建议」,
|
|
4781
|
+
与用户「默认不配置就不开启」相反。 */
|
|
4782
|
+
function loadSuggestPref() {
|
|
4783
|
+
var o = { engine: '', model: '' };
|
|
4784
|
+
try {
|
|
4785
|
+
o.engine = String(localStorage.getItem('magic.suggest.engine') || '').trim();
|
|
4786
|
+
o.model = String(localStorage.getItem('magic.suggest.model') || '').trim();
|
|
4787
|
+
} catch (e) {}
|
|
4788
|
+
if (!o.engine) o.model = '';
|
|
4789
|
+
return o;
|
|
4790
|
+
}
|
|
4791
|
+
function saveSuggestPref() {
|
|
4792
|
+
try {
|
|
4793
|
+
localStorage.setItem('magic.suggest.engine', SUGGEST.engine || '');
|
|
4794
|
+
localStorage.setItem('magic.suggest.model', SUGGEST.model || '');
|
|
4795
|
+
} catch (e) {}
|
|
4796
|
+
schedulePushPrefs();
|
|
4797
|
+
paintSuggestPref();
|
|
4798
|
+
}
|
|
4799
|
+
function paintSuggestPref() {
|
|
4800
|
+
var e = document.getElementById('suggestEngine');
|
|
4801
|
+
var m = document.getElementById('suggestModel');
|
|
4802
|
+
if (e) e.textContent = SUGGEST.engine || '不开启';
|
|
4803
|
+
if (m) m.textContent = SUGGEST.engine ? (qaModelText(SUGGEST.model) || '引擎默认') : '跟随引擎';
|
|
4804
|
+
}
|
|
4805
|
+
function applySuggestToLive() {
|
|
4806
|
+
if (conv && typeof conv.setSuggestEngine === 'function') {
|
|
4807
|
+
conv.setSuggestEngine(SUGGEST.engine, SUGGEST.model);
|
|
4808
|
+
}
|
|
4809
|
+
paintSuggestPref();
|
|
4810
|
+
paintRunSettings();
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4768
4813
|
function loadRunPref() {
|
|
4769
4814
|
var o = { engine: '', model: '' };
|
|
4770
4815
|
try {
|
|
@@ -5140,6 +5185,67 @@ button.setrow{font-family:inherit}
|
|
|
5140
5185
|
el.style.textAlign = 'left';
|
|
5141
5186
|
return el;
|
|
5142
5187
|
}
|
|
5188
|
+
/* 「我的 → 建议引擎」:单列,首项「不开启」。不展示模型 —— 建议是纯文本短任务,
|
|
5189
|
+
选模型只会让界面更复杂;core 对 llm 会自动挑不思考的变体。 */
|
|
5190
|
+
/* 「我的 → 建议引擎 / 模型」:与默认引擎同一个二列抽屉(左引擎 · 右模型)。
|
|
5191
|
+
首项「不开启」;点左列只浏览、点右列才落定。llm 放最前,模型为空时 core 自动挑最快变体。 */
|
|
5192
|
+
function openSuggestEnginePicker() {
|
|
5193
|
+
openHostSheet('建议引擎 / 模型', function (body) {
|
|
5194
|
+
body.innerHTML = '';
|
|
5195
|
+
var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
|
|
5196
|
+
hint.style.textAlign = 'left';
|
|
5197
|
+
body.appendChild(hint);
|
|
5198
|
+
fetchEngines().then(function (list) {
|
|
5199
|
+
if (hint.parentNode) hint.remove();
|
|
5200
|
+
var stale = enginesStaleHint();
|
|
5201
|
+
if (stale) body.appendChild(stale);
|
|
5202
|
+
var avail = (list || []).filter(function (x) { return x && x.ok; });
|
|
5203
|
+
/* 推荐的放最上面:llm 是建议这条旁路最快的引擎。 */
|
|
5204
|
+
avail.sort(function (a, b) {
|
|
5205
|
+
if (a.engine === 'llm') return -1;
|
|
5206
|
+
if (b.engine === 'llm') return 1;
|
|
5207
|
+
return String(a.engine).localeCompare(String(b.engine));
|
|
5208
|
+
});
|
|
5209
|
+
if (!avail.length) {
|
|
5210
|
+
var d = hEl('div', 'history__hint',
|
|
5211
|
+
'没有可用引擎:先在这台机器上装一个(终端跑 magic-agent --engines 会给出安装命令)。');
|
|
5212
|
+
d.style.textAlign = 'left';
|
|
5213
|
+
body.appendChild(d);
|
|
5214
|
+
return;
|
|
5215
|
+
}
|
|
5216
|
+
buildEngineCascade(body, avail, {
|
|
5217
|
+
prefs: SUGGEST,
|
|
5218
|
+
pick: pickSuggestPref,
|
|
5219
|
+
offLabel: '不开启',
|
|
5220
|
+
offDesc: '不生成下一轮建议(默认)',
|
|
5221
|
+
modelDefaultDesc: '不指定模型(llm 会自动挑最快变体)',
|
|
5222
|
+
});
|
|
5223
|
+
/* 建议生成一律不思考。各引擎都走官方关闭参数;某些模型路由不支持时
|
|
5224
|
+
才在超时/报错后降级 llm,不预先吞掉用户的选择。 */
|
|
5225
|
+
var nk = hEl('div', 'history__hint',
|
|
5226
|
+
'建议生成固定关闭思考:claude --thinking disabled、codex effort none、llm no-think / reasoning_effort、codebuddy alwaysThinkingEnabled=false、dsh reasoningEffort=off;引擎失败才降级 llm。');
|
|
5227
|
+
nk.style.textAlign = 'left';
|
|
5228
|
+
body.appendChild(nk);
|
|
5229
|
+
}, function (e) {
|
|
5230
|
+
hint.textContent = '拉取引擎清单失败:' + ((e && e.message) || e);
|
|
5231
|
+
var retry = hEl('button', 'pick__citem', '重试');
|
|
5232
|
+
retry.type = 'button';
|
|
5233
|
+
retry.onclick = function () { openSuggestEnginePicker(); };
|
|
5234
|
+
body.appendChild(retry);
|
|
5235
|
+
});
|
|
5236
|
+
});
|
|
5237
|
+
}
|
|
5238
|
+
function pickSuggestPref(engine, model) {
|
|
5239
|
+
SUGGEST.engine = String(engine || '').trim();
|
|
5240
|
+
SUGGEST.model = (SUGGEST.engine && model) ? String(model).trim() : '';
|
|
5241
|
+
saveSuggestPref();
|
|
5242
|
+
applySuggestToLive();
|
|
5243
|
+
closeHostSheet();
|
|
5244
|
+
toast(SUGGEST.engine
|
|
5245
|
+
? ('建议引擎:' + SUGGEST.engine + ' · ' + (qaModelText(SUGGEST.model) || '引擎默认模型'))
|
|
5246
|
+
: '已关闭建议功能');
|
|
5247
|
+
}
|
|
5248
|
+
|
|
5143
5249
|
function openDefaultEnginePicker() {
|
|
5144
5250
|
openHostSheet('默认引擎 / 模型', function (body) {
|
|
5145
5251
|
function load() {
|
|
@@ -5172,8 +5278,16 @@ button.setrow{font-family:inherit}
|
|
|
5172
5278
|
});
|
|
5173
5279
|
}
|
|
5174
5280
|
/* 二列级联(左引擎 · 右该引擎的模型),与对话框里那套同构:
|
|
5175
|
-
点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。
|
|
5176
|
-
|
|
5281
|
+
点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。
|
|
5282
|
+
默认引擎与建议引擎共用;o 可换目标 / 落定回调 / 关闭项文案。 */
|
|
5283
|
+
function buildEngineCascade(body, avail, o) {
|
|
5284
|
+
o = o || {};
|
|
5285
|
+
var prefs = o.prefs || RUN_PREF;
|
|
5286
|
+
var pick = o.pick || pickRunPref;
|
|
5287
|
+
var offLabel = o.offLabel || '自动';
|
|
5288
|
+
var offDesc = o.offDesc || '首个可用引擎';
|
|
5289
|
+
var modelDefaultDesc = o.modelDefaultDesc || '不指定模型,由引擎自己挑';
|
|
5290
|
+
|
|
5177
5291
|
var wrap = hEl('div', 'pick__cols');
|
|
5178
5292
|
var left = hEl('div', 'pick__col');
|
|
5179
5293
|
var right = hEl('div', 'pick__col pick__col--r');
|
|
@@ -5181,24 +5295,24 @@ button.setrow{font-family:inherit}
|
|
|
5181
5295
|
body.appendChild(wrap);
|
|
5182
5296
|
|
|
5183
5297
|
var cur = avail[0];
|
|
5184
|
-
avail.forEach(function (en) { if (en.engine ===
|
|
5298
|
+
avail.forEach(function (en) { if (en.engine === prefs.engine) cur = en; });
|
|
5185
5299
|
|
|
5186
5300
|
function paint() {
|
|
5187
5301
|
left.innerHTML = ''; right.innerHTML = '';
|
|
5188
|
-
var
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
left.appendChild(
|
|
5302
|
+
var off = hEl('button', 'pick__citem' + (!prefs.engine ? ' is-on' : ''));
|
|
5303
|
+
off.type = 'button';
|
|
5304
|
+
off.appendChild(hEl('span', 'pick__cl', offLabel));
|
|
5305
|
+
off.appendChild(hEl('span', 'pick__cd', offDesc));
|
|
5306
|
+
off.onclick = function () { pick('', ''); };
|
|
5307
|
+
left.appendChild(off);
|
|
5194
5308
|
|
|
5195
5309
|
avail.forEach(function (en) {
|
|
5196
5310
|
var n = (en.models || []).length;
|
|
5197
|
-
/* is-on =
|
|
5198
|
-
|
|
5311
|
+
/* is-on = 已保存的值(浅蓝实底);is-cur = 右列此刻在浏览谁(左侧细条,无底色)——
|
|
5312
|
+
两者分开,关闭项选中时不会出现两个一样的选中块。 */
|
|
5199
5313
|
var b = hEl('button', 'pick__citem'
|
|
5200
|
-
+ (
|
|
5201
|
-
+ (en.engine === cur.engine &&
|
|
5314
|
+
+ (prefs.engine === en.engine ? ' is-on' : '')
|
|
5315
|
+
+ (en.engine === cur.engine && prefs.engine !== en.engine ? ' is-cur' : ''));
|
|
5202
5316
|
b.type = 'button';
|
|
5203
5317
|
b.appendChild(hEl('span', 'pick__cl', en.engine));
|
|
5204
5318
|
b.appendChild(hEl('span', 'pick__cd', n ? (n + ' 个模型') : '引擎默认'));
|
|
@@ -5206,20 +5320,20 @@ button.setrow{font-family:inherit}
|
|
|
5206
5320
|
left.appendChild(b);
|
|
5207
5321
|
});
|
|
5208
5322
|
|
|
5209
|
-
var d = hEl('button', 'pick__citem' + (
|
|
5323
|
+
var d = hEl('button', 'pick__citem' + (prefs.engine === cur.engine && !prefs.model ? ' is-on' : ''));
|
|
5210
5324
|
d.type = 'button';
|
|
5211
5325
|
d.appendChild(hEl('span', 'pick__cl', '引擎默认'));
|
|
5212
|
-
d.appendChild(hEl('span', 'pick__cd',
|
|
5213
|
-
d.onclick = function () {
|
|
5326
|
+
d.appendChild(hEl('span', 'pick__cd', modelDefaultDesc));
|
|
5327
|
+
d.onclick = function () { pick(cur.engine, ''); };
|
|
5214
5328
|
right.appendChild(d);
|
|
5215
5329
|
|
|
5216
5330
|
(cur.models || []).forEach(function (mm) {
|
|
5217
|
-
var b = hEl('button', 'pick__citem' + (
|
|
5331
|
+
var b = hEl('button', 'pick__citem' + (prefs.engine === cur.engine && prefs.model === mm ? ' is-on' : ''));
|
|
5218
5332
|
b.type = 'button';
|
|
5219
|
-
/* 显示剥掉 `custom-local:` 前缀(`mm` 本身是完整 id,选中时原样存进
|
|
5333
|
+
/* 显示剥掉 `custom-local:` 前缀(`mm` 本身是完整 id,选中时原样存进 prefs),
|
|
5220
5334
|
倍率挂同行右侧。 */
|
|
5221
5335
|
modelItem(b, cur.engine, mm);
|
|
5222
|
-
b.onclick = function () {
|
|
5336
|
+
b.onclick = function () { pick(cur.engine, mm); };
|
|
5223
5337
|
right.appendChild(b);
|
|
5224
5338
|
});
|
|
5225
5339
|
}
|
|
@@ -5316,11 +5430,46 @@ button.setrow{font-family:inherit}
|
|
|
5316
5430
|
/* 权限 / 团队沿用会话组件里那唯一一份选择器(浮层盖在本页之上);选完就地重画。 */
|
|
5317
5431
|
on('btnRsPermission', function () { if (conv && conv.openPermissionPicker) conv.openPermissionPicker(); });
|
|
5318
5432
|
on('btnRsTeam', function () { if (conv && conv.openTeamPicker) conv.openTeamPicker(); });
|
|
5433
|
+
/* 会话托管(2026-10-23,按会话):只有开 / 关两档,直接翻当前这条会话。 */
|
|
5434
|
+
on('btnRsHosting', toggleHostingHere);
|
|
5319
5435
|
/* 选择器由会话组件管理、没有回调通知宿主,所以用一次点击兜底重画:
|
|
5320
5436
|
本页可见时任何一次点击(包括选择器里点中一行)之后重读状态。 */
|
|
5321
5437
|
document.addEventListener('click', function () {
|
|
5322
5438
|
if (!runSettingsPageEl.hidden) paintRunSettings();
|
|
5323
5439
|
});
|
|
5440
|
+
/* 需求页(reqs.js):内容与逻辑不在本文件,这里只注入宿主能力并挂载一次。
|
|
5441
|
+
getter 形式传 BASE/TOKEN/空间清单 —— 切环境后这些值会变,不能传快照。 */
|
|
5442
|
+
if (window.MagicReqs && window.MagicReqs.mount) {
|
|
5443
|
+
window.MagicReqs.mount({
|
|
5444
|
+
rootEl: reqsEl,
|
|
5445
|
+
detailEl: reqDetailEl,
|
|
5446
|
+
hEl: hEl,
|
|
5447
|
+
elSpan: elSpan,
|
|
5448
|
+
svgUse: svgUse,
|
|
5449
|
+
toast: toast,
|
|
5450
|
+
openReader: openReader,
|
|
5451
|
+
hideAll: hideAll,
|
|
5452
|
+
setActive: setActive,
|
|
5453
|
+
showRoot: function () {
|
|
5454
|
+
hideAll();
|
|
5455
|
+
reqsEl.hidden = false;
|
|
5456
|
+
tabbarEl.hidden = false;
|
|
5457
|
+
setActive('reqs');
|
|
5458
|
+
},
|
|
5459
|
+
showDetail: function () {
|
|
5460
|
+
hideAll();
|
|
5461
|
+
reqDetailEl.hidden = false;
|
|
5462
|
+
tabbarEl.hidden = true;
|
|
5463
|
+
},
|
|
5464
|
+
getBase: function () { return BASE; },
|
|
5465
|
+
getToken: function () { return TOKEN; },
|
|
5466
|
+
getRootDirs: function () { return ROOT_DIRS; },
|
|
5467
|
+
getCustomSpaces: function () { return CUSTOM_SPACES; },
|
|
5468
|
+
getRunPref: function () { return RUN_PREF; },
|
|
5469
|
+
isRealSpaceDir: isRealSpaceDir,
|
|
5470
|
+
topLevelSessions: topLevelSessions
|
|
5471
|
+
});
|
|
5472
|
+
}
|
|
5324
5473
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
5325
5474
|
b.onclick = function () {
|
|
5326
5475
|
var t = b.getAttribute('data-tab');
|
|
@@ -5352,6 +5501,8 @@ button.setrow{font-family:inherit}
|
|
|
5352
5501
|
on('btnNotify', openNotifySettings);
|
|
5353
5502
|
on('btnDefaultEngine', openDefaultEnginePicker);
|
|
5354
5503
|
on('btnDefaultModel', openDefaultEnginePicker);
|
|
5504
|
+
on('btnSuggestEngine', openSuggestEnginePicker);
|
|
5505
|
+
on('btnSuggestModel', openSuggestEnginePicker);
|
|
5355
5506
|
/* 团队模式配置(「我的」→ 子页面):计划 + 7 个角色,行由 paintTeamRoles 动态渲染,
|
|
5356
5507
|
行的点击在渲染时绑定(见 addTeamRouteRow / addTeamRuleRow),这里只留入口与返回。 */
|
|
5357
5508
|
on('btnTeamPage', showTeamPage);
|
|
@@ -5362,11 +5513,10 @@ button.setrow{font-family:inherit}
|
|
|
5362
5513
|
if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
|
|
5363
5514
|
if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
|
|
5364
5515
|
if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
};
|
|
5516
|
+
|
|
5517
|
+
/* 会话托管(2026-10-23 起按会话):入口在会话页 → 任务运行设置(见 btnRsHosting)。
|
|
5518
|
+
「我的」里不再有全局开关 —— 曾经那枚 `tglHosting` 写的是设备级 `magic.hosting`,
|
|
5519
|
+
改一条会话会把所有会话都改掉(用户 2026-10-23:「跟团队模式一样…不再是全局」)。 */
|
|
5370
5520
|
|
|
5371
5521
|
/* ══════════ 网络代理(VPN 隧道)══════════
|
|
5372
5522
|
把整机流量经隧道送到桌面端的 SOCKS5 出口:TUN → 内核 → 桌面端 → 互联网。
|