magic-agent 2.3.0 → 2.3.9
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 +78 -13
- package/README.md +10 -4
- package/client-ui/pc/app.css +14 -0
- package/client-ui/pc/js/93-rail.js +3 -1
- package/client-ui/shared/conv/03-chat-message.js +20 -3
- package/client-ui/shared/conv/07-stream-delegation.js +15 -0
- package/client-ui/shared/conv/27-conv-ledger.js +1 -1
- package/client-ui/shared/conv/conv.css +17 -0
- package/client-ui/shared/conversation.js +209 -39
- package/client-ui/shared/transport-http.js +193 -38
- package/client-ui-mobile/index.html +733 -107
- package/client-ui-mobile/reqs.js +42 -29
- 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 +6 -0
- package/internal/agent/llmengine_test.go +7 -0
- package/internal/agent/llmhttp.go +6 -0
- package/internal/agent/llmhttp_test.go +27 -0
- package/internal/agent/llmmodels.go +13 -0
- package/internal/agent/permission.go +5 -0
- package/internal/cli/ask.go +32 -17
- package/internal/cli/requirements.go +2 -0
- package/internal/cli/sessionlog.go +5 -0
- package/internal/cli/sessionlog_user_test.go +49 -0
- package/internal/client/cors_test.go +32 -0
- package/internal/client/jobs.go +1160 -0
- package/internal/client/jobs_test.go +369 -0
- package/internal/client/requirements.go +27 -7
- package/internal/client/requirements_test.go +51 -0
- package/internal/client/service.go +259 -142
- package/internal/client/service_test.go +78 -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/session.go +15 -1
- package/internal/session/workspace_lock.go +165 -0
- package/internal/session/workspace_lock_test.go +90 -0
- package/internal/suggest/suggest.go +101 -7
- package/internal/suggest/suggest_test.go +44 -0
- 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 +3 -2
|
@@ -67,6 +67,12 @@
|
|
|
67
67
|
/* 状态色底(半透明叠加色,明暗主题通用) */
|
|
68
68
|
--tint-run:rgba(59,130,246,.18); --tint-wait:rgba(245,158,11,.20); --tint-done:rgba(34,197,94,.18);
|
|
69
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;
|
|
70
76
|
|
|
71
77
|
/* ── 掌天瓶绿液(可选令牌,shared/conversation.css 用 `var(--q-bottle*, 兜底)` 取)──
|
|
72
78
|
瓶是**玉**不是玻璃(见 conversation.js 的形制说明),所以颜色分两组:
|
|
@@ -112,6 +118,9 @@
|
|
|
112
118
|
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
113
119
|
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
114
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;
|
|
115
124
|
/* 浅底上玉胎要更浓:`.42` 那档实测会被冲成白瓷,读不出「翠」。浓到 .66 又像实心塑料。 */
|
|
116
125
|
--q-bottle-jade-hi:rgba(146,231,184,.62);
|
|
117
126
|
--q-bottle-jade:rgba(96,205,152,.60);
|
|
@@ -137,6 +146,9 @@ html[data-theme="light"]{color-scheme:light;
|
|
|
137
146
|
--accent:#2563EB; --warning:#D97706; --success:#16A34A;
|
|
138
147
|
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
139
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;
|
|
140
152
|
/* 浅底上玉胎要更浓(同上一条注释) */
|
|
141
153
|
--q-bottle-jade-hi:rgba(146,231,184,.62);
|
|
142
154
|
--q-bottle-jade:rgba(96,205,152,.60);
|
|
@@ -288,6 +300,11 @@ textarea{font:inherit}
|
|
|
288
300
|
.srow__ico--run{background:var(--tint-run);color:var(--primary-action)}
|
|
289
301
|
.srow__ico--wait{background:var(--tint-wait);color:var(--warning)}
|
|
290
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)}
|
|
291
308
|
.srow__ico--idle{background:var(--subtle);color:var(--muted)}
|
|
292
309
|
/* 等待中(串行排队):紫色 + 一圈**呼吸**的环。
|
|
293
310
|
与「运行中」的扫光刻意分开 —— 扫光在转(在动),这圈只明暗(在等);
|
|
@@ -971,20 +988,18 @@ button.setrow{font-family:inherit}
|
|
|
971
988
|
<button class="seg__b" data-v="serial">串行</button>
|
|
972
989
|
</span>
|
|
973
990
|
</div>
|
|
974
|
-
<!-- 会话托管(2026-10-23
|
|
975
|
-
|
|
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>
|
|
991
|
+
<!-- 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」;
|
|
992
|
+
同日再定:「跟团队模式一样改成挂在会话上,子会话继承,不再是全局」)。
|
|
993
|
+
所以这里**没有**全局开关 —— 与团队模式同一处入口:会话页右上角 → 任务运行设置。 -->
|
|
984
994
|
<!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
|
|
985
995
|
两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
|
|
986
996
|
<button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v" id="defaultEngine">自动(首个可用)</span></button>
|
|
987
997
|
<button class="setrow" id="btnDefaultModel"><span class="setrow__l">默认模型</span><span class="setrow__v" id="defaultModel">跟随引擎</span></button>
|
|
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>
|
|
988
1003
|
<!-- 2026-10-22 用户:「触感反馈 / 语言」这两项不需要 —— 已移除(设备级偏好只剩主题/字体)。 -->
|
|
989
1004
|
<div class="setrow"><span class="setrow__l">网络代理</span><button class="tgl" id="tglVpn" aria-label="网络代理"></button></div>
|
|
990
1005
|
<button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
|
|
@@ -1033,7 +1048,8 @@ button.setrow{font-family:inherit}
|
|
|
1033
1048
|
|
|
1034
1049
|
<!-- ══════════ 会话页 → 任务运行设置(独立子页面)══════════
|
|
1035
1050
|
会话页右上角的调节键点进来(2026-10-21 用户:「运行设置在任务对话框里 不也应该是独立页面吗」)。
|
|
1036
|
-
|
|
1051
|
+
五项都是**当前这条会话**的运行参数:设备只读,其余点开还是原来的选择器(选择器只有一份)。
|
|
1052
|
+
会话托管(2026-10-23)也在这里:它跟团队模式一样**属于这条会话**,不再是「我的」里的全局开关。 -->
|
|
1037
1053
|
<div class="history" id="runSettingsPage" hidden>
|
|
1038
1054
|
<div class="history__top">
|
|
1039
1055
|
<button class="topbar__icon" id="btnRunSettingsBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
@@ -1045,7 +1061,9 @@ button.setrow{font-family:inherit}
|
|
|
1045
1061
|
<button class="setrow" id="btnRsWorkspace"><span class="setrow__l">工作空间</span><span class="setrow__v" id="rsWorkspace">默认(不属于任何空间)</span></button>
|
|
1046
1062
|
<button class="setrow" id="btnRsPermission"><span class="setrow__l">权限模式</span><span class="setrow__v" id="rsPermission">沿用默认</span></button>
|
|
1047
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>
|
|
1048
1065
|
</div>
|
|
1066
|
+
<div class="history__hint" style="text-align:left">会话托管:每轮成功结束自动发送最合适的建议(最多连续 12 轮)。只作用于当前会话;团队子会话默认继承主会话的开关。</div>
|
|
1049
1067
|
</div>
|
|
1050
1068
|
</div>
|
|
1051
1069
|
|
|
@@ -1286,6 +1304,8 @@ button.setrow{font-family:inherit}
|
|
|
1286
1304
|
/* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
|
|
1287
1305
|
提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
|
|
1288
1306
|
var RUN_PREF = loadRunPref();
|
|
1307
|
+
/* 建议引擎 / 模型(「我的」):引擎空 = 不开启建议功能(默认)。与默认引擎分开存,互不影响。 */
|
|
1308
|
+
var SUGGEST = loadSuggestPref();
|
|
1289
1309
|
var ENGINES = []; // 最近一次从 /desk/engines?models=1 拿到的清单
|
|
1290
1310
|
var ENGINES_STALE_AT = ''; // 非空 = 这份清单是插件侧的兜底缓存(探测超时/失败),值是缓存时间
|
|
1291
1311
|
/* ── 团队模式(2026-10-21)──
|
|
@@ -1409,6 +1429,87 @@ button.setrow{font-family:inherit}
|
|
|
1409
1429
|
saveTeamPref();
|
|
1410
1430
|
}
|
|
1411
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
|
+
|
|
1412
1513
|
/* ══════════ 环境(多连接)══════════
|
|
1413
1514
|
2026-10-22 用户:「移动端要支持环境切换 可以有多个连接」。
|
|
1414
1515
|
一台手机可以记住多台桌面(家里 / 公司 / 另一台机器),任务页标题就是切换入口。
|
|
@@ -1474,24 +1575,44 @@ button.setrow{font-family:inherit}
|
|
|
1474
1575
|
var id = connIdOf(t);
|
|
1475
1576
|
var old = findConn(id) || {};
|
|
1476
1577
|
var list = loadConns().filter(function (c) { return c.id !== id; });
|
|
1578
|
+
/* eid:环境的稳定身份(见 ensureEnvId)。改地址/改令牌时由调用方带过来(t.eid),
|
|
1579
|
+
否则沿用老记录的;都没有才发新号。 */
|
|
1580
|
+
var eid = t.eid || old.eid || newEid();
|
|
1477
1581
|
var next = {
|
|
1478
1582
|
id: id, url: t.url || '', token: t.token || '',
|
|
1479
1583
|
name: (t.name != null && t.name !== '') ? t.name : (old.name || ''),
|
|
1480
1584
|
host: connHost(t.url), app: old.app || '', at: Date.now(),
|
|
1585
|
+
eid: eid,
|
|
1481
1586
|
seed: !!old.seed // 种出来的默认环境:改名/改地址后仍然认出它
|
|
1482
1587
|
};
|
|
1483
1588
|
list.unshift(next);
|
|
1484
1589
|
saveConns(list);
|
|
1590
|
+
/* 第一次拿到 eid:把这个环境原先按 host 哈希分区的队列行与配置槽迁过来。 */
|
|
1591
|
+
if (!old.eid && !t.eid) migrateEnvData(envHashOf(next), eidKey(eid));
|
|
1485
1592
|
return next;
|
|
1486
1593
|
}
|
|
1487
|
-
|
|
1594
|
+
/* 删环境时把它名下的排队任务一起清掉:地址可能被别的服务器复用,
|
|
1595
|
+
留着就会在下次连上时被当成那台的任务发出去。
|
|
1596
|
+
keepQueue=true 用于「改地址/改令牌」——那不是删环境,eid 不变,队列要跟着走。 */
|
|
1597
|
+
function removeConn(id, keepQueue) {
|
|
1488
1598
|
var list = loadConns();
|
|
1489
1599
|
var hit = null;
|
|
1490
1600
|
list.forEach(function (c) { if (c && c.id === id) hit = c; });
|
|
1491
1601
|
/* 用户主动删掉种出来的默认环境:记一笔,下次启动别再自动加回来。 */
|
|
1492
1602
|
if (hit && hit.seed) { try { localStorage.setItem('magic.remote.seedoff.v1', '1'); } catch (e) {} }
|
|
1603
|
+
if (hit && !keepQueue) dropEnvQueue(hit);
|
|
1493
1604
|
saveConns(list.filter(function (c) { return c.id !== id; }));
|
|
1494
1605
|
}
|
|
1606
|
+
/* 清掉某条环境名下的排队行(eid 桶与老 host 桶都清)。 */
|
|
1607
|
+
function dropEnvQueue(conn) {
|
|
1608
|
+
if (!conn) return;
|
|
1609
|
+
var keys = [eidKey(conn.eid), envHashOf(conn)].filter(function (k) { return !!k; });
|
|
1610
|
+
if (!keys.length) return;
|
|
1611
|
+
try {
|
|
1612
|
+
var rows = readQueueAll().filter(function (p) { return !p || keys.indexOf(String(p.env || '')) < 0; });
|
|
1613
|
+
localStorage.setItem(QUEUE_KEY, JSON.stringify(rows));
|
|
1614
|
+
} catch (e) {}
|
|
1615
|
+
}
|
|
1495
1616
|
function clearConns() {
|
|
1496
1617
|
saveConns([]);
|
|
1497
1618
|
try {
|
|
@@ -1587,9 +1708,12 @@ button.setrow{font-family:inherit}
|
|
|
1587
1708
|
/* 隧道换域名(cloudflared 每次重启都换):把上一枚**默认命名**的种子远程记录换到新地址。
|
|
1588
1709
|
用户改过名字的不动 —— 那条是他自己配的环境。 */
|
|
1589
1710
|
var kept = [];
|
|
1711
|
+
var replacedEid = '';
|
|
1590
1712
|
for (var k = 0; k < list.length; k++) {
|
|
1591
1713
|
var o = list[k];
|
|
1592
|
-
|
|
1714
|
+
/* 隧道换域名 = 同一台桌面换了入口:把它的 eid 带过去,
|
|
1715
|
+
否则新记录会发新号,这台环境排队中的任务与配置槽全部变孤儿。 */
|
|
1716
|
+
if (o && o.seed && o.name === SEED_NAME && o.url !== url) { replacedEid = o.eid || replacedEid; continue; }
|
|
1593
1717
|
kept.push(o);
|
|
1594
1718
|
}
|
|
1595
1719
|
if (kept.length !== list.length) saveConns(kept);
|
|
@@ -1609,18 +1733,20 @@ button.setrow{font-family:inherit}
|
|
|
1609
1733
|
return;
|
|
1610
1734
|
}
|
|
1611
1735
|
}
|
|
1612
|
-
seedMark(upsertConn({ url: url, token: TOKEN, name: SEED_NAME }));
|
|
1736
|
+
seedMark(upsertConn({ url: url, token: TOKEN, name: SEED_NAME, eid: replacedEid }));
|
|
1613
1737
|
}
|
|
1614
1738
|
|
|
1615
1739
|
/* ── 设备现场(按环境隔离)──
|
|
1616
1740
|
2026-10-22 用户:「切换环境后 所有东西都要跟着变 包括会话 需求 我的这些 是选择环境上的东西」。
|
|
1617
1741
|
会话与需求本来就来自桌面端(切完重拉);这里解决的是**留在本机的那部分状态**:
|
|
1618
1742
|
主题 / 字体 / 任务模式 / 默认引擎与模型 / 团队配置 / 当前空间 / 置顶 / 通知 / 浏览器收藏。
|
|
1619
|
-
|
|
1743
|
+
做法:切环境时把这一组键快照到「按环境身份分桶」的槽里,再把目标环境的槽写回来,然后整页重载
|
|
1620
1744
|
—— 内存态全部重新初始化,不留上一台机器的影子。
|
|
1621
|
-
⚠️
|
|
1745
|
+
⚠️ 分桶键用稳定 eid(见 ensureEnvId),不是 url|token(令牌会轮换)、也不是 host:port
|
|
1746
|
+
(换 IP/端口/隧道域名就变成另一台,现场全丢)。老数据按 host 哈希做一次迁移。 */
|
|
1622
1747
|
var ENV_SCOPE_KEYS = [
|
|
1623
|
-
'magic.ws', 'magic.taskmode', 'magic.hosting', 'magic.engine', 'magic.model',
|
|
1748
|
+
'magic.ws', 'magic.taskmode', 'magic.hosting.sessions.v1', 'magic.engine', 'magic.model',
|
|
1749
|
+
'magic.suggest.engine', 'magic.suggest.model',
|
|
1624
1750
|
'magic.theme', 'magic.font', 'magic.notify',
|
|
1625
1751
|
'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
|
|
1626
1752
|
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
@@ -1636,11 +1762,64 @@ button.setrow{font-family:inherit}
|
|
|
1636
1762
|
for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
|
|
1637
1763
|
return h.toString(36);
|
|
1638
1764
|
}
|
|
1639
|
-
|
|
1765
|
+
/* ── 稳定环境身份(eid)──
|
|
1766
|
+
地址会变(IP/端口/隧道域名),令牌会轮换(App 每次启动换注入令牌),两者都不能当环境身份:
|
|
1767
|
+
地址变了 → 旧队列/旧配置变孤儿;地址被别的服务器复用 → 旧队列发错环境。
|
|
1768
|
+
eid 在环境第一次落盘时生成,之后跟着这条记录走:改地址、改令牌都不换号。 */
|
|
1769
|
+
function newEid() {
|
|
1770
|
+
return 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1771
|
+
}
|
|
1772
|
+
function eidKey(id) { return id ? ('eid:' + id) : ''; }
|
|
1773
|
+
/* 老版本按 host:port 分区的键(只用于一次性迁移和「还没拿到 eid」的临时记录)。 */
|
|
1774
|
+
function envHashOf(conn) {
|
|
1640
1775
|
var host = connHost((conn && conn.url) || connUrlNow());
|
|
1641
1776
|
return host ? envScopeHash(host) : '';
|
|
1642
1777
|
}
|
|
1778
|
+
function envScopeIdOf(conn) {
|
|
1779
|
+
if (conn && conn.eid) return eidKey(conn.eid);
|
|
1780
|
+
return envHashOf(conn);
|
|
1781
|
+
}
|
|
1643
1782
|
function envScopeId() { return envScopeIdOf(activeConn()); }
|
|
1783
|
+
/* 给一条**已落盘**的环境补 eid(老记录没有),并把按 host 分区的旧队列/旧配置槽迁过来。 */
|
|
1784
|
+
function ensureEnvId(conn) {
|
|
1785
|
+
if (!conn) return conn;
|
|
1786
|
+
var oldHash = envHashOf(conn);
|
|
1787
|
+
if (conn.eid) {
|
|
1788
|
+
migrateEnvData(oldHash, eidKey(conn.eid)); // 上次可能只写了一半:重入也要幂等补齐
|
|
1789
|
+
return conn;
|
|
1790
|
+
}
|
|
1791
|
+
var list = loadConns(), hit = null;
|
|
1792
|
+
/* 扫码/粘贴进来的临时目标没有 id:按 url+令牌现算一个,才能跟上已落盘的那条。 */
|
|
1793
|
+
var wantId = conn.id || connIdOf({
|
|
1794
|
+
url: conn.url || connUrlNow(),
|
|
1795
|
+
token: conn.token != null ? conn.token : (TOKEN || '')
|
|
1796
|
+
});
|
|
1797
|
+
for (var i = 0; i < list.length; i++) if (list[i] && list[i].id === wantId) { hit = list[i]; break; }
|
|
1798
|
+
if (!hit) return conn; // 临时记录(还没落盘):等 upsertConn 落盘时再发号
|
|
1799
|
+
hit.eid = newEid();
|
|
1800
|
+
saveConns(list);
|
|
1801
|
+
migrateEnvData(oldHash, eidKey(hit.eid));
|
|
1802
|
+
return hit;
|
|
1803
|
+
}
|
|
1804
|
+
/* 把旧键名下的排队行与配置槽搬到新键名下(幂等)。 */
|
|
1805
|
+
function migrateEnvData(from, to) {
|
|
1806
|
+
if (!from || !to || from === to) return;
|
|
1807
|
+
migrateQueueEnv(from, to);
|
|
1808
|
+
try {
|
|
1809
|
+
var v = localStorage.getItem(ENV_SCOPE_PREFIX + from);
|
|
1810
|
+
if (v != null) {
|
|
1811
|
+
if (localStorage.getItem(ENV_SCOPE_PREFIX + to) == null) localStorage.setItem(ENV_SCOPE_PREFIX + to, v);
|
|
1812
|
+
localStorage.removeItem(ENV_SCOPE_PREFIX + from);
|
|
1813
|
+
}
|
|
1814
|
+
} catch (e) {}
|
|
1815
|
+
}
|
|
1816
|
+
function migrateQueueEnv(from, to) {
|
|
1817
|
+
try {
|
|
1818
|
+
var rows = readQueueAll(), changed = false;
|
|
1819
|
+
rows.forEach(function (p) { if (p && String(p.env || '') === from) { p.env = to; changed = true; } });
|
|
1820
|
+
if (changed) localStorage.setItem(QUEUE_KEY, JSON.stringify(rows));
|
|
1821
|
+
} catch (e) {}
|
|
1822
|
+
}
|
|
1644
1823
|
/* 把当前这组键存进 id 的槽(切走之前调用)。 */
|
|
1645
1824
|
function saveEnvScope(id) {
|
|
1646
1825
|
if (!id) return;
|
|
@@ -1676,7 +1855,8 @@ button.setrow{font-family:inherit}
|
|
|
1676
1855
|
· 本地任何一处改了,防抖推回远端。
|
|
1677
1856
|
纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。 */
|
|
1678
1857
|
var PREF_SYNC_KEYS = [
|
|
1679
|
-
'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
|
|
1858
|
+
'magic.engine', 'magic.model', 'magic.suggest.engine', 'magic.suggest.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
|
|
1859
|
+
'magic.hosting.sessions.v1',
|
|
1680
1860
|
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
1681
1861
|
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
|
|
1682
1862
|
];
|
|
@@ -1790,7 +1970,8 @@ button.setrow{font-family:inherit}
|
|
|
1790
1970
|
if (btn) btn.classList.remove('is-open');
|
|
1791
1971
|
}
|
|
1792
1972
|
/* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
|
|
1793
|
-
|
|
1973
|
+
排队任务按**环境**分别落库(见 QUEUE_KEY):旧环境那批留在原地,切回来再接着跑 ——
|
|
1974
|
+
队列绝不能被带到另一台环境上跑,那边的会话结束事件这边根本收不到。 */
|
|
1794
1975
|
/* 换环境 = 换一台桌面:会话 / 需求 / 我的设置全都属于那台机器。
|
|
1795
1976
|
做法分两步:① 把当前这台的本机状态快照进它的槽,再把目标环境的槽写回来;
|
|
1796
1977
|
② 记住「停在哪个环境」并整页重载 —— 重载后内存态从零初始化,按目标环境的
|
|
@@ -1798,6 +1979,7 @@ button.setrow{font-family:inherit}
|
|
|
1798
1979
|
⚠️ 不在这里手写「清哪些变量」:那种清单永远会漏(漏一个就带着上一台的数据)。 */
|
|
1799
1980
|
function switchEnv(c) {
|
|
1800
1981
|
if (!c) return;
|
|
1982
|
+
c = ensureEnvId(c) || c; // 先把目标环境的 eid 补上,下面取槽才是稳定键
|
|
1801
1983
|
if (c.url === connUrlNow() && String(c.token || '') === String(TOKEN || '')) {
|
|
1802
1984
|
toast('已在「' + (connLabel(c) || '当前环境') + '」');
|
|
1803
1985
|
return;
|
|
@@ -1954,9 +2136,12 @@ button.setrow{font-family:inherit}
|
|
|
1954
2136
|
}
|
|
1955
2137
|
function persist(t) {
|
|
1956
2138
|
var wasActive = !!(conn && conn.url === connUrlNow() && String(conn.token || '') === String(TOKEN || ''));
|
|
2139
|
+
/* 改地址/改令牌 = 同一台环境换了入口:eid 带过去,队列与配置槽跟着走。 */
|
|
2140
|
+
if (conn && conn.eid) t.eid = conn.eid;
|
|
1957
2141
|
var next = upsertConn(t);
|
|
1958
|
-
/* 改了地址/令牌 = 换了一条记录:旧的那条要删掉,别留一条连不上的僵尸。
|
|
1959
|
-
|
|
2142
|
+
/* 改了地址/令牌 = 换了一条记录:旧的那条要删掉,别留一条连不上的僵尸。
|
|
2143
|
+
队列已经跟着 eid 走了,这里不能再按旧记录清一遍。 */
|
|
2144
|
+
if (conn && conn.id !== next.id) removeConn(conn.id, true);
|
|
1960
2145
|
/* 名称允许清空,也允许改:upsertConn 会沿用旧名,这里按输入框的最终值覆盖。 */
|
|
1961
2146
|
var list = loadConns();
|
|
1962
2147
|
for (var i = 0; i < list.length; i++) {
|
|
@@ -2061,6 +2246,7 @@ button.setrow{font-family:inherit}
|
|
|
2061
2246
|
function showMine() {
|
|
2062
2247
|
hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
|
|
2063
2248
|
mineDevice.textContent = histSubtitle || '本机';
|
|
2249
|
+
paintSuggestPref();
|
|
2064
2250
|
var ns = document.getElementById('notifyState');
|
|
2065
2251
|
if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
|
|
2066
2252
|
markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
|
|
@@ -2629,10 +2815,12 @@ button.setrow{font-family:inherit}
|
|
|
2629
2815
|
/* 任务模式:parallel(默认,全部并行)| serial(同一空间同时只跑一个新任务)。 */
|
|
2630
2816
|
var TASK_MODE = 'parallel';
|
|
2631
2817
|
try { TASK_MODE = localStorage.getItem('magic.taskmode') || 'parallel'; } catch (e) {}
|
|
2632
|
-
/* 会话托管(2026-10-23
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2818
|
+
/* 会话托管(2026-10-23):跟团队模式一样**挂在会话上**(用户:「跟团队模式一样改成
|
|
2819
|
+
挂在会话上,子会话继承,不再是全局」)。`sessions` 是 sid → 开/关;`fresh` 只临时装
|
|
2820
|
+
「新会话还没拿到 session_id 时」的开关(首帧带回 id 就落到 `sessions` 上)。
|
|
2821
|
+
旧版是全设备一个 `magic.hosting` —— 那正是「改一条会话,所有会话都变」的来源,
|
|
2822
|
+
不迁移(把全局值抄给每条会话等于把 bug 换个地方),直接清掉。 */
|
|
2823
|
+
var HOSTING = loadHostingPref();
|
|
2636
2824
|
var PREFS = loadPrefs();
|
|
2637
2825
|
applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
|
|
2638
2826
|
paintRunPref();
|
|
@@ -2692,7 +2880,8 @@ button.setrow{font-family:inherit}
|
|
|
2692
2880
|
宿主这边的筛选只是第一道。 */
|
|
2693
2881
|
session: p.session,
|
|
2694
2882
|
attachments: (p.attachments || []).slice(),
|
|
2695
|
-
atts: (p.atts || []).slice()
|
|
2883
|
+
atts: (p.atts || []).slice(),
|
|
2884
|
+
hosted: !!p.hosted
|
|
2696
2885
|
};
|
|
2697
2886
|
});
|
|
2698
2887
|
try { return conv.adoptQueue(items) || 0; } catch (e) { return 0; }
|
|
@@ -2708,16 +2897,48 @@ button.setrow{font-family:inherit}
|
|
|
2708
2897
|
adoptPendingFor(sid);
|
|
2709
2898
|
});
|
|
2710
2899
|
}
|
|
2711
|
-
/*
|
|
2900
|
+
/* 会话托管的开关:**按当前这条会话**落盘(用户 2026-10-23:「跟团队模式一样改成
|
|
2901
|
+
挂在会话上,子会话继承,不再是全局」)。开关的权威值在这里,组件只执行。
|
|
2712
2902
|
reason 非空 = 组件自己暂停的(失败 / 被停 / 到上限 / 用户点了「托管中」标记),
|
|
2713
|
-
|
|
2714
|
-
function
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2903
|
+
这里要如实弹一句;宿主自己拨开关时由调用处给更具体的提示。 */
|
|
2904
|
+
function convSessionKey() {
|
|
2905
|
+
var raw = (conv && conv.state && conv.state.session) || convSid || '';
|
|
2906
|
+
var s = raw ? sessionByAnyId(raw) : null;
|
|
2907
|
+
return s ? (sidOf(s) || String(raw || '')) : String(raw || '');
|
|
2908
|
+
}
|
|
2909
|
+
function writeHostingFor(on) {
|
|
2910
|
+
var key = convSessionKey();
|
|
2911
|
+
if (key) {
|
|
2912
|
+
var s = sessionByAnyId(key);
|
|
2913
|
+
/* 同一会话的 run_id ↔ session_id 别名只留一条,避免两个键各说一套。 */
|
|
2914
|
+
if (s) {
|
|
2915
|
+
[s.session_id, s.run_id].forEach(function (k) {
|
|
2916
|
+
if (k && String(k) !== key) delete HOSTING.sessions[String(k)];
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
HOSTING.sessions[key] = !!on;
|
|
2920
|
+
HOSTING.fresh = false;
|
|
2921
|
+
} else {
|
|
2922
|
+
HOSTING.fresh = !!on;
|
|
2923
|
+
}
|
|
2924
|
+
saveHostingPref();
|
|
2925
|
+
}
|
|
2926
|
+
function onHostingToggleFromConv(on, reason) {
|
|
2927
|
+
writeHostingFor(!!on);
|
|
2719
2928
|
if (reason) toast('会话托管已暂停:' + reason);
|
|
2720
2929
|
}
|
|
2930
|
+
/* 运行设置里那一行:直接翻当前会话的开关(不弹选择器 —— 托管只有开 / 关两档)。 */
|
|
2931
|
+
function toggleHostingHere() {
|
|
2932
|
+
if (!SUGGEST.engine) { toast('先在「我的 → 建议引擎」里选一个引擎,才能开启会话托管'); return; }
|
|
2933
|
+
if (!conv) { toast('会话还没准备好'); return; }
|
|
2934
|
+
var on = typeof conv.hostingOn === 'function' ? !!conv.hostingOn() : false;
|
|
2935
|
+
var next = !on;
|
|
2936
|
+
try { conv.setHosting(next); } catch (e) {}
|
|
2937
|
+
writeHostingFor(next);
|
|
2938
|
+
toast(next
|
|
2939
|
+
? '会话托管已开启:每轮结束自动发送最合适的建议,提问卡自动选第一项继续'
|
|
2940
|
+
: '会话托管已关闭');
|
|
2941
|
+
}
|
|
2721
2942
|
|
|
2722
2943
|
function mountConversation(sid, s) {
|
|
2723
2944
|
/* 同一条会话、同一个空间:组件还活着,画面也还在 —— 直接把这一屏端回来。
|
|
@@ -2733,6 +2954,7 @@ button.setrow{font-family:inherit}
|
|
|
2733
2954
|
/* same:true —— 上面已经确认是同一屏会话。组件按字面判 id 会把别名当成
|
|
2734
2955
|
「换了会话」→ clearQueue() 删掉排队追问(本次报障的病根之一)。 */
|
|
2735
2956
|
try { conv.setSession(sid, s, { same: true }); } catch (e) {}
|
|
2957
|
+
if (typeof conv.setSuggestEngine === 'function') conv.setSuggestEngine(SUGGEST.engine, SUGGEST.model);
|
|
2736
2958
|
window.magicConv = conv;
|
|
2737
2959
|
return;
|
|
2738
2960
|
}
|
|
@@ -2741,14 +2963,18 @@ button.setrow{font-family:inherit}
|
|
|
2741
2963
|
convSid = sid || null;
|
|
2742
2964
|
convWs = WS;
|
|
2743
2965
|
/* 每次重建组件都丢掉「上一条还没落定的新会话」的临时开关 —— 否则用户在新会话里
|
|
2744
|
-
|
|
2745
|
-
老会话的开关由 teamOnFor 从它自己那份记录里取;新会话从「关」起。 */
|
|
2966
|
+
开了团队 / 托管但没发消息,转去打开一条历史会话时,那条会话的首帧会把开关误记成开启。
|
|
2967
|
+
老会话的开关由 teamOnFor / hostingOnFor 从它自己那份记录里取;新会话从「关」起。 */
|
|
2746
2968
|
TEAM.fresh = false;
|
|
2969
|
+
HOSTING.fresh = false;
|
|
2747
2970
|
try {
|
|
2748
|
-
var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN, client: 'mobile' });
|
|
2971
|
+
var transport = wrapTransportForJobs(window.MagicTransportHttp({ base: BASE, token: TOKEN, client: 'mobile' }));
|
|
2749
2972
|
conv = window.MagicConversation.create({
|
|
2750
2973
|
el: convEl, transport: transport, titleEl: titleEl, workspace: WS,
|
|
2751
2974
|
engine: RUN_PREF.engine, model: RUN_PREF.model,
|
|
2975
|
+
/* 建议功能默认关闭:只有「我的 → 建议引擎」里显式选了引擎才传非空值;
|
|
2976
|
+
模型为空 = 引擎默认(建议生成对 llm 用 no-think 变体作默认值)。 */
|
|
2977
|
+
suggestEngine: SUGGEST.engine, suggestModel: SUGGEST.model,
|
|
2752
2978
|
showPermission: false, showAttach: true, beforeAsk: gateAsk,
|
|
2753
2979
|
onInterrupt: cancelWaitingAsk,
|
|
2754
2980
|
/* 组件内排队也要落库(2026-10-21 用户:「非团队的串行排队也没落库」):
|
|
@@ -2759,9 +2985,10 @@ button.setrow{font-family:inherit}
|
|
|
2759
2985
|
onQueueTake: queueTaken,
|
|
2760
2986
|
onQueueDrop: queueDropped,
|
|
2761
2987
|
onPick: rememberRunPick,
|
|
2762
|
-
/*
|
|
2763
|
-
|
|
2764
|
-
|
|
2988
|
+
/* 会话托管:开关初值按**这条会话**给(自己没有记录就沿父链继承,见 hostingOnFor);
|
|
2989
|
+
组件自己暂停时回调这里把这条会话的开关关掉。 */
|
|
2990
|
+
hosting: hostingOnFor(sid, s),
|
|
2991
|
+
onHostingChange: onHostingToggleFromConv,
|
|
2765
2992
|
/* 团队模式:开关初值按**这条会话**给;配置是设备偏好(每轮现读,改配置立刻生效)。
|
|
2766
2993
|
features 也给函数:能力清单是异步拉到的(/desk/health),
|
|
2767
2994
|
查到之后开关才会亮出来,不必重新挂载组件。 */
|
|
@@ -2778,9 +3005,12 @@ button.setrow{font-family:inherit}
|
|
|
2778
3005
|
};
|
|
2779
3006
|
},
|
|
2780
3007
|
features: function () { return CORE_FEATURES; },
|
|
2781
|
-
/* 新会话的开关先记在 TEAM.fresh:core 的首帧带回 session_id
|
|
2782
|
-
|
|
2783
|
-
onEvent: function (ev) {
|
|
3008
|
+
/* 新会话的开关先记在 TEAM.fresh / HOSTING.fresh:core 的首帧带回 session_id 时,
|
|
3009
|
+
当场绑到这条会话上(否则返回列表再点进来就找不到它了)。 */
|
|
3010
|
+
onEvent: function (ev) {
|
|
3011
|
+
bindFreshTeam(ev && ev.session_id);
|
|
3012
|
+
bindFreshHosting(ev && ev.session_id);
|
|
3013
|
+
},
|
|
2784
3014
|
/* 菜单里的「定时任务」还在,但底部那个 tab 已经删了(2026-10-22 用户:
|
|
2785
3015
|
「定时任务tab先删除」)。现在点它给一句说明,而不是跳去一张只有一个占位符的页 ——
|
|
2786
3016
|
原先那页的正文就是这句说明,页面本身没有承载任何数据。
|
|
@@ -2805,6 +3035,39 @@ button.setrow{font-family:inherit}
|
|
|
2805
3035
|
convEl.appendChild(d);
|
|
2806
3036
|
}
|
|
2807
3037
|
}
|
|
3038
|
+
/* ── 进详情时补拉这一条的状态(2026-10-23 用户:「白色会话进会话详情时应该再拉一波更新状态」)──
|
|
3039
|
+
列表里的 status 是**进列表那一刻**的快照;而 core 的 `--sessions` 读侧还会把「记录写着
|
|
3040
|
+
在跑、进程其实已经不在」的行当场归一成 gone。用户点开一条**没有颜色**的会话(既不是
|
|
3041
|
+
运行中 / 等审批,也不是已完成 / 失败 / 已取消)时,多半就是这份快照已经过期。
|
|
3042
|
+
这里只补这一条:拉一次登记表、按 id 别名(session_id ↔ run_id)对上就地更新,
|
|
3043
|
+
状态真的变了才重绘。失败静默 —— 它是补刀,不是主路径。 */
|
|
3044
|
+
function refreshRowStatus(sid) {
|
|
3045
|
+
if (!sid || !TOKEN) return;
|
|
3046
|
+
var seq = CONN_SEQ;
|
|
3047
|
+
fetch(BASE + '/desk/sessions', { headers: { 'X-Magic-Token': TOKEN } })
|
|
3048
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
3049
|
+
.then(function (list) {
|
|
3050
|
+
if (seq !== CONN_SEQ || !Array.isArray(list)) return;
|
|
3051
|
+
var row = null, cur = null, i;
|
|
3052
|
+
for (i = 0; i < list.length; i++) {
|
|
3053
|
+
var x = list[i];
|
|
3054
|
+
if (!x) continue;
|
|
3055
|
+
if (sameConvId(x.session_id, sid) || sameConvId(x.run_id, sid)) { row = x; break; }
|
|
3056
|
+
}
|
|
3057
|
+
if (!row) return;
|
|
3058
|
+
for (i = 0; i < LAST_SESSIONS.length; i++) {
|
|
3059
|
+
if (sameConvId(sidOf(LAST_SESSIONS[i]), sid)) { cur = LAST_SESSIONS[i]; break; }
|
|
3060
|
+
}
|
|
3061
|
+
if (!cur) return;
|
|
3062
|
+
var was = statusOf(cur);
|
|
3063
|
+
if (row.status) cur.status = row.status;
|
|
3064
|
+
if (row.state) cur.state = row.state;
|
|
3065
|
+
cur.alive = row.alive === true;
|
|
3066
|
+
if (row.updated_at) cur.updated_at = row.updated_at;
|
|
3067
|
+
if (statusOf(cur) !== was) renderSessions(LAST_SESSIONS);
|
|
3068
|
+
})
|
|
3069
|
+
.catch(function () { /* 拉不到就保持原样 */ });
|
|
3070
|
+
}
|
|
2808
3071
|
function openSession(sid, s) {
|
|
2809
3072
|
/* ⚠️ 一律以会话自己的归属为准,**不能**只在其有 workspace 时才改 WS(2026-10-21):
|
|
2810
3073
|
原写法 `if (s && s.workspace) { WS = s.workspace; }` 会让「打开一条默认空间的会话」
|
|
@@ -2814,6 +3077,9 @@ button.setrow{font-family:inherit}
|
|
|
2814
3077
|
saveWs();
|
|
2815
3078
|
showSession(s && s.workspace ? basename(s.workspace) : histSubtitle);
|
|
2816
3079
|
mountConversation(sid, s);
|
|
3080
|
+
/* 灰白档 = 状态未知(或列表快照过期):进详情时补拉一次,见 refreshRowStatus。
|
|
3081
|
+
其它档(运行中 / 等审批 / 已完成 / 失败 / 已取消)列表已经说清楚了,不必再打一发。 */
|
|
3082
|
+
if (statusOf(s) === 'idle') refreshRowStatus(sid);
|
|
2817
3083
|
}
|
|
2818
3084
|
/* ws:不传 = 默认(不属于任何空间);传字符串 = 在该空间里新建(列表里空间组的「+」)。
|
|
2819
3085
|
⚠️ 「新建任务」大按钮必须从**默认**起(2026-10-21 用户:「大的新建会话 空间应该是
|
|
@@ -2828,6 +3094,15 @@ button.setrow{font-family:inherit}
|
|
|
2828
3094
|
}
|
|
2829
3095
|
|
|
2830
3096
|
function connect(target) {
|
|
3097
|
+
if (!target) return;
|
|
3098
|
+
/* 已经连着另一台:换环境必须走 switchEnv(整页重载)。
|
|
3099
|
+
就地改 BASE/TOKEN 会把内存里的队列、串行门、会话组件留在旧环境,
|
|
3100
|
+
而 saveQueue 又会把旧 PENDING 行按新环境重标后发出去 —— 这正是跨环境串队的入口。 */
|
|
3101
|
+
if ((BASE || TOKEN) && (String(target.url || '') !== String(connUrlNow()) ||
|
|
3102
|
+
String(target.token || '') !== String(TOKEN || ''))) {
|
|
3103
|
+
switchEnv(target);
|
|
3104
|
+
return;
|
|
3105
|
+
}
|
|
2831
3106
|
BASE = target.url || '';
|
|
2832
3107
|
TOKEN = target.token || '';
|
|
2833
3108
|
CONN_SEQ++; // 上一台桌面还在飞的请求:回来时直接丢弃(见 loadSessions)
|
|
@@ -2852,6 +3127,7 @@ button.setrow{font-family:inherit}
|
|
|
2852
3127
|
ACTIVE_ID = '';
|
|
2853
3128
|
try { localStorage.removeItem(ACTIVE_ENV_KEY); } catch (e) {} // 主动断开 = 下次回启动页
|
|
2854
3129
|
SERVER_VERSION = ''; // 版本号属于「那一条连接」:断开就清掉,避免下次连到别的机器还显示旧值
|
|
3130
|
+
SERVER_JOBS = false; // 作业队列能力也属于那一条连接
|
|
2855
3131
|
/* 排队任务**不跟着断连消失**(它们已经落库):只清内存里的等待态,下次连上由
|
|
2856
3132
|
restoreQueue 从磁盘接回来。串行门的 FIFO 也一并重置 —— 里面的 tail 是挂在一条
|
|
2857
3133
|
已废弃页面上的 promise,留着会让新连接的第一条永远排在它后面。 */
|
|
@@ -2930,6 +3206,7 @@ button.setrow{font-family:inherit}
|
|
|
2930
3206
|
TITLES = a[1] || {};
|
|
2931
3207
|
DW = (a[2] && a[2].defaultWorkspace) || '';
|
|
2932
3208
|
DW_READY = true;
|
|
3209
|
+
SERVER_JOBS = !!(a[2] && a[2].jobs);
|
|
2933
3210
|
/* 环境配置以桌面端为准:连上后拉一次(见 pullPrefs)。只拉一次,
|
|
2934
3211
|
后面本地的改动由 schedulePushPrefs 推回去。 */
|
|
2935
3212
|
if (!PREFS_PULLED) pullPrefs();
|
|
@@ -2993,6 +3270,10 @@ button.setrow{font-family:inherit}
|
|
|
2993
3270
|
var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
|
|
2994
3271
|
var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
|
|
2995
3272
|
var DW_READY = false; // DW 是否已从 health 落定(空间键归一依赖它,见 restoreQueue)
|
|
3273
|
+
/* 服务端作业队列是否可用(GET /desk/health 的 jobs 字段,2026-10-23)。
|
|
3274
|
+
有它:排队与互斥在服务端(多客户端同一 workspace 串行 + request_id 幂等);
|
|
3275
|
+
没有:退回本地串行门(老服务端,只有本设备自己排)。 */
|
|
3276
|
+
var SERVER_JOBS = false;
|
|
2996
3277
|
var SPACES_ROOT = ''; // 工作空间根(health.workspacesRoot)
|
|
2997
3278
|
var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
|
|
2998
3279
|
var CUSTOM_SPACES = [];
|
|
@@ -3177,18 +3458,19 @@ button.setrow{font-family:inherit}
|
|
|
3177
3458
|
rowMenuEl.style.top = Math.max(12, Math.min(r.top, window.innerHeight - mh - 12)) + 'px';
|
|
3178
3459
|
rowMenuEl.style.left = Math.max(12, Math.min(r.left + 10, window.innerWidth - mw - 12)) + 'px';
|
|
3179
3460
|
}
|
|
3180
|
-
/* 重命名:写服务端标题表(PUT /desk/session/{id}/title),成功后就地更新并重绘。
|
|
3461
|
+
/* 重命名:写服务端标题表(PUT /desk/session/{id}/title),成功后就地更新并重绘。
|
|
3462
|
+
输入走抽屉里的真输入框(openTextInputSheet)—— window.prompt 在悬浮球 / WebView 里
|
|
3463
|
+
点了没反应,与「自定义路径」是同一处坑(2026-10-23)。 */
|
|
3181
3464
|
function renameSession(s) {
|
|
3182
3465
|
var id = sidOf(s);
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
.catch(function (e) { window.alert('重命名失败:' + (e && e.message ? e.message : e)); });
|
|
3466
|
+
openTextInputSheet({ title: '重命名会话', value: titleOf(s), emptyHint: '名称不能为空' }, function (name) {
|
|
3467
|
+
var hdr = { 'Content-Type': 'application/json' };
|
|
3468
|
+
if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
|
|
3469
|
+
fetch(BASE + '/desk/session/' + encodeURIComponent(id) + '/title', { method: 'PUT', headers: hdr, body: JSON.stringify({ title: name }) })
|
|
3470
|
+
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
3471
|
+
.then(function () { TITLES[id] = name; renderSessions(LAST_SESSIONS); })
|
|
3472
|
+
.catch(function (e) { toast('重命名失败:' + (e && e.message ? e.message : e)); });
|
|
3473
|
+
});
|
|
3192
3474
|
}
|
|
3193
3475
|
/* 删除一棵会话树:删主会话时,挂在它下面的**子任务会话**也要从本地列表一起消失。
|
|
3194
3476
|
服务端(session.Delete)已经级联删了登记记录 / 日志 / 标题 / 状态;这里只是不让
|
|
@@ -3265,12 +3547,24 @@ button.setrow{font-family:inherit}
|
|
|
3265
3547
|
return h;
|
|
3266
3548
|
}
|
|
3267
3549
|
/* 会话状态:由 /desk/sessions 的 status(会话级统一状态,见 core 的 session-status.json)
|
|
3268
|
-
映射;老版本 CLI 不给 status 时回落 state。
|
|
3550
|
+
映射;老版本 CLI 不给 status 时回落 state。
|
|
3551
|
+
词表与 core 的 session.Record.State 一一对应(不再是「除了三种都算灰色」):
|
|
3552
|
+
running / alive → run 蓝 + 扫光环
|
|
3553
|
+
waiting_approval → wait 琥珀(活着的等待)
|
|
3554
|
+
done / idle → done 绿(idle = 常驻会话本轮跑完、进程还在等追加,
|
|
3555
|
+
与 PC 左栏的「已完成」同口径)
|
|
3556
|
+
failed / gone → fail 红(gone = 记录写着在跑、进程已不在)
|
|
3557
|
+
cancelled / interrupted → stop 石褐(人为收工 / 被打断,不是故障)
|
|
3558
|
+
其它(空 / 陌生值) → idle 灰白兜底
|
|
3559
|
+
⚠️ 2026-10-23 之前 failed / cancelled / gone 全落进 idle,列表上是一片白 ——
|
|
3560
|
+
用户分不清「正常结束」和「挂了」(报障:「这两个会话为什么是白色」)。 */
|
|
3269
3561
|
function statusOf(s) {
|
|
3270
3562
|
var st = String((s && (s.status || s.state)) || '');
|
|
3271
3563
|
if (st === 'running' || s.alive === true) return 'run';
|
|
3272
3564
|
if (st === 'waiting_approval') return 'wait';
|
|
3273
|
-
if (st === 'done') return 'done';
|
|
3565
|
+
if (st === 'done' || st === 'idle') return 'done';
|
|
3566
|
+
if (st === 'failed' || st === 'gone') return 'fail';
|
|
3567
|
+
if (st === 'cancelled' || st === 'interrupted' || st === 'stopped') return 'stop';
|
|
3274
3568
|
return 'idle';
|
|
3275
3569
|
}
|
|
3276
3570
|
/* 会话行图标:团队会话=双人图形(mode:"team"),其余**设备形状按来源端**
|
|
@@ -3832,6 +4126,38 @@ button.setrow{font-family:inherit}
|
|
|
3832
4126
|
else { b.style.cursor = 'default'; }
|
|
3833
4127
|
body.appendChild(b);
|
|
3834
4128
|
}
|
|
4129
|
+
/* 单行文本输入抽屉(重命名 / 自定义路径共用)。
|
|
4130
|
+
⚠️ **不用 window.prompt**:悬浮球(Electron 小窗)与 Android WebView 都没实现它,
|
|
4131
|
+
调用只会静默返回 null —— 表现就是「点击无效」且抽屉原样不动(2026-10-23 报障)。
|
|
4132
|
+
这里换成抽屉里的真输入框,样式沿用启动页那套 .field / .btn;空值给红字提示,
|
|
4133
|
+
不再静默 return(否则和上面的 bug 长得一模一样)。 */
|
|
4134
|
+
function openTextInputSheet(opt, onOk) {
|
|
4135
|
+
openHostSheet(opt.title || '', function (body) {
|
|
4136
|
+
var f = hEl('div', 'field');
|
|
4137
|
+
var inp = document.createElement('input');
|
|
4138
|
+
inp.type = 'text';
|
|
4139
|
+
inp.value = opt.value || '';
|
|
4140
|
+
if (opt.placeholder) inp.placeholder = opt.placeholder;
|
|
4141
|
+
inp.setAttribute('autocapitalize', 'off');
|
|
4142
|
+
inp.setAttribute('autocorrect', 'off');
|
|
4143
|
+
inp.setAttribute('spellcheck', 'false');
|
|
4144
|
+
f.appendChild(inp);
|
|
4145
|
+
body.appendChild(f);
|
|
4146
|
+
var err = hEl('div', 'field__err'); err.hidden = true;
|
|
4147
|
+
body.appendChild(err);
|
|
4148
|
+
var ok = hEl('button', 'btn btn--filled', '确定'); ok.type = 'button';
|
|
4149
|
+
body.appendChild(ok);
|
|
4150
|
+
function commit() {
|
|
4151
|
+
var v = String(inp.value || '').trim();
|
|
4152
|
+
if (!v) { err.textContent = opt.emptyHint || '不能为空'; err.hidden = false; inp.focus(); return; }
|
|
4153
|
+
closeHostSheet();
|
|
4154
|
+
if (onOk) onOk(v);
|
|
4155
|
+
}
|
|
4156
|
+
ok.onclick = commit;
|
|
4157
|
+
inp.onkeydown = function (e) { if (e.key === 'Enter') { e.preventDefault(); commit(); } };
|
|
4158
|
+
setTimeout(function () { try { inp.focus(); inp.select(); } catch (e) {} }, 30);
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
3835
4161
|
function applyWs() { if (conv && conv.setWorkspace) conv.setWorkspace(WS); }
|
|
3836
4162
|
/* 工作空间值的显示:WS 为空 = 默认(不传 -w,core 用调用方 cwd)。
|
|
3837
4163
|
⚠️ **绝不显示目录名**(2026-10-21 用户:「默认就是要不属于任何空间目录」)。
|
|
@@ -3854,6 +4180,11 @@ button.setrow{font-family:inherit}
|
|
|
3854
4180
|
if (permBtn) permBtn.classList.toggle('setrow--danger', !!(conv && conv.permissionTier && conv.permissionTier() === 'full'));
|
|
3855
4181
|
var t = document.getElementById('rsTeam');
|
|
3856
4182
|
if (t) t.textContent = (conv && conv.teamLabel) ? conv.teamLabel() : '关闭';
|
|
4183
|
+
/* 会话托管(2026-10-23,按会话):与团队模式并列在同一张卡里。
|
|
4184
|
+
标签读**组件此刻的真实状态**(子会话可能是从父会话继承来的开启值)。 */
|
|
4185
|
+
var h = document.getElementById('rsHosting');
|
|
4186
|
+
if (h) h.textContent = !SUGGEST.engine ? '未配置建议引擎'
|
|
4187
|
+
: ((conv && typeof conv.hostingOn === 'function' && conv.hostingOn()) ? '开启' : '关闭');
|
|
3857
4188
|
/* 团队模式:与权限模式并列(用户 2026-10-21:「团队模式开关在界面右上角跟权限模式一样」)。
|
|
3858
4189
|
core 没有这个能力位时**不显示**这一行 —— 显示一个点了必然失败的开关是最坏的做法。 */
|
|
3859
4190
|
var showTeam = !conv || typeof conv.featureOn !== 'function' || conv.featureOn('feature.session.team');
|
|
@@ -3923,6 +4254,9 @@ button.setrow{font-family:inherit}
|
|
|
3923
4254
|
/* team:重发时原样还原团队配置(见 fireQueued)——
|
|
3924
4255
|
丢了它这条排队任务就会从「团队」静默降级成单任务。 */
|
|
3925
4256
|
team: info.team || null,
|
|
4257
|
+
/* hosted:托管自动发的那一轮被串行门挡住 / 重启恢复时要原样还原,
|
|
4258
|
+
否则重发出去的历史事件会退回「用户亲手发的」画法。 */
|
|
4259
|
+
hosted: !!info.hosted,
|
|
3926
4260
|
attachments: (info.attachments || []).slice(),
|
|
3927
4261
|
/* atts:气泡缩略图的快照({name, remoteUrl},见 conversation.js 的 notifyQueue)。
|
|
3928
4262
|
重进会话时 adoptPendingFor 用它把排队气泡原样画回来 —— 只存路径的话恢复出来会丢图。 */
|
|
@@ -3934,7 +4268,7 @@ button.setrow{font-family:inherit}
|
|
|
3934
4268
|
组件被销毁重建后,它的 owner='conv' 条目还留在 PENDING 里,由
|
|
3935
4269
|
adoptPendingFor 接回新实例 —— 状态只能落在条目上,不能只存在组件内存。 */
|
|
3936
4270
|
owner: info.owner || 'gate',
|
|
3937
|
-
hide: !!hide, dev: devKey(), at: Date.now() };
|
|
4271
|
+
hide: !!hide, env: queueEnvKey(), dev: devKey(), at: Date.now() };
|
|
3938
4272
|
PENDING.push(p); saveQueue();
|
|
3939
4273
|
if (!hide) renderSessions(LAST_SESSIONS);
|
|
3940
4274
|
return p;
|
|
@@ -3980,14 +4314,88 @@ button.setrow{font-family:inherit}
|
|
|
3980
4314
|
delete QPEND[id];
|
|
3981
4315
|
cancelPending(pid); // 标取消 + 从 PENDING 与磁盘上一并删掉
|
|
3982
4316
|
}
|
|
3983
|
-
/*
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
4317
|
+
/* 服务端已受理(open 到达):本地排队行交给服务端,删掉并清干净映射。 */
|
|
4318
|
+
function clearPendingRow(id) {
|
|
4319
|
+
if (!id) return;
|
|
4320
|
+
Object.keys(QPEND).forEach(function (k) { if (QPEND[k] === id) delete QPEND[k]; });
|
|
4321
|
+
ASK_WAITING = ASK_WAITING.filter(function (x) { return !x || x.id !== id; });
|
|
4322
|
+
dropPending(id);
|
|
4323
|
+
}
|
|
4324
|
+
/* ── 把服务端作业队列接进对话组件(2026-10-23)──
|
|
4325
|
+
· 每个 ask 都带 request_id:与本地排队行同一个 id,断线/重启/换标签页重发也只跑一次;
|
|
4326
|
+
· 排队时服务端发 `event: queued`(老服务端没有,忽略)→ 把本地行翻成可见的「等待中」;
|
|
4327
|
+
· `open` 到达 = 服务端已受理并开跑 → 删本地行。此后刷新页面也不会再重发。
|
|
4328
|
+
老服务端(没有 jobs 能力)也照样带这些字段 —— 多出来的键会被忽略,行为不变。 */
|
|
4329
|
+
function wrapTransportForJobs(t) {
|
|
4330
|
+
if (!t || t.__jobsWrapped) return t;
|
|
4331
|
+
t.__jobsWrapped = true;
|
|
4332
|
+
var baseAsk = t.ask;
|
|
4333
|
+
t.ask = function (payload, h) {
|
|
4334
|
+
var p = payload || {};
|
|
4335
|
+
var pend = pendingOf(p.prompt);
|
|
4336
|
+
var rid = (pend && pend.id) || ('q' + Date.now() + '-' + Math.floor(Math.random() * 100000));
|
|
4337
|
+
p.request_id = rid;
|
|
4338
|
+
/* 串行模式由用户选;并行模式显式传 false,服务端仍会套全局并发上限。 */
|
|
4339
|
+
if (p.serial === undefined) p.serial = (TASK_MODE === 'serial');
|
|
4340
|
+
var hs = h || {};
|
|
4341
|
+
var onOpen = hs.onOpen, onEvent = hs.onEvent, onEnd = hs.onEnd, onErr = hs.onError;
|
|
4342
|
+
if (pend && !pend.gone) {
|
|
4343
|
+
pend.dispatching = true;
|
|
4344
|
+
pend.requestId = rid;
|
|
4345
|
+
if (ASK_WAITING.indexOf(pend) < 0) ASK_WAITING.push(pend);
|
|
4346
|
+
}
|
|
4347
|
+
hs.onEvent = function (ev) {
|
|
4348
|
+
if (ev && ev.state === 'queued' && pend && !pend.gone) {
|
|
4349
|
+
if (ev.job) pend.job = ev.job;
|
|
4350
|
+
pend.pos = ev.position || 0;
|
|
4351
|
+
if (pend.hide) { pend.hide = false; renderSessions(LAST_SESSIONS); }
|
|
4352
|
+
if (typeof pend.queuedCb === 'function') { try { pend.queuedCb(true); } catch (e) {} }
|
|
4353
|
+
}
|
|
4354
|
+
if (onEvent) { try { onEvent(ev); } catch (e) {} }
|
|
4355
|
+
};
|
|
4356
|
+
hs.onOpen = function (id) {
|
|
4357
|
+
if (pend && !pend.gone) {
|
|
4358
|
+
if (typeof pend.queuedCb === 'function') { try { pend.queuedCb(false); } catch (e) {} }
|
|
4359
|
+
clearPendingRow(pend.id);
|
|
4360
|
+
}
|
|
4361
|
+
if (onOpen) { try { onOpen(id); } catch (e) {} }
|
|
4362
|
+
};
|
|
4363
|
+
hs.onEnd = function (code, ev) {
|
|
4364
|
+
if (pend && !pend.gone) {
|
|
4365
|
+
if (typeof pend.queuedCb === 'function') { try { pend.queuedCb(false); } catch (e) {} }
|
|
4366
|
+
ASK_WAITING = ASK_WAITING.filter(function (x) { return x !== pend; });
|
|
4367
|
+
}
|
|
4368
|
+
if (onEnd) { try { onEnd(code, ev); } catch (e) {} }
|
|
4369
|
+
};
|
|
4370
|
+
hs.onError = function (e) {
|
|
4371
|
+
/* 发不出去:本地行留着(下次连上 / 重开 App 由 restoreQueue 重发,request_id 不变)。 */
|
|
4372
|
+
if (pend && !pend.gone) { pend.dispatching = false; }
|
|
4373
|
+
if (onErr) { try { onErr(e); } catch (e2) {} }
|
|
4374
|
+
};
|
|
4375
|
+
return baseAsk.call(t, p, hs);
|
|
4376
|
+
};
|
|
4377
|
+
return t;
|
|
4378
|
+
}
|
|
4379
|
+
/* ── 队列落库(按**环境**分区)──
|
|
4380
|
+
分区键用稳定 eid(见 ensureEnvId),与 ENV_SCOPE 同一口径;不是令牌也不是地址:
|
|
4381
|
+
· 令牌会变 —— App 每次启动会换注入令牌,按令牌分桶等于每次重启换一个新桶;
|
|
4382
|
+
· 令牌还会**重复** —— 多台桌面用同一枚显式 `--token` 部署时,按令牌分桶就把
|
|
4383
|
+
A 的队列当成 B 的:切过去直接发到 B,而「A 的会话结束」事件还在 A,这边等不到;
|
|
4384
|
+
· 地址也会变/会被复用 —— 按 host 分桶要么变孤儿,要么把旧任务发给后来占用
|
|
4385
|
+
同一地址的另一台服务器。
|
|
4386
|
+
loadQueue 只认当前环境那批,别的**留在原地不动**(切回去时接着跑)——
|
|
3988
4387
|
所以这里是读改写,不是整体覆盖。 */
|
|
3989
4388
|
var QUEUE_KEY = 'magic.serial.queue.v1';
|
|
4389
|
+
function queueEnvKey() { return envScopeIdOf(activeConn()) || String(BASE || ''); }
|
|
4390
|
+
/* 兼容旧行(没有 env、只有令牌派生的 dev):只有令牌正好是当前这台时才认领 ——
|
|
4391
|
+
认领后下一次写盘会补上 env。其余环境的行原样留着,等切回去再接。 */
|
|
3990
4392
|
function devKey() { return String(TOKEN || '').slice(0, 12) || String(BASE || ''); }
|
|
4393
|
+
function rowEnv(p) {
|
|
4394
|
+
if (!p) return '';
|
|
4395
|
+
if (p.env) return String(p.env);
|
|
4396
|
+
if (p.dev && p.dev === devKey()) return queueEnvKey();
|
|
4397
|
+
return '';
|
|
4398
|
+
}
|
|
3991
4399
|
function readQueueAll() {
|
|
3992
4400
|
try {
|
|
3993
4401
|
var a = JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]');
|
|
@@ -3995,7 +4403,11 @@ button.setrow{font-family:inherit}
|
|
|
3995
4403
|
} catch (e) { return []; }
|
|
3996
4404
|
}
|
|
3997
4405
|
function saveQueue() {
|
|
3998
|
-
|
|
4406
|
+
/* 没连上就不知道自己是哪台环境:此时宁可不写,也不能把「认不出归属」的行
|
|
4407
|
+
当成自己的冲掉(那正是旧实现丢别的环境队列的方式)。 */
|
|
4408
|
+
var mine = queueEnvKey();
|
|
4409
|
+
if (!mine) return;
|
|
4410
|
+
var others = readQueueAll().filter(function (p) { return rowEnv(p) !== mine; });
|
|
3999
4411
|
/* 只落**持久**字段:hide / gone 是这一次页面会话里的显示态,存下去会让「关掉 App 时
|
|
4000
4412
|
正好还没到 300ms」的那条永远隐藏 —— 队列里有它、列表上却看不见,最难查的一种。
|
|
4001
4413
|
id 保留:恢复时据此与内存里已有的条目去重。 */
|
|
@@ -4008,13 +4420,41 @@ button.setrow{font-family:inherit}
|
|
|
4008
4420
|
/* owner 刻意不落盘:重启后所有残留条目都由 restoreQueue 交给宿主
|
|
4009
4421
|
(enqueueDispatch),语义与 'host' 一致 —— 存下来反而会让「上次是
|
|
4010
4422
|
组件排队」的那条永远等不到组件接管。 */
|
|
4011
|
-
dev: p.dev, at: p.at };
|
|
4423
|
+
env: mine, dev: p.dev, at: p.at };
|
|
4012
4424
|
});
|
|
4013
4425
|
try { localStorage.setItem(QUEUE_KEY, JSON.stringify(others.concat(mineRows))); } catch (e) {}
|
|
4426
|
+
queueBroadcast();
|
|
4014
4427
|
}
|
|
4015
4428
|
function loadQueue() {
|
|
4016
|
-
var mine =
|
|
4017
|
-
|
|
4429
|
+
var mine = queueEnvKey();
|
|
4430
|
+
if (!mine) return [];
|
|
4431
|
+
return readQueueAll().filter(function (p) { return p && rowEnv(p) === mine && p.prompt; });
|
|
4432
|
+
}
|
|
4433
|
+
/* 同源多标签页:队列变了就互相知会一声(真正的一致性由服务端负责,
|
|
4434
|
+
这里只是让列表不用等下一次轮询才更新)。 */
|
|
4435
|
+
var QUEUE_BC = null;
|
|
4436
|
+
try { if (window.BroadcastChannel) QUEUE_BC = new BroadcastChannel('magic.queue'); } catch (e) { QUEUE_BC = null; }
|
|
4437
|
+
function queueBroadcast() {
|
|
4438
|
+
if (!QUEUE_BC) return;
|
|
4439
|
+
try { QUEUE_BC.postMessage({ env: queueEnvKey(), at: Date.now() }); } catch (e) {}
|
|
4440
|
+
}
|
|
4441
|
+
function syncPendingFromDisk() {
|
|
4442
|
+
var disk = loadQueue();
|
|
4443
|
+
var keep = [];
|
|
4444
|
+
PENDING.forEach(function (p) { if (p && (p.dispatching || p.owner === 'conv')) keep.push(p); });
|
|
4445
|
+
var ids = {};
|
|
4446
|
+
keep.forEach(function (p) { ids[p.id] = 1; });
|
|
4447
|
+
PENDING = disk.filter(function (p) { return !ids[p.id]; }).concat(keep);
|
|
4448
|
+
renderSessions(LAST_SESSIONS);
|
|
4449
|
+
}
|
|
4450
|
+
if (QUEUE_BC) {
|
|
4451
|
+
try {
|
|
4452
|
+
QUEUE_BC.onmessage = function (ev) {
|
|
4453
|
+
var d = ev && ev.data;
|
|
4454
|
+
if (!d || d.env !== queueEnvKey()) return;
|
|
4455
|
+
try { syncPendingFromDisk(); } catch (e) {}
|
|
4456
|
+
};
|
|
4457
|
+
} catch (e) {}
|
|
4018
4458
|
}
|
|
4019
4459
|
/* 某条排队任务被用户放弃(划掉 / 删除):磁盘上也要删,否则下次启动它又冒出来。 */
|
|
4020
4460
|
function purgeQueue(id) {
|
|
@@ -4022,6 +4462,7 @@ button.setrow{font-family:inherit}
|
|
|
4022
4462
|
var rest = readQueueAll().filter(function (p) { return p.id !== id; });
|
|
4023
4463
|
localStorage.setItem(QUEUE_KEY, JSON.stringify(rest));
|
|
4024
4464
|
} catch (e) {}
|
|
4465
|
+
queueBroadcast();
|
|
4025
4466
|
}
|
|
4026
4467
|
/* 占位何时能还:等登记表里该空间真的出现这一条(= 它的 core 已落盘)。
|
|
4027
4468
|
判据不能只看 `running`:几十毫秒就收工的任务,落盘那一刻起就不是 running 了,
|
|
@@ -4142,6 +4583,15 @@ button.setrow{font-family:inherit}
|
|
|
4142
4583
|
function gateAsk(info) {
|
|
4143
4584
|
if (TASK_MODE !== 'serial' || !info) return Promise.resolve(true);
|
|
4144
4585
|
var ws = wsKey(info.workspace);
|
|
4586
|
+
/* 服务端有作业队列:真正的「谁先跑」由服务端裁决(多客户端同一 workspace 串行),
|
|
4587
|
+
宿主这里只把这条登记成本地行(关 App 后能恢复),然后立刻放行 ——
|
|
4588
|
+
组件照常发 /desk/ask,服务端会先排队再开跑,排队时推 `queued` 事件回来。 */
|
|
4589
|
+
if (SERVER_JOBS) {
|
|
4590
|
+
var pend = addPending(ws, info, true);
|
|
4591
|
+
ASK_WAITING.push(pend);
|
|
4592
|
+
pend.queuedCb = (typeof info.queued === 'function') ? info.queued : null;
|
|
4593
|
+
return Promise.resolve(true);
|
|
4594
|
+
}
|
|
4145
4595
|
var q = serialQ(ws);
|
|
4146
4596
|
/* 落库是同步写,很便宜;而「点了发送、网还没回、用户退出了」正是丢任务的高危窗口,
|
|
4147
4597
|
所以不等任何延时,先进队列进磁盘。 */
|
|
@@ -4201,6 +4651,19 @@ button.setrow{font-family:inherit}
|
|
|
4201
4651
|
if (!hit) return false;
|
|
4202
4652
|
hit.cancelled = true;
|
|
4203
4653
|
hit.gone = true;
|
|
4654
|
+
/* 服务端作业队列:排队中要把它从服务端撤掉,否则这边删了行、那边照样会开跑。
|
|
4655
|
+
有 job id 走 job 路由;还没拿到(queued 事件未到)就按 request_id 撤。 */
|
|
4656
|
+
if (SERVER_JOBS && (hit.job || hit.requestId || hit.id)) {
|
|
4657
|
+
try {
|
|
4658
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
|
|
4659
|
+
if (hit.job) {
|
|
4660
|
+
fetch(BASE + '/desk/jobs/' + encodeURIComponent(hit.job) + '/cancel', { method: 'POST', headers: hdr }).catch(function () {});
|
|
4661
|
+
} else {
|
|
4662
|
+
fetch(BASE + '/desk/jobs/cancel', { method: 'POST', headers: hdr,
|
|
4663
|
+
body: JSON.stringify({ request_id: hit.requestId || hit.id }) }).catch(function () {});
|
|
4664
|
+
}
|
|
4665
|
+
} catch (e) {}
|
|
4666
|
+
}
|
|
4204
4667
|
dropPending(id);
|
|
4205
4668
|
renderSessions(LAST_SESSIONS);
|
|
4206
4669
|
return true;
|
|
@@ -4250,7 +4713,62 @@ button.setrow{font-family:inherit}
|
|
|
4250
4713
|
list.forEach(function (p) { p.owner = 'host'; });
|
|
4251
4714
|
PENDING = list;
|
|
4252
4715
|
renderSessions(LAST_SESSIONS);
|
|
4253
|
-
|
|
4716
|
+
if (!SERVER_JOBS) {
|
|
4717
|
+
list.forEach(function (p) { enqueueDispatch(p); });
|
|
4718
|
+
return;
|
|
4719
|
+
}
|
|
4720
|
+
/* 服务端作业队列:直接按 request_id 重新提交(幂等),提交成功才算交给服务端。
|
|
4721
|
+
用 Web Locks 串行化「同源多标签页同时恢复」,避免两边各发一遍
|
|
4722
|
+
—— 即使没有 Web Locks,服务端的 request_id 幂等也能兜住。 */
|
|
4723
|
+
var submitAll = function () {
|
|
4724
|
+
list.forEach(function (p) { submitQueuedJob(p); });
|
|
4725
|
+
};
|
|
4726
|
+
try {
|
|
4727
|
+
if (navigator.locks && navigator.locks.request) {
|
|
4728
|
+
navigator.locks.request('magic.queue.restore.' + queueEnvKey(), submitAll);
|
|
4729
|
+
} else {
|
|
4730
|
+
submitAll();
|
|
4731
|
+
}
|
|
4732
|
+
} catch (e) { submitAll(); }
|
|
4733
|
+
}
|
|
4734
|
+
/* 服务端作业提交:受理即删本地行(服务端接管,之后刷新/重开 App 都不会重发)。
|
|
4735
|
+
失败则保留并标「未能启动」,下次连上由 restoreQueue 再试(request_id 不变 → 幂等)。 */
|
|
4736
|
+
function submitQueuedJob(p) {
|
|
4737
|
+
if (!connected()) return Promise.resolve(false);
|
|
4738
|
+
var body = { engine: p.engine, prompt: p.prompt, request_id: p.id, serial: (TASK_MODE === 'serial') };
|
|
4739
|
+
if (p.model) body.model = p.model;
|
|
4740
|
+
if (p.w) body.workspace = p.w;
|
|
4741
|
+
if (p.session) body.session = p.session;
|
|
4742
|
+
if (p.permission) body.permission = p.permission;
|
|
4743
|
+
if (p.team) body.team = p.team;
|
|
4744
|
+
if (p.hosted) body.hosted = true;
|
|
4745
|
+
if (p.attachments && p.attachments.length) body.attachments = p.attachments;
|
|
4746
|
+
var hdr = { 'Content-Type': 'application/json' };
|
|
4747
|
+
if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
|
|
4748
|
+
return fetch(BASE + '/desk/jobs', { method: 'POST', headers: hdr, body: JSON.stringify(body) })
|
|
4749
|
+
.then(function (r) {
|
|
4750
|
+
if (!r.ok) {
|
|
4751
|
+
return r.text().then(function (txt) {
|
|
4752
|
+
var why = '';
|
|
4753
|
+
try { var j = JSON.parse(txt); why = (j && j.error) || ''; } catch (e) {}
|
|
4754
|
+
throw new Error(why || ('HTTP ' + r.status));
|
|
4755
|
+
});
|
|
4756
|
+
}
|
|
4757
|
+
return r.json();
|
|
4758
|
+
})
|
|
4759
|
+
.then(function (out) {
|
|
4760
|
+
if (!out || !out.job) return false;
|
|
4761
|
+
if (p.stalled) { p.stalled = false; p.stalledReason = ''; }
|
|
4762
|
+
dropPending(p.id);
|
|
4763
|
+
if (typeof loadSessions === 'function') loadSessions(true);
|
|
4764
|
+
return true;
|
|
4765
|
+
})
|
|
4766
|
+
.catch(function (e) {
|
|
4767
|
+
p.stalled = true;
|
|
4768
|
+
p.stalledReason = (e && e.message) || '网络错误';
|
|
4769
|
+
renderSessions(LAST_SESSIONS);
|
|
4770
|
+
return false;
|
|
4771
|
+
});
|
|
4254
4772
|
}
|
|
4255
4773
|
function enqueueDispatch(p) {
|
|
4256
4774
|
var ws = wsKey(p.w);
|
|
@@ -4301,6 +4819,8 @@ button.setrow{font-family:inherit}
|
|
|
4301
4819
|
/* 团队配置是在发送那一刻快照下来的(见 addPending):重发必须带上,
|
|
4302
4820
|
否则这条排队任务会被当成普通单任务跑。 */
|
|
4303
4821
|
if (p.team) payload.team = p.team;
|
|
4822
|
+
/* 托管自动发的那一轮:重发也要把标识带上(历史回放才能继续画成「托管发送」)。 */
|
|
4823
|
+
if (p.hosted) payload.hosted = true;
|
|
4304
4824
|
if (p.attachments && p.attachments.length) payload.attachments = p.attachments;
|
|
4305
4825
|
return new Promise(function (resolve) {
|
|
4306
4826
|
var done = false, handle = null, t0 = Date.now();
|
|
@@ -4555,13 +5075,19 @@ button.setrow{font-family:inherit}
|
|
|
4555
5075
|
var isCustom = CUSTOM_SPACES.indexOf(w) >= 0;
|
|
4556
5076
|
hRow(body, basename(w), isCustom ? '自定义' : null, function () { WS = w; saveWs(); applyWs(); closeHostSheet(); paintRunSettings(); }, false, WS === w);
|
|
4557
5077
|
});
|
|
4558
|
-
hRow(body, '自定义路径…', '+',
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
5078
|
+
hRow(body, '自定义路径…', '+', openCustomPathSheet);
|
|
5079
|
+
});
|
|
5080
|
+
}
|
|
5081
|
+
/* 自定义路径:复用单行输入抽屉(见 openTextInputSheet 上方的 prompt 说明)。 */
|
|
5082
|
+
function openCustomPathSheet() {
|
|
5083
|
+
openTextInputSheet({
|
|
5084
|
+
title: '自定义路径',
|
|
5085
|
+
value: SPACES_ROOT || DW || '',
|
|
5086
|
+
placeholder: '/绝对路径',
|
|
5087
|
+
emptyHint: '请输入工作空间的绝对路径'
|
|
5088
|
+
}, function (p) {
|
|
5089
|
+
if (CUSTOM_SPACES.indexOf(p) < 0) { CUSTOM_SPACES.push(p); saveSpaces(); }
|
|
5090
|
+
WS = p; saveWs(); applyWs(); loadSessions(); paintRunSettings();
|
|
4565
5091
|
});
|
|
4566
5092
|
}
|
|
4567
5093
|
|
|
@@ -4570,6 +5096,40 @@ button.setrow{font-family:inherit}
|
|
|
4570
5096
|
会话里输入框上方的级联切换也会回写这里(见 mountConversation 的 onPick)——
|
|
4571
5097
|
与 PC 端「选过一次就一直用」的语义一致,「我的」里显示的就是下次新建会用的那一组。
|
|
4572
5098
|
⚠️ 模型必须挂在引擎下:engine 为空时 model 一律清空,不留孤儿模型。 */
|
|
5099
|
+
/* 建议引擎:只认**显式配置过**的值;没有 / 空串 = 不开启建议功能(默认)。
|
|
5100
|
+
不能回落到 RUN_PREF.engine —— 那等于「配了默认编码引擎就自动有建议」,
|
|
5101
|
+
与用户「默认不配置就不开启」相反。 */
|
|
5102
|
+
function loadSuggestPref() {
|
|
5103
|
+
var o = { engine: '', model: '' };
|
|
5104
|
+
try {
|
|
5105
|
+
o.engine = String(localStorage.getItem('magic.suggest.engine') || '').trim();
|
|
5106
|
+
o.model = String(localStorage.getItem('magic.suggest.model') || '').trim();
|
|
5107
|
+
} catch (e) {}
|
|
5108
|
+
if (!o.engine) o.model = '';
|
|
5109
|
+
return o;
|
|
5110
|
+
}
|
|
5111
|
+
function saveSuggestPref() {
|
|
5112
|
+
try {
|
|
5113
|
+
localStorage.setItem('magic.suggest.engine', SUGGEST.engine || '');
|
|
5114
|
+
localStorage.setItem('magic.suggest.model', SUGGEST.model || '');
|
|
5115
|
+
} catch (e) {}
|
|
5116
|
+
schedulePushPrefs();
|
|
5117
|
+
paintSuggestPref();
|
|
5118
|
+
}
|
|
5119
|
+
function paintSuggestPref() {
|
|
5120
|
+
var e = document.getElementById('suggestEngine');
|
|
5121
|
+
var m = document.getElementById('suggestModel');
|
|
5122
|
+
if (e) e.textContent = SUGGEST.engine || '不开启';
|
|
5123
|
+
if (m) m.textContent = SUGGEST.engine ? (qaModelText(SUGGEST.model) || '引擎默认') : '跟随引擎';
|
|
5124
|
+
}
|
|
5125
|
+
function applySuggestToLive() {
|
|
5126
|
+
if (conv && typeof conv.setSuggestEngine === 'function') {
|
|
5127
|
+
conv.setSuggestEngine(SUGGEST.engine, SUGGEST.model);
|
|
5128
|
+
}
|
|
5129
|
+
paintSuggestPref();
|
|
5130
|
+
paintRunSettings();
|
|
5131
|
+
}
|
|
5132
|
+
|
|
4573
5133
|
function loadRunPref() {
|
|
4574
5134
|
var o = { engine: '', model: '' };
|
|
4575
5135
|
try {
|
|
@@ -4945,6 +5505,67 @@ button.setrow{font-family:inherit}
|
|
|
4945
5505
|
el.style.textAlign = 'left';
|
|
4946
5506
|
return el;
|
|
4947
5507
|
}
|
|
5508
|
+
/* 「我的 → 建议引擎」:单列,首项「不开启」。不展示模型 —— 建议是纯文本短任务,
|
|
5509
|
+
选模型只会让界面更复杂;建议生成对 llm 会用不思考的变体作默认值。 */
|
|
5510
|
+
/* 「我的 → 建议引擎 / 模型」:与默认引擎同一个二列抽屉(左引擎 · 右模型)。
|
|
5511
|
+
首项「不开启」;点左列只浏览、点右列才落定。llm 放最前,模型为空时建议生成用 no-think 变体作默认值。 */
|
|
5512
|
+
function openSuggestEnginePicker() {
|
|
5513
|
+
openHostSheet('建议引擎 / 模型', function (body) {
|
|
5514
|
+
body.innerHTML = '';
|
|
5515
|
+
var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
|
|
5516
|
+
hint.style.textAlign = 'left';
|
|
5517
|
+
body.appendChild(hint);
|
|
5518
|
+
fetchEngines().then(function (list) {
|
|
5519
|
+
if (hint.parentNode) hint.remove();
|
|
5520
|
+
var stale = enginesStaleHint();
|
|
5521
|
+
if (stale) body.appendChild(stale);
|
|
5522
|
+
var avail = (list || []).filter(function (x) { return x && x.ok; });
|
|
5523
|
+
/* 推荐的放最上面:llm 是建议这条旁路最快的引擎。 */
|
|
5524
|
+
avail.sort(function (a, b) {
|
|
5525
|
+
if (a.engine === 'llm') return -1;
|
|
5526
|
+
if (b.engine === 'llm') return 1;
|
|
5527
|
+
return String(a.engine).localeCompare(String(b.engine));
|
|
5528
|
+
});
|
|
5529
|
+
if (!avail.length) {
|
|
5530
|
+
var d = hEl('div', 'history__hint',
|
|
5531
|
+
'没有可用引擎:先在这台机器上装一个(终端跑 magic-agent --engines 会给出安装命令)。');
|
|
5532
|
+
d.style.textAlign = 'left';
|
|
5533
|
+
body.appendChild(d);
|
|
5534
|
+
return;
|
|
5535
|
+
}
|
|
5536
|
+
buildEngineCascade(body, avail, {
|
|
5537
|
+
prefs: SUGGEST,
|
|
5538
|
+
pick: pickSuggestPref,
|
|
5539
|
+
offLabel: '不开启',
|
|
5540
|
+
offDesc: '不生成下一轮建议(默认)',
|
|
5541
|
+
modelDefaultDesc: '不指定模型(建议生成用 no-think 变体)',
|
|
5542
|
+
});
|
|
5543
|
+
/* 建议生成一律不思考。各引擎都走官方关闭参数;某些模型路由不支持时
|
|
5544
|
+
才在超时/报错后降级 llm,不预先吞掉用户的选择。 */
|
|
5545
|
+
var nk = hEl('div', 'history__hint',
|
|
5546
|
+
'建议生成固定关闭思考:claude --thinking disabled、codex effort none、llm reasoning_effort=none(显式模型不换;空模型用 no-think 变体)、codebuddy alwaysThinkingEnabled=false、dsh reasoningEffort=off;引擎失败才降级 llm。');
|
|
5547
|
+
nk.style.textAlign = 'left';
|
|
5548
|
+
body.appendChild(nk);
|
|
5549
|
+
}, function (e) {
|
|
5550
|
+
hint.textContent = '拉取引擎清单失败:' + ((e && e.message) || e);
|
|
5551
|
+
var retry = hEl('button', 'pick__citem', '重试');
|
|
5552
|
+
retry.type = 'button';
|
|
5553
|
+
retry.onclick = function () { openSuggestEnginePicker(); };
|
|
5554
|
+
body.appendChild(retry);
|
|
5555
|
+
});
|
|
5556
|
+
});
|
|
5557
|
+
}
|
|
5558
|
+
function pickSuggestPref(engine, model) {
|
|
5559
|
+
SUGGEST.engine = String(engine || '').trim();
|
|
5560
|
+
SUGGEST.model = (SUGGEST.engine && model) ? String(model).trim() : '';
|
|
5561
|
+
saveSuggestPref();
|
|
5562
|
+
applySuggestToLive();
|
|
5563
|
+
closeHostSheet();
|
|
5564
|
+
toast(SUGGEST.engine
|
|
5565
|
+
? ('建议引擎:' + SUGGEST.engine + ' · ' + (qaModelText(SUGGEST.model) || '引擎默认模型'))
|
|
5566
|
+
: '已关闭建议功能');
|
|
5567
|
+
}
|
|
5568
|
+
|
|
4948
5569
|
function openDefaultEnginePicker() {
|
|
4949
5570
|
openHostSheet('默认引擎 / 模型', function (body) {
|
|
4950
5571
|
function load() {
|
|
@@ -4977,8 +5598,16 @@ button.setrow{font-family:inherit}
|
|
|
4977
5598
|
});
|
|
4978
5599
|
}
|
|
4979
5600
|
/* 二列级联(左引擎 · 右该引擎的模型),与对话框里那套同构:
|
|
4980
|
-
点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。
|
|
4981
|
-
|
|
5601
|
+
点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。
|
|
5602
|
+
默认引擎与建议引擎共用;o 可换目标 / 落定回调 / 关闭项文案。 */
|
|
5603
|
+
function buildEngineCascade(body, avail, o) {
|
|
5604
|
+
o = o || {};
|
|
5605
|
+
var prefs = o.prefs || RUN_PREF;
|
|
5606
|
+
var pick = o.pick || pickRunPref;
|
|
5607
|
+
var offLabel = o.offLabel || '自动';
|
|
5608
|
+
var offDesc = o.offDesc || '首个可用引擎';
|
|
5609
|
+
var modelDefaultDesc = o.modelDefaultDesc || '不指定模型,由引擎自己挑';
|
|
5610
|
+
|
|
4982
5611
|
var wrap = hEl('div', 'pick__cols');
|
|
4983
5612
|
var left = hEl('div', 'pick__col');
|
|
4984
5613
|
var right = hEl('div', 'pick__col pick__col--r');
|
|
@@ -4986,24 +5615,24 @@ button.setrow{font-family:inherit}
|
|
|
4986
5615
|
body.appendChild(wrap);
|
|
4987
5616
|
|
|
4988
5617
|
var cur = avail[0];
|
|
4989
|
-
avail.forEach(function (en) { if (en.engine ===
|
|
5618
|
+
avail.forEach(function (en) { if (en.engine === prefs.engine) cur = en; });
|
|
4990
5619
|
|
|
4991
5620
|
function paint() {
|
|
4992
5621
|
left.innerHTML = ''; right.innerHTML = '';
|
|
4993
|
-
var
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
left.appendChild(
|
|
5622
|
+
var off = hEl('button', 'pick__citem' + (!prefs.engine ? ' is-on' : ''));
|
|
5623
|
+
off.type = 'button';
|
|
5624
|
+
off.appendChild(hEl('span', 'pick__cl', offLabel));
|
|
5625
|
+
off.appendChild(hEl('span', 'pick__cd', offDesc));
|
|
5626
|
+
off.onclick = function () { pick('', ''); };
|
|
5627
|
+
left.appendChild(off);
|
|
4999
5628
|
|
|
5000
5629
|
avail.forEach(function (en) {
|
|
5001
5630
|
var n = (en.models || []).length;
|
|
5002
|
-
/* is-on =
|
|
5003
|
-
|
|
5631
|
+
/* is-on = 已保存的值(浅蓝实底);is-cur = 右列此刻在浏览谁(左侧细条,无底色)——
|
|
5632
|
+
两者分开,关闭项选中时不会出现两个一样的选中块。 */
|
|
5004
5633
|
var b = hEl('button', 'pick__citem'
|
|
5005
|
-
+ (
|
|
5006
|
-
+ (en.engine === cur.engine &&
|
|
5634
|
+
+ (prefs.engine === en.engine ? ' is-on' : '')
|
|
5635
|
+
+ (en.engine === cur.engine && prefs.engine !== en.engine ? ' is-cur' : ''));
|
|
5007
5636
|
b.type = 'button';
|
|
5008
5637
|
b.appendChild(hEl('span', 'pick__cl', en.engine));
|
|
5009
5638
|
b.appendChild(hEl('span', 'pick__cd', n ? (n + ' 个模型') : '引擎默认'));
|
|
@@ -5011,20 +5640,20 @@ button.setrow{font-family:inherit}
|
|
|
5011
5640
|
left.appendChild(b);
|
|
5012
5641
|
});
|
|
5013
5642
|
|
|
5014
|
-
var d = hEl('button', 'pick__citem' + (
|
|
5643
|
+
var d = hEl('button', 'pick__citem' + (prefs.engine === cur.engine && !prefs.model ? ' is-on' : ''));
|
|
5015
5644
|
d.type = 'button';
|
|
5016
5645
|
d.appendChild(hEl('span', 'pick__cl', '引擎默认'));
|
|
5017
|
-
d.appendChild(hEl('span', 'pick__cd',
|
|
5018
|
-
d.onclick = function () {
|
|
5646
|
+
d.appendChild(hEl('span', 'pick__cd', modelDefaultDesc));
|
|
5647
|
+
d.onclick = function () { pick(cur.engine, ''); };
|
|
5019
5648
|
right.appendChild(d);
|
|
5020
5649
|
|
|
5021
5650
|
(cur.models || []).forEach(function (mm) {
|
|
5022
|
-
var b = hEl('button', 'pick__citem' + (
|
|
5651
|
+
var b = hEl('button', 'pick__citem' + (prefs.engine === cur.engine && prefs.model === mm ? ' is-on' : ''));
|
|
5023
5652
|
b.type = 'button';
|
|
5024
|
-
/* 显示剥掉 `custom-local:` 前缀(`mm` 本身是完整 id,选中时原样存进
|
|
5653
|
+
/* 显示剥掉 `custom-local:` 前缀(`mm` 本身是完整 id,选中时原样存进 prefs),
|
|
5025
5654
|
倍率挂同行右侧。 */
|
|
5026
5655
|
modelItem(b, cur.engine, mm);
|
|
5027
|
-
b.onclick = function () {
|
|
5656
|
+
b.onclick = function () { pick(cur.engine, mm); };
|
|
5028
5657
|
right.appendChild(b);
|
|
5029
5658
|
});
|
|
5030
5659
|
}
|
|
@@ -5121,6 +5750,8 @@ button.setrow{font-family:inherit}
|
|
|
5121
5750
|
/* 权限 / 团队沿用会话组件里那唯一一份选择器(浮层盖在本页之上);选完就地重画。 */
|
|
5122
5751
|
on('btnRsPermission', function () { if (conv && conv.openPermissionPicker) conv.openPermissionPicker(); });
|
|
5123
5752
|
on('btnRsTeam', function () { if (conv && conv.openTeamPicker) conv.openTeamPicker(); });
|
|
5753
|
+
/* 会话托管(2026-10-23,按会话):只有开 / 关两档,直接翻当前这条会话。 */
|
|
5754
|
+
on('btnRsHosting', toggleHostingHere);
|
|
5124
5755
|
/* 选择器由会话组件管理、没有回调通知宿主,所以用一次点击兜底重画:
|
|
5125
5756
|
本页可见时任何一次点击(包括选择器里点中一行)之后重读状态。 */
|
|
5126
5757
|
document.addEventListener('click', function () {
|
|
@@ -5190,6 +5821,8 @@ button.setrow{font-family:inherit}
|
|
|
5190
5821
|
on('btnNotify', openNotifySettings);
|
|
5191
5822
|
on('btnDefaultEngine', openDefaultEnginePicker);
|
|
5192
5823
|
on('btnDefaultModel', openDefaultEnginePicker);
|
|
5824
|
+
on('btnSuggestEngine', openSuggestEnginePicker);
|
|
5825
|
+
on('btnSuggestModel', openSuggestEnginePicker);
|
|
5193
5826
|
/* 团队模式配置(「我的」→ 子页面):计划 + 7 个角色,行由 paintTeamRoles 动态渲染,
|
|
5194
5827
|
行的点击在渲染时绑定(见 addTeamRouteRow / addTeamRuleRow),这里只留入口与返回。 */
|
|
5195
5828
|
on('btnTeamPage', showTeamPage);
|
|
@@ -5201,20 +5834,9 @@ button.setrow{font-family:inherit}
|
|
|
5201
5834
|
if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
|
|
5202
5835
|
if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
|
|
5203
5836
|
|
|
5204
|
-
/*
|
|
5205
|
-
|
|
5206
|
-
|
|
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
|
-
}
|
|
5837
|
+
/* 会话托管(2026-10-23 起按会话):入口在会话页 → 任务运行设置(见 btnRsHosting)。
|
|
5838
|
+
「我的」里不再有全局开关 —— 曾经那枚 `tglHosting` 写的是设备级 `magic.hosting`,
|
|
5839
|
+
改一条会话会把所有会话都改掉(用户 2026-10-23:「跟团队模式一样…不再是全局」)。 */
|
|
5218
5840
|
|
|
5219
5841
|
/* ══════════ 网络代理(VPN 隧道)══════════
|
|
5220
5842
|
把整机流量经隧道送到桌面端的 SOCKS5 出口:TUN → 内核 → 桌面端 → 互联网。
|
|
@@ -5352,7 +5974,9 @@ button.setrow{font-family:inherit}
|
|
|
5352
5974
|
var tok = (lastUrl === injUrl && injectedToken)
|
|
5353
5975
|
? injectedToken : (sc.token || injectedToken);
|
|
5354
5976
|
if (!tok) break;
|
|
5355
|
-
BASE = sc.url; TOKEN = tok;
|
|
5977
|
+
BASE = sc.url; TOKEN = tok;
|
|
5978
|
+
sc = ensureEnvId(sc) || sc; // 老记录补 eid,并把按地址分区的旧队列/配置槽迁过来
|
|
5979
|
+
ACTIVE_ID = sc.id;
|
|
5356
5980
|
showHistory(sc.host || connHost(sc.url));
|
|
5357
5981
|
restoreQueue();
|
|
5358
5982
|
resumed = true;
|
|
@@ -5365,6 +5989,7 @@ button.setrow{font-family:inherit}
|
|
|
5365
5989
|
/* 把当前这条(壳注入的)连接落成环境,名字默认「公司远程」——
|
|
5366
5990
|
否则它只是一条改不了名、也留不住的「未命名」。 */
|
|
5367
5991
|
seedCurrentConn();
|
|
5992
|
+
ensureEnvId(activeConn()); // 种出来的这条也补 eid;seedoff 没落盘时按老键回退
|
|
5368
5993
|
showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
|
|
5369
5994
|
restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
|
|
5370
5995
|
} else if (!resumed) {
|
|
@@ -5375,6 +6000,7 @@ button.setrow{font-family:inherit}
|
|
|
5375
6000
|
all.sort(function (a, b) { return (b.at || 0) - (a.at || 0); });
|
|
5376
6001
|
var pick = all[0];
|
|
5377
6002
|
if (pick) {
|
|
6003
|
+
pick = ensureEnvId(pick) || pick;
|
|
5378
6004
|
BASE = pick.url; TOKEN = pick.token || ''; ACTIVE_ID = pick.id;
|
|
5379
6005
|
try { localStorage.setItem(ACTIVE_ENV_KEY, pick.url); } catch (e) {}
|
|
5380
6006
|
showHistory(pick.host || connHost(pick.url));
|