magic-agent 2.1.5 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +103 -6
- package/client-ui/pc/app.css +3 -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 +21 -1
- package/client-ui/shared/conv/03-chat-message.js +43 -0
- 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/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +16 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +655 -87
- package/client-ui/shared/transport-http.js +16 -0
- package/client-ui-mobile/index.html +401 -553
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +507 -0
- package/cmd/magic-client/main.go +1 -1
- package/internal/cli/ask.go +6 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- 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/service.go +11 -1
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- 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 +421 -0
- package/internal/suggest/suggest_test.go +218 -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 +5 -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 逐字段对齐。 */
|
|
@@ -313,11 +316,25 @@ textarea{font:inherit}
|
|
|
313
316
|
.srow__s{display:flex;align-items:center;gap:5px;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden}
|
|
314
317
|
.srow__stext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
315
318
|
.srow__time{flex:0 0 auto;font-size:12px;color:var(--muted)}
|
|
319
|
+
/* 运行中**自动临时置顶**的小图钉(2026-10-22):让「它凭什么在最上面」有据可查。
|
|
320
|
+
手动置顶靠「置顶」分段表达,不重复画;跑完 statusOf 不再是 run,图钉随之消失。 */
|
|
321
|
+
.srow__auto{flex:0 0 auto;display:flex;align-items:center;color:var(--primary-action);opacity:.75}
|
|
316
322
|
/* 子会话(团队模式拆出的子任务,2026-10-22):**缩进挂在主会话下面**,不占顶层一行。
|
|
317
323
|
左侧一道竖线 + 小一档图标 —— 读起来是「这条主会话拆出去的活」;点开仍是它自己
|
|
318
|
-
那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。
|
|
324
|
+
那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。
|
|
325
|
+
⚠️ 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
|
|
326
|
+
计数徽标(有几个子会话 / 是否有子任务在跑),点它才展开。 */
|
|
319
327
|
.sblock{min-width:0}
|
|
320
328
|
.sblock__kids{margin:0 8px 6px 30px;padding-left:8px;border-left:2px solid var(--border)}
|
|
329
|
+
.srow__kids{flex:0 0 auto;display:inline-flex;align-items:center;gap:3px;height:20px;padding:0 5px;
|
|
330
|
+
border:1px solid var(--border);border-radius:999px;background:var(--subtle);color:var(--muted);
|
|
331
|
+
font-size:11px;font-weight:600;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none}
|
|
332
|
+
.srow__kids:active{opacity:.7}
|
|
333
|
+
.srow__kids svg{transition:transform .15s ease}
|
|
334
|
+
.srow__kids.is-open svg{transform:rotate(90deg)}
|
|
335
|
+
/* 有子任务在跑:徽标转成运行色 + 一个小圆点(折叠时也能看出「下面有活在跑」)。 */
|
|
336
|
+
.srow__kids.is-run{color:var(--running);border-color:currentColor}
|
|
337
|
+
.srow__kids-dot{width:6px;height:6px;border-radius:50%;background:var(--running)}
|
|
321
338
|
.srow--sub{padding:9px 8px 9px 4px;gap:10px}
|
|
322
339
|
.srow--sub .srow__ico{width:32px;height:32px}
|
|
323
340
|
.srow--sub .srow__ico svg{width:16px;height:16px}
|
|
@@ -365,12 +382,6 @@ textarea{font:inherit}
|
|
|
365
382
|
.wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
|
|
366
383
|
.wsrow__open:active{background:var(--subtle)}
|
|
367
384
|
.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
385
|
/* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致) */
|
|
375
386
|
.wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
|
|
376
387
|
color:var(--queued);background:var(--tint-queued);white-space:nowrap}
|
|
@@ -378,33 +389,6 @@ textarea{font:inherit}
|
|
|
378
389
|
.wsrow__chev.is-open{transform:rotate(90deg)}
|
|
379
390
|
.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
391
|
.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
392
|
/* 长按会话行的浮动菜单 */
|
|
409
393
|
.rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
|
|
410
394
|
border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
|
|
@@ -722,6 +706,9 @@ button.setrow{font-family:inherit}
|
|
|
722
706
|
.qa-todo-ico{width:14px;height:14px;border-width:1.5px}
|
|
723
707
|
.qa-todo-ico svg{width:9px;height:9px}
|
|
724
708
|
.qa-todo-meta{font-size:11px;max-width:52%}
|
|
709
|
+
.qa-todo-active{font-size:11px;max-width:56%}
|
|
710
|
+
.qa-todo-st{font-size:11.5px}
|
|
711
|
+
.qa-todo-dur{font-size:11.5px}
|
|
725
712
|
.qa-todo-toggle{width:26px;height:26px}
|
|
726
713
|
.qa-todo-toggle svg{width:14px;height:14px}
|
|
727
714
|
|
|
@@ -984,13 +971,22 @@ button.setrow{font-family:inherit}
|
|
|
984
971
|
<button class="seg__b" data-v="serial">串行</button>
|
|
985
972
|
</span>
|
|
986
973
|
</div>
|
|
974
|
+
<!-- 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行,
|
|
975
|
+
是否开启在我的 tab 中可配置」)。开关是**设备级**,落 localStorage;
|
|
976
|
+
组件执行、这里只存与显示。给一行说明,避免只看到四个字不知道会发生什么。 -->
|
|
977
|
+
<div class="setrow">
|
|
978
|
+
<span class="setrow__main">
|
|
979
|
+
<span class="setrow__l">会话托管</span>
|
|
980
|
+
<span class="setrow__sub">每轮结束自动发送最合适的建议;提问卡自动选第一项继续(最多连续 12 轮)</span>
|
|
981
|
+
</span>
|
|
982
|
+
<button class="tgl" id="tglHosting" aria-label="会话托管"></button>
|
|
983
|
+
</div>
|
|
987
984
|
<!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
|
|
988
985
|
两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
|
|
989
986
|
<button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v" id="defaultEngine">自动(首个可用)</span></button>
|
|
990
987
|
<button class="setrow" id="btnDefaultModel"><span class="setrow__l">默认模型</span><span class="setrow__v" id="defaultModel">跟随引擎</span></button>
|
|
991
|
-
|
|
988
|
+
<!-- 2026-10-22 用户:「触感反馈 / 语言」这两项不需要 —— 已移除(设备级偏好只剩主题/字体)。 -->
|
|
992
989
|
<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
990
|
<button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
|
|
995
991
|
</div>
|
|
996
992
|
<!-- 团队模式(2026-10-21):开关**按会话**给,放会话页右上角的运行设置里(与权限模式并排),
|
|
@@ -1054,30 +1050,12 @@ button.setrow{font-family:inherit}
|
|
|
1054
1050
|
</div>
|
|
1055
1051
|
|
|
1056
1052
|
<!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
|
|
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>
|
|
1053
|
+
**独立子页面**:内容、样式、逻辑都在 reqs.js / reqs.css,本文件只留两个空容器
|
|
1054
|
+
(隐藏/显示由底部 tab 控制)。数据形状、两级口径见 reqs.js 文件头。 -->
|
|
1055
|
+
<div class="history" id="reqs" hidden></div>
|
|
1071
1056
|
|
|
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>
|
|
1057
|
+
<!-- 需求的二级视图:只看所选模块的需求列表(从一级目录点模块进来;内容由 reqs.js 渲染) -->
|
|
1058
|
+
<div class="history" id="reqDetail" hidden></div>
|
|
1081
1059
|
|
|
1082
1060
|
<!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
|
|
1083
1061
|
<div class="history" id="discover" hidden>
|
|
@@ -1203,7 +1181,7 @@ button.setrow{font-family:inherit}
|
|
|
1203
1181
|
<nav class="tabbar" id="tabbar" hidden>
|
|
1204
1182
|
<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
1183
|
<!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
|
|
1206
|
-
老 core 上点进来会由
|
|
1184
|
+
老 core 上点进来会由 reqs.js 的 501 分支给出明确文案,
|
|
1207
1185
|
不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
|
|
1208
1186
|
<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
1187
|
<span class="tabfab">
|
|
@@ -1237,6 +1215,9 @@ button.setrow{font-family:inherit}
|
|
|
1237
1215
|
<script src="shared/host-glue.js"></script>
|
|
1238
1216
|
<script src="shared/conversation.js"></script>
|
|
1239
1217
|
<script src="shared/transport-http.js"></script>
|
|
1218
|
+
<!-- 需求页(独立子页面模块):只在这里加载并注册 window.MagicReqs,
|
|
1219
|
+
真正的 DOM/逻辑都在 reqs.js,主 IIFE 末尾 mount 时把宿主能力注入进去。 -->
|
|
1220
|
+
<script src="reqs.js"></script>
|
|
1240
1221
|
<script>
|
|
1241
1222
|
(function () {
|
|
1242
1223
|
'use strict';
|
|
@@ -1266,13 +1247,9 @@ button.setrow{font-family:inherit}
|
|
|
1266
1247
|
var segTheme = document.getElementById('segTheme');
|
|
1267
1248
|
var segFont = document.getElementById('segFont');
|
|
1268
1249
|
var segMode = document.getElementById('segMode');
|
|
1269
|
-
var tglHaptic = document.getElementById('tglHaptic');
|
|
1270
1250
|
var tabbarEl = document.getElementById('tabbar');
|
|
1271
1251
|
var reqsEl = document.getElementById('reqs');
|
|
1272
|
-
var reqListEl = document.getElementById('reqList');
|
|
1273
1252
|
var reqDetailEl = document.getElementById('reqDetail');
|
|
1274
|
-
var reqDetailTitle = document.getElementById('reqDetailTitle');
|
|
1275
|
-
var reqDetailListEl = document.getElementById('reqDetailList');
|
|
1276
1253
|
var discoverEl = document.getElementById('discover');
|
|
1277
1254
|
var teamPageEl = document.getElementById('teamPage');
|
|
1278
1255
|
var runSettingsPageEl = document.getElementById('runSettingsPage');
|
|
@@ -1643,8 +1620,8 @@ button.setrow{font-family:inherit}
|
|
|
1643
1620
|
—— 内存态全部重新初始化,不留上一台机器的影子。
|
|
1644
1621
|
⚠️ 分桶键用 host:port 而不是 url|token:token 会随 App 重启变,按 token 分桶等于每次都丢现场。 */
|
|
1645
1622
|
var ENV_SCOPE_KEYS = [
|
|
1646
|
-
'magic.ws', 'magic.taskmode', 'magic.engine', 'magic.model',
|
|
1647
|
-
'magic.theme', 'magic.font', 'magic.
|
|
1623
|
+
'magic.ws', 'magic.taskmode', 'magic.hosting', 'magic.engine', 'magic.model',
|
|
1624
|
+
'magic.theme', 'magic.font', 'magic.notify',
|
|
1648
1625
|
'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
|
|
1649
1626
|
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
1650
1627
|
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
|
|
@@ -1697,7 +1674,7 @@ button.setrow{font-family:inherit}
|
|
|
1697
1674
|
所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
|
|
1698
1675
|
· 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
|
|
1699
1676
|
· 本地任何一处改了,防抖推回远端。
|
|
1700
|
-
|
|
1677
|
+
纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。 */
|
|
1701
1678
|
var PREF_SYNC_KEYS = [
|
|
1702
1679
|
'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
|
|
1703
1680
|
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
@@ -2071,14 +2048,14 @@ button.setrow{font-family:inherit}
|
|
|
2071
2048
|
loadSessions();
|
|
2072
2049
|
}
|
|
2073
2050
|
function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
|
|
2074
|
-
/*
|
|
2051
|
+
/* 需求页:内容与逻辑在 reqs.js(独立子页面模块),这里只负责切页。
|
|
2052
|
+
进页面即拉一次清单(GET /desk/requirements,只读、不调模型);
|
|
2075
2053
|
「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
|
|
2076
2054
|
两者代价差三个数量级,绝不能混成一个动作。 */
|
|
2077
2055
|
function showReqs() {
|
|
2078
2056
|
hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
|
|
2079
|
-
/*
|
|
2080
|
-
|
|
2081
|
-
loadRequirements(false);
|
|
2057
|
+
/* 从二级退回来时不要还卡在详情态:reqs.js 的 show() 会回一级目录并拉清单。 */
|
|
2058
|
+
if (window.MagicReqs && window.MagicReqs.show) window.MagicReqs.show();
|
|
2082
2059
|
}
|
|
2083
2060
|
function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
|
|
2084
2061
|
function showMine() {
|
|
@@ -2087,7 +2064,6 @@ button.setrow{font-family:inherit}
|
|
|
2087
2064
|
var ns = document.getElementById('notifyState');
|
|
2088
2065
|
if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
|
|
2089
2066
|
markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
|
|
2090
|
-
tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
|
|
2091
2067
|
paintRunPref();
|
|
2092
2068
|
refreshVpn();
|
|
2093
2069
|
/* 版本号:先用已拉到的值立刻画(列表页的 /desk/health 顺手带回来的),
|
|
@@ -2634,15 +2610,14 @@ button.setrow{font-family:inherit}
|
|
|
2634
2610
|
});
|
|
2635
2611
|
}
|
|
2636
2612
|
|
|
2637
|
-
/* ══════════ 偏好:主题 /
|
|
2613
|
+
/* ══════════ 偏好:主题 / 字体(本地持久化)══════════ */
|
|
2638
2614
|
function loadPrefs() {
|
|
2639
2615
|
try {
|
|
2640
2616
|
return {
|
|
2641
2617
|
theme: localStorage.getItem('magic.theme') || 'system',
|
|
2642
|
-
font: localStorage.getItem('magic.font') || '1'
|
|
2643
|
-
haptic: localStorage.getItem('magic.haptic') || 'on'
|
|
2618
|
+
font: localStorage.getItem('magic.font') || '1'
|
|
2644
2619
|
};
|
|
2645
|
-
} catch (e) { return { theme: 'system', font: '1'
|
|
2620
|
+
} catch (e) { return { theme: 'system', font: '1' }; }
|
|
2646
2621
|
}
|
|
2647
2622
|
function savePref(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
|
2648
2623
|
function applyThemePref(v) {
|
|
@@ -2651,24 +2626,113 @@ button.setrow{font-family:inherit}
|
|
|
2651
2626
|
}
|
|
2652
2627
|
/* 字体大小:组件尺寸是 px,改 root font-size 无效;用 body zoom 整体缩放(WebView 支持)。 */
|
|
2653
2628
|
function applyFontPref(v) { try { document.body.style.zoom = (v && v !== '1') ? v : ''; } catch (e) {} }
|
|
2654
|
-
function applyHapticPref(v) { window.MAGIC_HAPTICS = (v !== 'off'); }
|
|
2655
2629
|
/* 任务模式:parallel(默认,全部并行)| serial(同一空间同时只跑一个新任务)。 */
|
|
2656
2630
|
var TASK_MODE = 'parallel';
|
|
2657
2631
|
try { TASK_MODE = localStorage.getItem('magic.taskmode') || 'parallel'; } catch (e) {}
|
|
2632
|
+
/* 会话托管(2026-10-23):默认关;跟着环境走(见 ENV_SCOPE_KEYS),
|
|
2633
|
+
同一台手机切到另一台桌面时,两台各自的托管开关互不继承。 */
|
|
2634
|
+
var HOSTING = false;
|
|
2635
|
+
try { HOSTING = localStorage.getItem('magic.hosting') === '1'; } catch (e) {}
|
|
2658
2636
|
var PREFS = loadPrefs();
|
|
2659
|
-
applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
|
|
2637
|
+
applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
|
|
2660
2638
|
paintRunPref();
|
|
2661
2639
|
paintTeamPref(); // 团队配置卡片(能力位要到连上桌面后才亮,见 loadSessions)
|
|
2662
2640
|
|
|
2663
2641
|
/* 挂 conversation:传输层由宿主注入,组件不连后端(见 CLIENT-CONTRACT.md 3.2.1)。
|
|
2664
2642
|
sid 非空 = 打开该会话(组件会走 readSession 回放历史);s = 该会话对象(标题取 prompt_head);空 = 新会话。 */
|
|
2643
|
+
/* 「同一个会话」要认 run_id ↔ session_id 的别名。
|
|
2644
|
+
会话刚起(团队任务 / 新会话首轮)时清单里只有 run_id,session_id 是 core 首帧之后
|
|
2645
|
+
才写回的;同一个会话因此会先后以两个 id 出现。只比字面会把「返回列表再点进来」
|
|
2646
|
+
判成另一条会话 —— 组件被销毁、内存队列清空,排队的追问就整条消失
|
|
2647
|
+
(2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
|
|
2648
|
+
function sameConvId(a, b) {
|
|
2649
|
+
if (!a || !b) return false;
|
|
2650
|
+
a = String(a); b = String(b);
|
|
2651
|
+
if (a === b) return true;
|
|
2652
|
+
for (var i = 0; i < LAST_SESSIONS.length; i++) {
|
|
2653
|
+
var s = LAST_SESSIONS[i];
|
|
2654
|
+
var ids = [s.session_id, s.run_id].filter(Boolean).map(String);
|
|
2655
|
+
if (ids.indexOf(a) >= 0 && ids.indexOf(b) >= 0) return true;
|
|
2656
|
+
}
|
|
2657
|
+
return false;
|
|
2658
|
+
}
|
|
2659
|
+
/* 这条会话里「已落库、还没轮到发」的条目,能接回组件的那些:
|
|
2660
|
+
· owner==='conv' —— 组件内排队(见 queuePushed),主人就是组件;
|
|
2661
|
+
· owner==='host' —— 重启恢复(restoreQueue)交给宿主的,但宿主**还没开始发**
|
|
2662
|
+
(!p.dispatching):用户这时点进会话,让组件接管更符合预期 ——
|
|
2663
|
+
否则画布上永远不显示「排队中」,只有列表角标。
|
|
2664
|
+
owner==='gate' 的绝不碰:那是串行门挡住的**当前这一轮**(气泡已由 fireTurn 画了),
|
|
2665
|
+
由 beforeAsk 的 Promise 自己放行,接管会双发。 */
|
|
2666
|
+
function pendingOfConv(sid) {
|
|
2667
|
+
return PENDING.filter(function (p) {
|
|
2668
|
+
if (p.gone || p.stalled || p.dispatching) return false;
|
|
2669
|
+
if (p.owner !== 'conv' && p.owner !== 'host') return false;
|
|
2670
|
+
return sameConvId(p.session, sid);
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
function adoptPendingFor(sid) {
|
|
2674
|
+
if (!conv || typeof conv.adoptQueue !== 'function' || !sid) return 0;
|
|
2675
|
+
/* ⚠️ 2026-10-23(用户:「排队的和自动建议发送串会话了」):`p.session` **空**时不许接。
|
|
2676
|
+
sameConvId('', sid) 这类「空串当通配」的匹配,正是把上一条会话的排队追问灌进
|
|
2677
|
+
当前会话的入口 —— 新会话首句排队那一刻 state.session 还是空的,落库就是空串。
|
|
2678
|
+
那种条目交给原主(enqueueDispatch 按 session 空 = 新会话下发),
|
|
2679
|
+
不该被任何一条**已有 id 的会话**认领。 */
|
|
2680
|
+
var list = pendingOfConv(sid).filter(function (p) { return !!p.session; });
|
|
2681
|
+
if (!list.length) return 0;
|
|
2682
|
+
var items = list.map(function (p) {
|
|
2683
|
+
/* 接管:宿主那条 enqueueDispatch 在真正 fireQueued 之前会检查 owner,
|
|
2684
|
+
看到它已归组件就放手(见 enqueueDispatch)—— 两边不会各发一次。 */
|
|
2685
|
+
p.owner = 'conv';
|
|
2686
|
+
/* 恢复出来的条目沿用**宿主持久化 id**:组件 take / drop 时回传的就是它,
|
|
2687
|
+
宿主据此删对行(QPEND 里重新登记一次,值指向自己)。 */
|
|
2688
|
+
QPEND[p.id] = p.id;
|
|
2689
|
+
return {
|
|
2690
|
+
id: p.id, prompt: p.prompt,
|
|
2691
|
+
/* 归属会话要一并交给组件:它据此在发出前再核一次(见 flushQueue),
|
|
2692
|
+
宿主这边的筛选只是第一道。 */
|
|
2693
|
+
session: p.session,
|
|
2694
|
+
attachments: (p.attachments || []).slice(),
|
|
2695
|
+
atts: (p.atts || []).slice()
|
|
2696
|
+
};
|
|
2697
|
+
});
|
|
2698
|
+
try { return conv.adoptQueue(items) || 0; } catch (e) { return 0; }
|
|
2699
|
+
}
|
|
2700
|
+
function adoptAfterHistory(sid, s) {
|
|
2701
|
+
var c = conv;
|
|
2702
|
+
if (!c) return;
|
|
2703
|
+
var p;
|
|
2704
|
+
try { p = c.setSession(sid, s); } catch (e) { p = null; }
|
|
2705
|
+
Promise.resolve(p).then(function () {
|
|
2706
|
+
/* 迟到的历史:这期间用户可能已经切走 / 又换了会话 —— 不许把旧队列灌进新画布。 */
|
|
2707
|
+
if (conv !== c || !sameConvId(sid, convSid)) return;
|
|
2708
|
+
adoptPendingFor(sid);
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
/* 会话托管的开关落盘 + 同步「我的」里那枚 toggle。
|
|
2712
|
+
reason 非空 = 组件自己暂停的(失败 / 被停 / 到上限 / 用户点了「托管中」标记),
|
|
2713
|
+
这里要如实弹一句;宿主自己拨开关时传空串,由调用处给更具体的提示。 */
|
|
2714
|
+
function setHostingPref(on, reason) {
|
|
2715
|
+
HOSTING = !!on;
|
|
2716
|
+
try { localStorage.setItem('magic.hosting', HOSTING ? '1' : '0'); } catch (e) {}
|
|
2717
|
+
var tgl = document.getElementById('tglHosting');
|
|
2718
|
+
if (tgl) tgl.classList.toggle('is-on', HOSTING);
|
|
2719
|
+
if (reason) toast('会话托管已暂停:' + reason);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2665
2722
|
function mountConversation(sid, s) {
|
|
2666
2723
|
/* 同一条会话、同一个空间:组件还活着,画面也还在 —— 直接把这一屏端回来。
|
|
2667
2724
|
以前这里无条件 destroy() + create() + innerHTML='',于是「返回列表 → 再点进来」
|
|
2668
2725
|
每次都要重拉全量历史、重新建 DOM、重新回放(长会话 = 又慢又费流量)。
|
|
2669
|
-
标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。
|
|
2670
|
-
|
|
2671
|
-
|
|
2726
|
+
标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。
|
|
2727
|
+
⚠️ 判「同一条会话」要认 run_id ↔ session_id 的别名(见 sameConvId):
|
|
2728
|
+
只比字面的话,团队任务/新会话在 session_id 回来前后拿到的是两个 id,
|
|
2729
|
+
同一屏会被判成两条会话 → 销毁重建 → 内存里排队的追问连气泡一起消失
|
|
2730
|
+
(2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
|
|
2731
|
+
if (conv && sid && WS === convWs && sameConvId(sid, convSid)) {
|
|
2732
|
+
convSid = sid; // 列表那边可能已经从 run_id 切到 session_id
|
|
2733
|
+
/* same:true —— 上面已经确认是同一屏会话。组件按字面判 id 会把别名当成
|
|
2734
|
+
「换了会话」→ clearQueue() 删掉排队追问(本次报障的病根之一)。 */
|
|
2735
|
+
try { conv.setSession(sid, s, { same: true }); } catch (e) {}
|
|
2672
2736
|
window.magicConv = conv;
|
|
2673
2737
|
return;
|
|
2674
2738
|
}
|
|
@@ -2695,6 +2759,9 @@ button.setrow{font-family:inherit}
|
|
|
2695
2759
|
onQueueTake: queueTaken,
|
|
2696
2760
|
onQueueDrop: queueDropped,
|
|
2697
2761
|
onPick: rememberRunPick,
|
|
2762
|
+
/* 会话托管:开关值由宿主给(权威在 localStorage),自动暂停时组件回调这里同步。 */
|
|
2763
|
+
hosting: HOSTING,
|
|
2764
|
+
onHostingChange: setHostingPref,
|
|
2698
2765
|
/* 团队模式:开关初值按**这条会话**给;配置是设备偏好(每轮现读,改配置立刻生效)。
|
|
2699
2766
|
features 也给函数:能力清单是异步拉到的(/desk/health),
|
|
2700
2767
|
查到之后开关才会亮出来,不必重新挂载组件。 */
|
|
@@ -2726,7 +2793,10 @@ button.setrow{font-family:inherit}
|
|
|
2726
2793
|
}
|
|
2727
2794
|
}
|
|
2728
2795
|
});
|
|
2729
|
-
|
|
2796
|
+
/* 历史画完再把落库的排队条目接回画布(见 adoptPendingFor):抢在回放之前插气泡,
|
|
2797
|
+
会被组件的 clearThread 一起清掉;而且那一刻「这一轮还在不在跑」已经由
|
|
2798
|
+
loadHistory / follow 落定,flush 的时机才是对的。 */
|
|
2799
|
+
if (sid) adoptAfterHistory(sid, s);
|
|
2730
2800
|
window.magicConv = conv;
|
|
2731
2801
|
} catch (e) {
|
|
2732
2802
|
var d = document.createElement('div');
|
|
@@ -2906,7 +2976,9 @@ button.setrow{font-family:inherit}
|
|
|
2906
2976
|
});
|
|
2907
2977
|
}
|
|
2908
2978
|
/* ── 会话列表:**置顶 / 任务 / 空间** 三段(对齐截图)──
|
|
2909
|
-
置顶:本地 pin
|
|
2979
|
+
置顶:本地 pin(长按会话行切换)+ **运行中自动临时置顶**(2026-10-22:跑着的那条
|
|
2980
|
+
自己进「置顶」段,不写 PINNED,跑完自动归位;见 isAutoPinned);
|
|
2981
|
+
任务:最近会话(默认前 8,可「展开更多」);
|
|
2910
2982
|
空间:会话里出现过的 workspace(点行=按空间筛选任务,+=在该空间新建)。 */
|
|
2911
2983
|
var LAST_SESSIONS = [];
|
|
2912
2984
|
var SEC_COLLAPSED = {};
|
|
@@ -2960,6 +3032,10 @@ button.setrow{font-family:inherit}
|
|
|
2960
3032
|
return !(pk && pk !== sidOf(s));
|
|
2961
3033
|
});
|
|
2962
3034
|
}
|
|
3035
|
+
/* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。
|
|
3036
|
+
任务页 / 需求页共用(需求页已拆到 reqs.js,那边自带一份同名实现;这里留着
|
|
3037
|
+
是因为 isRealSpaceDir / isTeamRunDir 还服务于任务页的空间分组)。 */
|
|
3038
|
+
function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
|
|
2963
3039
|
/* 一个目录算不算「空间」:得是**用户认得的工作区**。
|
|
2964
3040
|
根目录 / 不是空间(哪怕历史上有会话在那儿跑过)—— 需求页显示它只会让人
|
|
2965
3041
|
以为「/ 是个项目」(用户 2026-10-22:「没有空间的不需要」)。
|
|
@@ -3045,6 +3121,30 @@ button.setrow{font-family:inherit}
|
|
|
3045
3121
|
|
|
3046
3122
|
function sidOf(s) { return s.session_id || s.run_id; }
|
|
3047
3123
|
function isPinned(s) { return PINNED.indexOf(sidOf(s)) >= 0; }
|
|
3124
|
+
/* 运行中会话**自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
|
|
3125
|
+
判据只看当前是不是在跑(`statusOf === 'run'`),**不写 PINNED、不落 prefs** ——
|
|
3126
|
+
跑完下一次 renderSessions 自然归位,用户自己的手动置顶关系一点不动。
|
|
3127
|
+
子会话在跑也算:子行默认折叠在父块里,只提子行的话首屏根本看不见,
|
|
3128
|
+
所以把父块一起提到「置顶」段(父行本身不跑时不画图钉,计数徽标会亮运行色,见 sessionRow)。
|
|
3129
|
+
⚠️ 与手动置顶分开判:手动置顶仍只认 PINNED,自动的只在排序 / 分段时叠加。 */
|
|
3130
|
+
function isAutoPinned(s) {
|
|
3131
|
+
if (!s) return false;
|
|
3132
|
+
if (statusOf(s) === 'run') return true;
|
|
3133
|
+
var kids = CHILD_OF[sidOf(s)] || [];
|
|
3134
|
+
for (var i = 0; i < kids.length; i++) { if (statusOf(kids[i]) === 'run') return true; }
|
|
3135
|
+
return false;
|
|
3136
|
+
}
|
|
3137
|
+
/* 列表分段用:手动置顶 ∪ 运行中自动置顶。 */
|
|
3138
|
+
function isListPinned(s) { return isPinned(s) || isAutoPinned(s); }
|
|
3139
|
+
/* 置顶段 / 搜索结果的排序:**人工置顶在前 → 运行中自动置顶 → 普通**,同类按 updated_at 倒序。
|
|
3140
|
+
用户 2026-10-22 定稿:「应该在人工置顶的后面」—— 自动置顶只是临时把在跑的活提上来,
|
|
3141
|
+
不能压过用户自己钉的会话;跑完 isAutoPinned 变 false,它回到普通分段。 */
|
|
3142
|
+
function pinnedOrder(a, b) {
|
|
3143
|
+
var ra = isPinned(a) ? 2 : (isAutoPinned(a) ? 1 : 0);
|
|
3144
|
+
var rb = isPinned(b) ? 2 : (isAutoPinned(b) ? 1 : 0);
|
|
3145
|
+
return (rb - ra)
|
|
3146
|
+
|| String(b.updated_at || b.started_at || '').localeCompare(String(a.updated_at || a.started_at || ''));
|
|
3147
|
+
}
|
|
3048
3148
|
function togglePin(s) {
|
|
3049
3149
|
var id = sidOf(s), i = PINNED.indexOf(id);
|
|
3050
3150
|
if (i >= 0) PINNED.splice(i, 1); else PINNED.unshift(id);
|
|
@@ -3090,10 +3190,46 @@ button.setrow{font-family:inherit}
|
|
|
3090
3190
|
.then(function () { TITLES[id] = name; renderSessions(LAST_SESSIONS); })
|
|
3091
3191
|
.catch(function (e) { window.alert('重命名失败:' + (e && e.message ? e.message : e)); });
|
|
3092
3192
|
}
|
|
3093
|
-
/*
|
|
3193
|
+
/* 删除一棵会话树:删主会话时,挂在它下面的**子任务会话**也要从本地列表一起消失。
|
|
3194
|
+
服务端(session.Delete)已经级联删了登记记录 / 日志 / 标题 / 状态;这里只是不让
|
|
3195
|
+
用户在看到下一次轮询刷新之前,还点得进那些已经不存在了的子行。
|
|
3196
|
+
认亲按 run_id / session_id 双向别名折算(与 renderSessions 的 KEY2SID 同口径):
|
|
3197
|
+
子会话创建时主会话可能还没拿到 session_id,parent 指针里存的是 run_id。 */
|
|
3198
|
+
function deleteClosureOf(id, list) {
|
|
3199
|
+
var del = {};
|
|
3200
|
+
del[id] = 1;
|
|
3201
|
+
function mark(s) {
|
|
3202
|
+
if (!s) return false;
|
|
3203
|
+
var sid = sidOf(s);
|
|
3204
|
+
if (!sid) return false;
|
|
3205
|
+
var changed = !del[sid] || (s.run_id && !del[s.run_id]) || (s.session_id && !del[s.session_id]);
|
|
3206
|
+
del[sid] = 1;
|
|
3207
|
+
if (s.run_id) del[s.run_id] = 1;
|
|
3208
|
+
if (s.session_id) del[s.session_id] = 1;
|
|
3209
|
+
return changed;
|
|
3210
|
+
}
|
|
3211
|
+
(list || []).forEach(function (s) { if (del[sidOf(s)]) mark(s); });
|
|
3212
|
+
var changed = true;
|
|
3213
|
+
while (changed) {
|
|
3214
|
+
changed = false;
|
|
3215
|
+
(list || []).forEach(function (s) {
|
|
3216
|
+
var sid = sidOf(s);
|
|
3217
|
+
if (!sid || del[sid]) return;
|
|
3218
|
+
var pk = parentKeyOf(s);
|
|
3219
|
+
if (pk && del[pk]) changed = mark(s) || changed;
|
|
3220
|
+
});
|
|
3221
|
+
}
|
|
3222
|
+
return del;
|
|
3223
|
+
}
|
|
3224
|
+
/* 删除:DELETE /desk/session/{id}(登记表记录 + 事件日志 + 子任务会话),然后刷新列表。 */
|
|
3094
3225
|
function deleteSessionRow(s) {
|
|
3095
3226
|
var id = sidOf(s);
|
|
3096
|
-
|
|
3227
|
+
/* 文案如实说明「会连子任务一起删」:这是物理删除,提示里不能只提主会话。 */
|
|
3228
|
+
var kidCount = (CHILD_OF[id] || []).length;
|
|
3229
|
+
var ask = kidCount
|
|
3230
|
+
? '删除这个会话及其 ' + kidCount + ' 个子任务会话?此操作不可恢复。'
|
|
3231
|
+
: '删除这个会话?此操作不可恢复。';
|
|
3232
|
+
if (!window.confirm(ask)) return;
|
|
3097
3233
|
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3098
3234
|
fetch(BASE + '/desk/session/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr })
|
|
3099
3235
|
.then(function (r) {
|
|
@@ -3101,10 +3237,11 @@ button.setrow{font-family:inherit}
|
|
|
3101
3237
|
return r.json().catch(function () { return {}; });
|
|
3102
3238
|
})
|
|
3103
3239
|
.then(function () {
|
|
3104
|
-
var
|
|
3105
|
-
|
|
3240
|
+
var del = deleteClosureOf(id, LAST_SESSIONS);
|
|
3241
|
+
PINNED = PINNED.filter(function (pid) { return !del[pid]; }); savePinned();
|
|
3242
|
+
Object.keys(TITLES).forEach(function (tid) { if (del[tid]) delete TITLES[tid]; });
|
|
3106
3243
|
/* 本地移除 + 静默重绘:不走 loadSessions(那会先塞「读取中…」再重建 = 整页闪) */
|
|
3107
|
-
LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return sidOf(x)
|
|
3244
|
+
LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return !del[sidOf(x)]; });
|
|
3108
3245
|
renderSessions(LAST_SESSIONS);
|
|
3109
3246
|
})
|
|
3110
3247
|
.catch(function (e) { window.alert('删除失败:' + (e && e.message ? e.message : e)); });
|
|
@@ -3215,20 +3352,30 @@ button.setrow{font-family:inherit}
|
|
|
3215
3352
|
}
|
|
3216
3353
|
/* 子会话(团队模式拆出的子任务,2026-10-22):core 在 /desk/sessions 里给出
|
|
3217
3354
|
parent_session_id / parent_run_id(见 internal/session 的 Record)。子任务是**普通会话**
|
|
3218
|
-
(自己的登记 +
|
|
3355
|
+
(自己的登记 + 自己的日志,点进去看完整过程),列表里只做三件事:
|
|
3219
3356
|
· 不占顶层一行 —— 缩进挂在主会话下面(CHILD_OF:父 key → 子会话数组);
|
|
3357
|
+
· 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
|
|
3358
|
+
计数徽标,点它才展开;展开状态按会话 id 记在 KIDS_OPEN 里,刷新列表不收回;
|
|
3220
3359
|
· 不进「任务 / 空间」分组(它们的 workspace 是主会话里的任务目录,不该自成一个空间)。
|
|
3221
3360
|
⚠️ 父会话不在列表里(被删 / 老 core 没给父字段)→ 回落成顶层一行,
|
|
3222
3361
|
宁可多一行也不能让子会话凭空消失。
|
|
3223
3362
|
⚠️ 主会话可能还没拿到 session_id,parent_session_id 同时给了 parent_run_id 兜底。 */
|
|
3224
|
-
var CHILD_OF = {}, CHILD_IDS = {};
|
|
3363
|
+
var CHILD_OF = {}, CHILD_IDS = {}, KIDS_OPEN = {};
|
|
3225
3364
|
function parentKeyOf(s) { return (s && (s.parent_session_id || s.parent_run_id)) || ''; }
|
|
3365
|
+
function kidsOpenOf(id) { return !!KIDS_OPEN[id]; }
|
|
3366
|
+
function toggleKids(id) {
|
|
3367
|
+
if (!id) return;
|
|
3368
|
+
KIDS_OPEN[id] = !KIDS_OPEN[id];
|
|
3369
|
+
renderSessions(LAST_SESSIONS);
|
|
3370
|
+
}
|
|
3226
3371
|
function sessionBlock(s) {
|
|
3227
3372
|
var wrap = document.createElement('div');
|
|
3228
3373
|
wrap.className = 'sblock';
|
|
3229
3374
|
wrap.appendChild(s.pendingWait ? pendingRow(s) : sessionRow(s, !!CHILD_IDS[sidOf(s)]));
|
|
3230
|
-
var
|
|
3231
|
-
|
|
3375
|
+
var sid = sidOf(s);
|
|
3376
|
+
var kids = CHILD_OF[sid] || [];
|
|
3377
|
+
/* 默认不展开:只有用户点过父行上的计数徽标(KIDS_OPEN[sid])才挂子行。 */
|
|
3378
|
+
if (kids.length && kidsOpenOf(sid)) {
|
|
3232
3379
|
var box = document.createElement('div');
|
|
3233
3380
|
box.className = 'sblock__kids';
|
|
3234
3381
|
kids.forEach(function (k) { box.appendChild(sessionRow(k, true)); });
|
|
@@ -3252,6 +3399,49 @@ button.setrow{font-family:inherit}
|
|
|
3252
3399
|
col.appendChild(subEl);
|
|
3253
3400
|
/* 排队与团队标记都不在这里做:两者都做在图标里(见 sessionIcon),行尾不再挂角标。 */
|
|
3254
3401
|
b.appendChild(col);
|
|
3402
|
+
/* 子会话计数徽标(默认折叠开关):点一下展开 / 收回,**不触发打开会话**。
|
|
3403
|
+
没有子会话的普通行不画它,保持列表干净;有子任务在跑时亮运行色 + 圆点,
|
|
3404
|
+
这样折叠状态下也能看出「父会话下面还有活在跑」。 */
|
|
3405
|
+
var kids = CHILD_OF[sid] || [];
|
|
3406
|
+
if (!sub && kids.length) {
|
|
3407
|
+
var kidsRunning = false;
|
|
3408
|
+
for (var ki = 0; ki < kids.length; ki++) {
|
|
3409
|
+
if (statusOf(kids[ki]) === 'run') { kidsRunning = true; break; }
|
|
3410
|
+
}
|
|
3411
|
+
var opened = kidsOpenOf(sid);
|
|
3412
|
+
var chip = elSpan('srow__kids' + (opened ? ' is-open' : '') + (kidsRunning ? ' is-run' : ''));
|
|
3413
|
+
chip.setAttribute('role', 'button');
|
|
3414
|
+
chip.setAttribute('tabindex', '0');
|
|
3415
|
+
chip.setAttribute('aria-expanded', opened ? 'true' : 'false');
|
|
3416
|
+
chip.setAttribute('aria-label', (opened ? '收起 ' : '展开 ') + kids.length + ' 个子会话');
|
|
3417
|
+
chip.title = kidsRunning
|
|
3418
|
+
? kids.length + ' 个子会话 · 有任务在跑(点' + (opened ? '收起' : '展开') + ')'
|
|
3419
|
+
: kids.length + ' 个子会话(点' + (opened ? '收起' : '展开') + ')';
|
|
3420
|
+
if (kidsRunning) chip.appendChild(elSpan('srow__kids-dot'));
|
|
3421
|
+
chip.appendChild(elSpan('srow__kids-n', kids.length + ' 子会话'));
|
|
3422
|
+
chip.appendChild(svgUse('#i-chev-r', 12));
|
|
3423
|
+
var toggleKid = function (e) {
|
|
3424
|
+
if (e) { e.preventDefault(); e.stopPropagation(); }
|
|
3425
|
+
toggleKids(sid);
|
|
3426
|
+
};
|
|
3427
|
+
/* 父行有长按菜单与点击打开:徽标上的指针 / 点击事件都要截住,别让它们穿到父行。 */
|
|
3428
|
+
chip.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
|
|
3429
|
+
chip.addEventListener('pointerup', function (e) { e.stopPropagation(); });
|
|
3430
|
+
chip.addEventListener('click', toggleKid);
|
|
3431
|
+
chip.addEventListener('keydown', function (e) {
|
|
3432
|
+
if (e.key === 'Enter' || e.key === ' ') toggleKid(e);
|
|
3433
|
+
});
|
|
3434
|
+
b.appendChild(chip);
|
|
3435
|
+
}
|
|
3436
|
+
/* 运行中自动置顶的行带一枚小图钉(手动置顶由「置顶」分段说明,不重复画):
|
|
3437
|
+
只有**这行自己**在跑才画 —— 父行因某个子会话在跑而被提上来时,行上不画图钉
|
|
3438
|
+
(计数徽标会亮运行色),否则会误读成「父会话在跑」。 */
|
|
3439
|
+
if (run && !isPinned(s)) {
|
|
3440
|
+
var autoPin = elSpan('srow__auto');
|
|
3441
|
+
autoPin.title = '运行中 · 自动临时置顶(跑完自动归位)';
|
|
3442
|
+
autoPin.appendChild(svgUse('#i-pin', 14));
|
|
3443
|
+
b.appendChild(autoPin);
|
|
3444
|
+
}
|
|
3255
3445
|
var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
|
|
3256
3446
|
b.appendChild(timeEl);
|
|
3257
3447
|
if (run) { RUN_ROWS[sid] = { subEl: subEl, timeEl: timeEl, s: s }; refreshProgress(sid); }
|
|
@@ -3373,7 +3563,10 @@ button.setrow{font-family:inherit}
|
|
|
3373
3563
|
|
|
3374
3564
|
/* 搜索态:不分组、不出「分组视图」行,命中项平铺(对齐截图 2)。 */
|
|
3375
3565
|
if (searching) {
|
|
3376
|
-
var matched = query ? rows.filter(taskMatches) : rows;
|
|
3566
|
+
var matched = (query ? rows.filter(taskMatches) : rows).slice();
|
|
3567
|
+
/* 与分组列表同一口径:运行中的自动临时置顶排最前(搜索是临时态,跑完同样归位)。 */
|
|
3568
|
+
/* 与分组列表同一口径:人工置顶在前,运行中的自动临时置顶紧随其后(搜索是临时态,跑完归位)。 */
|
|
3569
|
+
matched.sort(pinnedOrder);
|
|
3377
3570
|
if (!matched.length && query) {
|
|
3378
3571
|
var miss = document.createElement('div'); miss.className = 'history__hint';
|
|
3379
3572
|
miss.textContent = '没有匹配「' + TASK_QUERY + '」的任务。';
|
|
@@ -3389,9 +3582,11 @@ button.setrow{font-family:inherit}
|
|
|
3389
3582
|
return;
|
|
3390
3583
|
}
|
|
3391
3584
|
|
|
3392
|
-
/*
|
|
3393
|
-
|
|
3394
|
-
var
|
|
3585
|
+
/* 置顶:手动 pin + **运行中自动临时置顶**(不写 PINNED,跑完归位);
|
|
3586
|
+
只在这里显示,置顶项不再出现在「任务 / 空间」里(去重)。 */
|
|
3587
|
+
var pinned = rows.filter(isListPinned);
|
|
3588
|
+
var rest = rows.filter(function (s) { return !isListPinned(s); });
|
|
3589
|
+
pinned.sort(pinnedOrder);
|
|
3395
3590
|
|
|
3396
3591
|
/* 任务:**不属于任何空间**的普通任务(且未置顶)+ 默认空间(不指定)的串行等待任务。
|
|
3397
3592
|
显式选了空间的等待任务归到下面的「空间」组(用户 2026-10-20:「串行等待中的任务
|
|
@@ -3605,451 +3800,6 @@ button.setrow{font-family:inherit}
|
|
|
3605
3800
|
if (e.key === 'Escape' && readerEl && !readerEl.hidden) { e.preventDefault(); closeReader(); }
|
|
3606
3801
|
}, true);
|
|
3607
3802
|
|
|
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
3803
|
|
|
4054
3804
|
function openHostSheet(title, build) {
|
|
4055
3805
|
closeHostSheet();
|
|
@@ -4174,6 +3924,16 @@ button.setrow{font-family:inherit}
|
|
|
4174
3924
|
丢了它这条排队任务就会从「团队」静默降级成单任务。 */
|
|
4175
3925
|
team: info.team || null,
|
|
4176
3926
|
attachments: (info.attachments || []).slice(),
|
|
3927
|
+
/* atts:气泡缩略图的快照({name, remoteUrl},见 conversation.js 的 notifyQueue)。
|
|
3928
|
+
重进会话时 adoptPendingFor 用它把排队气泡原样画回来 —— 只存路径的话恢复出来会丢图。 */
|
|
3929
|
+
atts: (info.atts || []).slice(),
|
|
3930
|
+
/* owner:谁负责把这一条真正发出去。
|
|
3931
|
+
'gate' = 串行门(gateAsk,放行后由这一轮自己下发)
|
|
3932
|
+
'host' = 宿主(restoreQueue / 重启恢复,见 enqueueDispatch)
|
|
3933
|
+
'conv' = 会话组件(组件内排队,轮到它时组件自己 flush)
|
|
3934
|
+
组件被销毁重建后,它的 owner='conv' 条目还留在 PENDING 里,由
|
|
3935
|
+
adoptPendingFor 接回新实例 —— 状态只能落在条目上,不能只存在组件内存。 */
|
|
3936
|
+
owner: info.owner || 'gate',
|
|
4177
3937
|
hide: !!hide, dev: devKey(), at: Date.now() };
|
|
4178
3938
|
PENDING.push(p); saveQueue();
|
|
4179
3939
|
if (!hide) renderSessions(LAST_SESSIONS);
|
|
@@ -4194,11 +3954,12 @@ button.setrow{font-family:inherit}
|
|
|
4194
3954
|
(2026-10-21 用户:「非团队的串行排队也没落库」)。
|
|
4195
3955
|
这里把它接进同一个 PENDING:进队即写 magic.serial.queue.v1、轮到就删行、取消就清盘。
|
|
4196
3956
|
重开 App 后由 restoreQueue 直接下发(不挂会话页面,事件仍进会话日志,点开可回放)。 */
|
|
4197
|
-
var QPEND = {}; // 组件条目 id(q…)→ 本端 PENDING id(p…)
|
|
3957
|
+
var QPEND = {}; // 组件条目 id(q… / 重新接管后的 p…)→ 本端 PENDING id(p…)
|
|
4198
3958
|
function queuePushed(info) {
|
|
4199
3959
|
if (!info || !info.id || !info.prompt || QPEND[info.id]) return;
|
|
4200
3960
|
/* hide:用户此刻就在会话里,气泡已经画出来了 —— 列表那条「等待中」延后
|
|
4201
3961
|
PENDING_SHOW_MS 再出现(与 gateAsk 同一口径:空间本来就空闲时不会闪一条假排队)。 */
|
|
3962
|
+
info.owner = 'conv';
|
|
4202
3963
|
var p = addPending(wsKey(info.workspace), info, true);
|
|
4203
3964
|
QPEND[info.id] = p.id;
|
|
4204
3965
|
setTimeout(function () {
|
|
@@ -4243,7 +4004,10 @@ button.setrow{font-family:inherit}
|
|
|
4243
4004
|
session: p.session || '',
|
|
4244
4005
|
prompt: p.prompt || '', engine: p.engine || '', model: p.model || '',
|
|
4245
4006
|
permission: p.permission || '', team: p.team || null,
|
|
4246
|
-
attachments: p.attachments || [],
|
|
4007
|
+
attachments: p.attachments || [], atts: p.atts || [],
|
|
4008
|
+
/* owner 刻意不落盘:重启后所有残留条目都由 restoreQueue 交给宿主
|
|
4009
|
+
(enqueueDispatch),语义与 'host' 一致 —— 存下来反而会让「上次是
|
|
4010
|
+
组件排队」的那条永远等不到组件接管。 */
|
|
4247
4011
|
dev: p.dev, at: p.at };
|
|
4248
4012
|
});
|
|
4249
4013
|
try { localStorage.setItem(QUEUE_KEY, JSON.stringify(others.concat(mineRows))); } catch (e) {}
|
|
@@ -4330,6 +4094,35 @@ button.setrow{font-family:inherit}
|
|
|
4330
4094
|
check();
|
|
4331
4095
|
});
|
|
4332
4096
|
}
|
|
4097
|
+
/* 宿主下发的**追问**要等原会话这一轮收尾(同一条会话不许并发两轮)。
|
|
4098
|
+
与 waitForSlot 的区别:那条按**空间**串行、且只看 TASK_MODE;这条按**会话**,
|
|
4099
|
+
并行模式也生效 —— 恢复一条「排在上一轮后面的追问」时,正是它在挡住并发。
|
|
4100
|
+
sid 为空(新任务)直接放行;清单拿不到 / 会话已不在 / 等超上限也放行:
|
|
4101
|
+
排队是**保护**不是**闸门**,不能因为探测失败把用户的任务永久卡住。 */
|
|
4102
|
+
var SESSION_WAIT_MAX_MS = 30 * 60 * 1000;
|
|
4103
|
+
function waitSessionIdle(sid) {
|
|
4104
|
+
if (!sid) return Promise.resolve();
|
|
4105
|
+
var t0 = Date.now();
|
|
4106
|
+
return new Promise(function (resolve) {
|
|
4107
|
+
function check() {
|
|
4108
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
4109
|
+
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
4110
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
4111
|
+
.then(function (list) {
|
|
4112
|
+
if (!Array.isArray(list)) { resolve(); return; }
|
|
4113
|
+
var row = null;
|
|
4114
|
+
for (var i = 0; i < list.length; i++) {
|
|
4115
|
+
var s = list[i];
|
|
4116
|
+
if (sidOf(s) === sid || s.session_id === sid || s.run_id === sid) { row = s; break; }
|
|
4117
|
+
}
|
|
4118
|
+
if (!row || !serialBusy(row) || Date.now() - t0 >= SESSION_WAIT_MAX_MS) { resolve(); return; }
|
|
4119
|
+
setTimeout(check, SLOT_POLL_MS);
|
|
4120
|
+
})
|
|
4121
|
+
.catch(function () { resolve(); });
|
|
4122
|
+
}
|
|
4123
|
+
check();
|
|
4124
|
+
});
|
|
4125
|
+
}
|
|
4333
4126
|
/* 排队行的出现时机:**进队即落库**(关 App 也不能丢,这是用户报障的正题);
|
|
4334
4127
|
列表上那条「等待中」行则由**是否真的要等**决定可见性(见 gateAsk 的 markWaiting)。
|
|
4335
4128
|
这个常量只剩一处用处:组件内排队(queuePushed,同一会话上一轮还没收尾)在
|
|
@@ -4452,6 +4245,9 @@ button.setrow{font-family:inherit}
|
|
|
4452
4245
|
var list = loadQueue();
|
|
4453
4246
|
if (!list.length) return;
|
|
4454
4247
|
list.sort(function (a, b) { return (a.at || 0) - (b.at || 0); }); // 按当时进队顺序
|
|
4248
|
+
/* 重启恢复的条目一律归宿主下发:它们的主人(会话组件)已经不在了,
|
|
4249
|
+
再标成 'conv' 就没人会认领(见 adoptPendingFor 的判据)。 */
|
|
4250
|
+
list.forEach(function (p) { p.owner = 'host'; });
|
|
4455
4251
|
PENDING = list;
|
|
4456
4252
|
renderSessions(LAST_SESSIONS);
|
|
4457
4253
|
list.forEach(function (p) { enqueueDispatch(p); });
|
|
@@ -4461,11 +4257,20 @@ button.setrow{font-family:inherit}
|
|
|
4461
4257
|
var q = serialQ(ws);
|
|
4462
4258
|
/* 重新尝试(用户点「未能启动」那条):先把红字连原因一起摘掉,回到正常的排队态。 */
|
|
4463
4259
|
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
|
-
|
|
4260
|
+
var mine = q.tail.then(function () { return waitForSlot(ws, q); })
|
|
4261
|
+
/* 同一条会话同时只能跑一轮:宿主下发的「追问」必须等这一轮真的收尾。
|
|
4262
|
+
并行模式(waitForSlot 直接放行)下尤其重要 —— 否则新进程和还在跑的那一轮
|
|
4263
|
+
会同时往同一份会话日志里写,正文与思考区交错,是最难查的一类脏数据。 */
|
|
4264
|
+
.then(function () { return waitSessionIdle(p.session); })
|
|
4265
|
+
.then(function () {
|
|
4266
|
+
/* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
|
|
4267
|
+
if (!connected()) return false;
|
|
4268
|
+
/* 等待期间用户点进了这条会话,组件已经把它接管回去了(adoptPendingFor 把 owner
|
|
4269
|
+
改回 'conv')→ 让组件自己 flush,宿主这里放手。不检查的话同一条会被发两次。 */
|
|
4270
|
+
if (p.owner !== 'host') return false;
|
|
4271
|
+
p.dispatching = true; // 已交给 fireQueued:此后不许组件再接管(防双发)
|
|
4272
|
+
return fireQueued(p);
|
|
4273
|
+
});
|
|
4469
4274
|
q.tail = mine.catch(function () { return false; });
|
|
4470
4275
|
return mine.then(function (sent) {
|
|
4471
4276
|
if (!sent) return; // 没落盘:留在队列里(stalled),不删
|
|
@@ -5321,6 +5126,39 @@ button.setrow{font-family:inherit}
|
|
|
5321
5126
|
document.addEventListener('click', function () {
|
|
5322
5127
|
if (!runSettingsPageEl.hidden) paintRunSettings();
|
|
5323
5128
|
});
|
|
5129
|
+
/* 需求页(reqs.js):内容与逻辑不在本文件,这里只注入宿主能力并挂载一次。
|
|
5130
|
+
getter 形式传 BASE/TOKEN/空间清单 —— 切环境后这些值会变,不能传快照。 */
|
|
5131
|
+
if (window.MagicReqs && window.MagicReqs.mount) {
|
|
5132
|
+
window.MagicReqs.mount({
|
|
5133
|
+
rootEl: reqsEl,
|
|
5134
|
+
detailEl: reqDetailEl,
|
|
5135
|
+
hEl: hEl,
|
|
5136
|
+
elSpan: elSpan,
|
|
5137
|
+
svgUse: svgUse,
|
|
5138
|
+
toast: toast,
|
|
5139
|
+
openReader: openReader,
|
|
5140
|
+
hideAll: hideAll,
|
|
5141
|
+
setActive: setActive,
|
|
5142
|
+
showRoot: function () {
|
|
5143
|
+
hideAll();
|
|
5144
|
+
reqsEl.hidden = false;
|
|
5145
|
+
tabbarEl.hidden = false;
|
|
5146
|
+
setActive('reqs');
|
|
5147
|
+
},
|
|
5148
|
+
showDetail: function () {
|
|
5149
|
+
hideAll();
|
|
5150
|
+
reqDetailEl.hidden = false;
|
|
5151
|
+
tabbarEl.hidden = true;
|
|
5152
|
+
},
|
|
5153
|
+
getBase: function () { return BASE; },
|
|
5154
|
+
getToken: function () { return TOKEN; },
|
|
5155
|
+
getRootDirs: function () { return ROOT_DIRS; },
|
|
5156
|
+
getCustomSpaces: function () { return CUSTOM_SPACES; },
|
|
5157
|
+
getRunPref: function () { return RUN_PREF; },
|
|
5158
|
+
isRealSpaceDir: isRealSpaceDir,
|
|
5159
|
+
topLevelSessions: topLevelSessions
|
|
5160
|
+
});
|
|
5161
|
+
}
|
|
5324
5162
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
5325
5163
|
b.onclick = function () {
|
|
5326
5164
|
var t = b.getAttribute('data-tab');
|
|
@@ -5362,11 +5200,21 @@ button.setrow{font-family:inherit}
|
|
|
5362
5200
|
if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
|
|
5363
5201
|
if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
|
|
5364
5202
|
if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5203
|
+
|
|
5204
|
+
/* 会话托管开关(我的 tab)。拨动只改「接下来的轮次」:
|
|
5205
|
+
正在跑的这一轮不受影响,收尾后若打开就自动接上。 */
|
|
5206
|
+
var tglHosting = document.getElementById('tglHosting');
|
|
5207
|
+
if (tglHosting) {
|
|
5208
|
+
tglHosting.classList.toggle('is-on', HOSTING);
|
|
5209
|
+
tglHosting.onclick = function () {
|
|
5210
|
+
var next = !HOSTING;
|
|
5211
|
+
setHostingPref(next, '');
|
|
5212
|
+
if (conv) { try { conv.setHosting(next); } catch (e) {} }
|
|
5213
|
+
toast(next
|
|
5214
|
+
? '会话托管已开启:每轮结束自动发送最合适的建议,提问卡自动选第一项继续'
|
|
5215
|
+
: '会话托管已关闭');
|
|
5216
|
+
};
|
|
5217
|
+
}
|
|
5370
5218
|
|
|
5371
5219
|
/* ══════════ 网络代理(VPN 隧道)══════════
|
|
5372
5220
|
把整机流量经隧道送到桌面端的 SOCKS5 出口:TUN → 内核 → 桌面端 → 互联网。
|