magic-agent 2.6.0 → 2.6.3
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 +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- 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/npm/install.js +11 -0
- package/package.json +8 -3
|
@@ -38,43 +38,127 @@
|
|
|
38
38
|
<!-- 脚本页(「发现 → 脚本」)是独立子页面:样式在 scripts.css、结构/逻辑在 scripts.js。
|
|
39
39
|
本文件只给两个空容器与一次 mount,不再混入这一页的实现。 -->
|
|
40
40
|
<link rel="stylesheet" href="scripts.css">
|
|
41
|
+
<!-- 应用页(「发现 → 应用」)是独立子页面:样式在 apps.css、结构/逻辑在 apps.js。 -->
|
|
42
|
+
<link rel="stylesheet" href="apps.css">
|
|
41
43
|
<style>
|
|
42
44
|
/* ===== 令牌:几何/字阶照 ZCode 安卓端;颜色契约见下 =====
|
|
43
45
|
⚠️ 默认 = 暗色。浅色仍逐字段对齐 ZCode Color.kt;
|
|
44
46
|
暗色自 2026-10-24 起改为掌天瓶墨绿系(用户:「这个深色跟掌天瓶主色不搭」)。 */
|
|
45
47
|
:root{
|
|
46
48
|
color-scheme:dark;
|
|
47
|
-
/* 2026-10-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
/* ── 安全区下沿:**走变量、不走 env()**(2026-10-26 用户:「常规状态和新建任务输入的时候
|
|
50
|
+
底栏高度样式都不一致」)──
|
|
51
|
+
`env(safe-area-inset-bottom)` 在键盘弹起时会**从 34 变成 0**(Android WebView 的
|
|
52
|
+
adjustResize 把导航栏让位给键盘,这条 inset 就归零了)。于是同一个底栏:
|
|
53
|
+
常规(picker 收起)= 56 + 34 = 90px,下沿还留出 34px 的栏面给 home indicator;
|
|
54
|
+
一点输入框 = 56 + 0 = 56px,栏体凭空矮 34px、图标整体往下掉 17px,
|
|
55
|
+
弧线也被挤到图标上 —— 就是「两种状态样式不一样」。
|
|
56
|
+
现在统一读 `--sab`:它由页脚那段脚本量出来并**在键盘弹起期间冻结**,
|
|
57
|
+
所以两种状态下栏体永远是 56 + 34。想知道它怎么冻的,看 `--sab` 那段脚注。 */
|
|
58
|
+
--sab:env(safe-area-inset-bottom, 0px);
|
|
59
|
+
/* ── 底栏三个几何常量:全部**从栏底往上量**,彼此不推算 ──
|
|
60
|
+
(2026-10-26 用户:「底栏高度固定 图标距离底部的距离固定 弧线距离底部的距离也固定」)
|
|
61
|
+
|
|
62
|
+
以前这三个数是**互相算出来**的,所以改一个会连带漂三个:
|
|
63
|
+
· 栏高 = `56px + sab`(写死在 height 里,56 这个数字散落在注释和 composer 内边距中)
|
|
64
|
+
· 图标 = 在 56 的内容区里 `align-items:center` —— 距栏底 = (56 − 图标高)/2,
|
|
65
|
+
于是 26px 的器物离底 15、28px 的「我的」离底 14,**四枚不在一条线上**;
|
|
66
|
+
· 弧线 = 由 `AMP=14` 反推下沉 10.5,距栏底 = 56 − 10.5,而 56 又是上面那个数。
|
|
67
|
+
|
|
68
|
+
现在三个都独立、都从同一条基准(栏底)量起:
|
|
69
|
+
--bar-h 栏体内容区高度(不含安全区)→ 栏总高 = --bar-h + --sab
|
|
70
|
+
--ic-gap 图标**底边** → 栏底(四枚器物共用一条基线,与图标自身尺寸无关)
|
|
71
|
+
--arc-dip 弧线**最深处** → 栏底(端点恒定在栏顶,见 .bararc 脚本)
|
|
72
|
+
⚠️ 改 --bar-h 只会把栏顶抬高/压低,图标与弧线相对栏底的位置一根毫毛不动。
|
|
73
|
+
⚠️⚠️ --ic-gap 与 --arc-dip 的关系**不能只看弧线最深处**(2026-10-26 踩过):
|
|
74
|
+
弧是「中间最低、两端最高」的浅盆,最深处落在 **FAB 中心的 x=W/2**,那里没有图标;
|
|
75
|
+
四枚图标在 x≈10%/30%/72%/92% 处,弧线到那里已经抬起来了。
|
|
76
|
+
所以**要逐个图标、按它自己的 x 求弧线 y** 再和「--ic-gap + 图标高」比:
|
|
77
|
+
390 宽实测(--ic-gap:15):
|
|
78
|
+
x=39.5 弧 52.0 图标顶 41.0 → 净空 11.0
|
|
79
|
+
x=118.5 弧 47.1 图标顶 41.0 → 净空 6.1 ← 最紧
|
|
80
|
+
x=281.5 弧 47.1 图标顶 41.0 → 净空 6.1 ← 最紧
|
|
81
|
+
x=360.5 弧 52.0 图标顶 43.0 → 净空 9.0
|
|
82
|
+
⚠️ 曾经误把「最深处」当基准,把 --arc-dip 降到 34 想「让弧更明显」——
|
|
83
|
+
结果 x=118.5/281.5 处的弧落到 37.3,**横穿两枚图标 3.7px**(用户:「弧线压住图标了」)。
|
|
84
|
+
那个误区是:以为最深处是全场最低点、只要它高于图标顶就安全;
|
|
85
|
+
实际上「距栏底」这个量**越小越低**,拿最深处(45.5)去比图标顶(41)看着危险,
|
|
86
|
+
而真正要防的是两侧上升段。
|
|
87
|
+
要改弧深,用 /tmp/pericon.mjs 那种**逐图标求弧线 y** 的算法复验,别用单一最深处。
|
|
88
|
+
若要弧更明显,同时下压 --ic-gap 或抬高 --bar-h,让「图标顶」也跟着抬。
|
|
89
|
+
⚠️ 改 --ic-gap / 图标尺寸 / --bar-h,这里四个 x 的净空要重新算一遍。 */
|
|
90
|
+
--bar-h:56px;
|
|
91
|
+
--ic-gap:15px;
|
|
92
|
+
--arc-dip:45.5px;
|
|
93
|
+
/* 「+」按钮底边 → 栏底。井半径 37 + 按钮半径 34 = 71,两者同心(见 .tabbar::before)。 */
|
|
94
|
+
--fab-gap:12px;
|
|
95
|
+
/* 暗色 = **掌天瓶墨绿系**(2026-10-24:「这个深色跟掌天瓶主色不搭」)。
|
|
96
|
+
⚠️ 2026-10-25 修的是**可辨识度**,不是色相:原来整套是 H≈157 但**饱和度只有 25%**的
|
|
97
|
+
深灰绿(#0B1512/#14211C/#20322A)。那个饱和度在手机 OLED 上直接读成「黑」——
|
|
98
|
+
用户连着两轮反馈「深色皮肤还是没变」。逐像素实测当时改动只覆盖 1.76% 像素,
|
|
99
|
+
全落在 FAB 和警告框上,背景一个像素没动。
|
|
100
|
+
现在做两件事,缺一不可:
|
|
101
|
+
① **把绿提到看得见**:canvas S 25%→60%、raised/subtle S 22~25%→37~38%。
|
|
102
|
+
低明度下饱和度是唯一能读出「这是墨绿不是黑」的东西。
|
|
103
|
+
② **把层次拉开**:canvas↔raised 1.12(几乎同色,卡片立不起来)→ **1.34**,
|
|
104
|
+
raised↔subtle 1.23 → **1.33**。原来整屏读作一块平板,这才是「没变」的真根因。
|
|
105
|
+
明度仍压在 L4~L21,不刺眼;正文对 canvas 17.4、muted 对 raised 7.0,都远超 AA。
|
|
106
|
+
色相锁在 H142~154 一个族内,不引入第二个色源。
|
|
107
|
+
|
|
108
|
+
⚠️ 2026-10-25 第二轮(用户:「深色皮肤继续完成全部配色优化」)—— 补上面这版没顾到的地方,
|
|
109
|
+
canvas 一个像素没动(仍是 #06180F),只校色相与「卡面本身」:
|
|
110
|
+
① 半透明色块会串色相:alpha 叠加是平均,暖色压在墨绿底上会被拉回绿 —— 实测
|
|
111
|
+
warning 22% 叠 raised 出 H60 橄榄、fail 18% 出 H38 土黄、stop(暖灰)出 H138 绿灰:
|
|
112
|
+
三个「状态底」互相读不出区别,还和旁边真正的绿混在一起。现在状态底按色相显式给
|
|
113
|
+
(不再写 rgba(前景色,.18)):叠完分别落在 warning H65/60、fail H8/18、stop H150/152,
|
|
114
|
+
与各自前景色同族(叠在 canvas 上比叠在卡面上更靠近本色 —— 卡面自己有 36% 的绿)。
|
|
115
|
+
② 同色相三档收成一个族:原 canvas H150 / raised H144 / subtle H142,差 8° 在大色面上
|
|
116
|
+
会读成「两种绿」—— 统一 H150;code/canvas、bubble/raised 只差明度不再差色相。
|
|
117
|
+
顺带把卡面抬到 L17(#1B3A2B):canvas↔raised 1.34 → 1.47,卡片立得起来一档。
|
|
118
|
+
③ --running 与 --success 同为 #22C55E:按 H142/45% 族提亮成 #35D972(列表图标、
|
|
119
|
+
脚本页徽标、插件状态都用它),对 canvas 9.9 / raised 6.7,也不再和 --success 撞。
|
|
120
|
+
④ --faint 在 raised 上只有 4.51(勉强贴着 AA 正文线 4.5):第三级文字(时间、模块计数、
|
|
121
|
+
「我的」副说明)偏淡 —— 提到 #86A294(raised 4.51 / canvas 6.64)。⚠️ 卡面抬到 L17
|
|
122
|
+
之后它仍然只能压到 4.51:再往上就要和 --muted(6.37)撞档,所以这是「三级文字」的
|
|
123
|
+
地板 —— 小于 12px 的字别用 --faint,用 --muted。
|
|
124
|
+
⑤ --tint-run 原来偏青(H164),跟运行图标 / 玉青对不上 —— 换成 Green A700 #00C853 20%:
|
|
125
|
+
叠出 H149 S47 的玉底,和主色同族。
|
|
126
|
+
⑥ 错误卡三色(conversation.css 的 .notice / .permchip / .boot-err)是纯红 #FCA5A5,
|
|
127
|
+
在一屏墨绿里是唯一的暖色、也偏刺眼:换成同明度的硃砂珊瑚 #F9B4A4(H12)/ #F58E7A(H14),
|
|
128
|
+
红仍读得出是红;底 #3A1414 压成更正的暗硃砂(H357 → H0)。 */
|
|
129
|
+
--canvas:#06180F; /* background = 掌天瓶墨绿底(不动) */
|
|
52
130
|
--ink:#F2FBF5; --ink-soft:#DCEAE2; --muted:#A7BFB2; --faint:#7C9488;
|
|
53
|
-
--border:rgba(
|
|
131
|
+
--border:rgba(71,184,128,.18); --subtle:#213F2F; --raised:#1B3A2B;
|
|
54
132
|
--primary-action:#6EE7A8; --on-primary-action:#07130E; /* primary = 掌天瓶玉青(原 ZCode 品牌蓝) */
|
|
55
133
|
/* 底栏「+」凹槽的两件套,**必须按主题分别给**(`--fab-well` 槽底,`--fab-notch` 接触影满强度色):
|
|
56
|
-
槽底不能用 `--canvas` —— 那是「页面底色」,暗色下 #
|
|
134
|
+
槽底不能用 `--canvas` —— 那是「页面底色」,暗色下 #06180F 比栏面暗 11 阶,
|
|
57
135
|
整圈会读成一道黑箍(比浮起来更假)。槽底该是**栏面稍暗一档**的同一个色系:
|
|
58
|
-
暗色 #
|
|
136
|
+
暗色 raised #1B3A2B → #152E21(玉盘墨绿,压 4 阶),浅色 #FFFFFF → #EEF2F7(同色相压 8 阶,必须够,
|
|
59
137
|
因为 248/250/252 与 255 只差 7 阶,直接用 canvas 等于没画)。 */
|
|
60
|
-
--fab-well:#
|
|
138
|
+
--fab-well:#152E1F; --fab-notch:rgba(0,0,0,.28);
|
|
61
139
|
--app-shadow:rgba(0,0,0,.5);
|
|
140
|
+
/* 底栏弧面的填充(见 .bararc)。深色给**比自己更暗一档**的玉面:弧线投在它上面读得出
|
|
141
|
+
「一片面浮在页面之上」。浅色两个块都不给 → 落到 --raised(白栏面)+ 收掉投影,只留弧线。 */
|
|
142
|
+
--bar-fill:color-mix(in srgb,var(--raised) 82%,var(--canvas));
|
|
62
143
|
|
|
63
144
|
/* 用户气泡 = surface/foreground:ZCode 的用户消息是**中性表面**不是品牌色气泡 */
|
|
64
|
-
--bubble:#
|
|
65
|
-
--code:#
|
|
145
|
+
--bubble:#1B3B27; --bubble-text:#F2FBF5;
|
|
146
|
+
--code:#04110B; --status-neutral:#213F2F; --status-neutral-text:#A7BFB2;
|
|
66
147
|
--activity:rgba(67,201,138,.14);
|
|
67
148
|
/* 底栏选中态 = 玉青(2026-10-24 用户定稿「修仙器物图标 + 玉青选中」):
|
|
68
149
|
与掌天瓶绿同源但比 --q-bottle 亮一档,暗底上才压得住;浅底用沉一档的玉青。 */
|
|
69
150
|
--tab-on:#6EE7A8; --tab-on-bg:rgba(34,197,94,.16);
|
|
70
151
|
--tab-off:#A7BFB2;
|
|
71
152
|
/* 底栏图标的双色点缀(2026-10-24「大胆设计」):鎏金 + 丹血朱。
|
|
72
|
-
25px 上颜色本身就是最强的辨识信号 —— 单色描边怎么调都读不出「法器」。
|
|
153
|
+
25px 上颜色本身就是最强的辨识信号 —— 单色描边怎么调都读不出「法器」。
|
|
154
|
+
⚠️ 2026-10-25 第二轮量过丹血朱:原 #E23B2E 是 H5 的响亮红,压在墨绿底上只有 4.86 ——
|
|
155
|
+
对这种**只有 25px、只用来点缀**的图标色够用(对比度门限针对文字,不针对图形点缀),
|
|
156
|
+
所以保留没动。它只在底栏图标里出现,不参与任何文字 / 面板配色。 */
|
|
73
157
|
--q-gold:#E8C77A; --q-blood:#E23B2E;
|
|
74
158
|
|
|
75
|
-
--md-body:#F2FBF5; --md-muted:#A7BFB2; --md-border:#
|
|
159
|
+
--md-body:#F2FBF5; --md-muted:#A7BFB2; --md-border:#2B5839; --md-code-bg:#1C3C27;
|
|
76
160
|
--link:#6EE7A8; --prompt:#6EE7A8; --shimmer:#F2FBF5;
|
|
77
|
-
--panel:#
|
|
161
|
+
--panel:#173322; --panel-border:#2B5839;
|
|
78
162
|
|
|
79
163
|
/* 语义三态必须互相区分的(2026-10-25 深色配色量化发现):
|
|
80
164
|
`--running` 绿 / `--queued` 金 / `--warning` 琥珀 三个在屏幕上是**并排出现**的
|
|
@@ -85,21 +169,39 @@
|
|
|
85
169
|
两者拉开到 1.95(色相差也从 1° 拉到 10°);同时对页底仍有 5.83 —— 警告文字照读不误。
|
|
86
170
|
这也顺带治好了需求页那一整块 #FBBF24 淡底横幅(占屏 13.7% 像素)的刺眼问题。 */
|
|
87
171
|
--accent:#6EE7A8; --warning:#D97706; --success:#22C55E;
|
|
88
|
-
|
|
89
|
-
|
|
172
|
+
/* 错误三色(2026-10-25 第二轮):纯红 #FCA5A5 / #F87171 → 硃砂珊瑚(同明度,H12 / H14)。
|
|
173
|
+
理由见上面第二轮第 ⑥ 条:红在一屏墨绿里本来就醒目,不需要再靠「纯红」加码。
|
|
174
|
+
底 #3A1414 对 error-text 9.1、icon 6.4。 */
|
|
175
|
+
--error-surface:#3A1414; --error-border:#7F1D1D; --error-text:#F9B4A4; --error-icon:#F58E7A;
|
|
176
|
+
--running:#35D972; --transitioning:#6EE7A8; --active:#FBBF24;
|
|
90
177
|
/* 状态色底(半透明叠加色):暗色「运行中」用玉青底跟主色同族;
|
|
91
|
-
浅色另给一份蓝底(见下面两个浅色块),保持 ZCode 那份观感不变。
|
|
92
|
-
|
|
178
|
+
浅色另给一份蓝底(见下面两个浅色块),保持 ZCode 那份观感不变。
|
|
179
|
+
⚠️ 数值是按「叠完之后的色相」反推的(2026-10-25 第二轮),别随手改成 rgba(前景色,.18):
|
|
180
|
+
那样叠完会串到别的色相(去青、发土、发绿),三个状态底互相分不清。 */
|
|
181
|
+
--tint-run:rgba(0,200,83,.20); --tint-wait:rgba(174,112,4,.22); --tint-done:rgba(34,197,94,.18);
|
|
93
182
|
/* 排队 = 蓄势待发 → 鎏金(同浅色那份)。原来用紫 #8B5CF6,
|
|
94
183
|
在墨绿底上没有任何亲缘色,一眼就是异物。 */
|
|
95
184
|
--tint-queued:rgba(232,199,122,.16); --queued:#E8C77A;
|
|
185
|
+
--badge-accent:var(--warning); /* 需求页「待整理」徽标的主色(深色=琥珀 5.8) */
|
|
96
186
|
/* 异常终态两档(2026-10-23 用户:「现在没有异常状态的颜色」):
|
|
97
187
|
· 失败(failed)→ 红:引擎报错结束,这条没跑完就没了,要人看一眼;
|
|
98
188
|
· 已取消 / 已打断 / 断连(cancelled / interrupted / gone)→ 石褐:人为收工或进程
|
|
99
189
|
被外部带走(服务重启、终端关闭),不是故障,所以不上红 —— 但也绝不能和
|
|
100
|
-
「正常结束」的绿或兜底的白同色。
|
|
101
|
-
|
|
102
|
-
|
|
190
|
+
「正常结束」的绿或兜底的白同色。
|
|
191
|
+
2026-10-25 第二轮:这两个底都是「反向补偿」过的层 —— 直接拿前景色做 rgba 会被墨绿底
|
|
192
|
+
拉走色相(实测失败出 H38 土黄、停/断连出 H138 绿灰),所以按叠完的落点反推:
|
|
193
|
+
失败底落在 H4/H3(硃砂粉),停/断连底落在 H150 中性玉灰。见上面第二轮第 ① 条。 */
|
|
194
|
+
--tint-fail:rgba(255,64,78,.22); --fail:#F87171;
|
|
195
|
+
--tint-stop:rgba(120,132,126,.26); --stop:#A8A29E;
|
|
196
|
+
/* 「整理失败」警告卡(reqs.css 用)的底与徽标底。**必须按主题分别给**:
|
|
197
|
+
浅色那两个式子是压 --raised(#FFFFFF) 出来的暖白;照抄到深色会变成橄榄绿(H99),
|
|
198
|
+
警告感全丢。深色这一版再往黄里走一档:底叠 --canvas 25% 落在 H73(和排队金 H45 拉开 28°)。
|
|
199
|
+
徽标若继续用「34% 半透明琥珀」,--ink 压上去只剩 3.2 —— 所以徽标改成实心琥珀层色、
|
|
200
|
+
文字改用 --canvas:墨字压琥珀 5.7,比原来的墨绿字压淡琥珀更清楚。
|
|
201
|
+
浅色两个块里钉回原写法(含 --warn-badge-text:--ink),保证浅色零变化。 */
|
|
202
|
+
--warn-surface:color-mix(in srgb,var(--warn-layer) 25%,var(--canvas));
|
|
203
|
+
--warn-layer:#B08A00; /* 反向补偿层:叠墨绿后落在 H73 */
|
|
204
|
+
--warn-badge:var(--warn-layer); --warn-badge-text:var(--canvas);
|
|
103
205
|
|
|
104
206
|
/* ── 掌天瓶绿液(可选令牌,shared/conversation.css 用 `var(--q-bottle*, 兜底)` 取)──
|
|
105
207
|
瓶是**玉**不是玻璃(见 conversation.js 的形制说明),所以颜色分两组:
|
|
@@ -108,12 +210,15 @@
|
|
|
108
210
|
· 灵液 `--q-bottle-hi / / lo / deep`:液位以下的浓翠。
|
|
109
211
|
· `--q-bottle-line` 是轮廓(比玉胎深一档的绿)、`--q-bottle-rim` 是边光/液面亮线/气泡
|
|
110
212
|
(必须比 hi 更白,否则整只瓶会「发光过度」)、`--q-bottle-cork` 是木塞。
|
|
111
|
-
|
|
213
|
+
⚠️ 这四个绿(hi / / lo / deep)是玉液的色阶,跟主题的 --running / --primary 无关,
|
|
214
|
+
浅色也继承这一份 —— 2026-10-25 第二轮没有动它们(只动了 line:H148 灰绿 → 并入 H150 族)。 */
|
|
112
215
|
--q-bottle-hi:#7DF3A8; --q-bottle:#22C55E; --q-bottle-lo:#0F7B3C; --q-bottle-deep:#06371F;
|
|
113
216
|
--q-bottle-line:#43A06E; --q-bottle-rim:#D9FBE8; --q-bottle-cork:#8A7659;
|
|
114
217
|
--q-bottle-glow:rgba(34,197,94,.30);
|
|
115
218
|
--q-bottle-jade-hi:rgba(120,232,175,.44);
|
|
116
219
|
--q-bottle-jade:rgba(52,192,124,.46);
|
|
220
|
+
/* 玉胎三层(半透明):叠在墨绿底上会被拽凉 —— 2026-10-25 第二轮把 G/B 往 H150 收了一点,
|
|
221
|
+
叠 canvas 出 H150~152(原来是 H143~145,比页面底色冷一档,读作「另一块玉」)。 */
|
|
117
222
|
--q-bottle-jade-lo:rgba(8,96,60,.58);
|
|
118
223
|
|
|
119
224
|
--r-card:16px; --r:12px; --r-full:999px;
|
|
@@ -149,6 +254,11 @@
|
|
|
149
254
|
--activity:rgba(37,99,235,.08);
|
|
150
255
|
/* 玉青选中(浅底沉一档,太浅会读不出「选中」) */
|
|
151
256
|
--tab-on:#0C8F5C; --tab-on-bg:rgba(16,163,90,.10);
|
|
257
|
+
/* 底栏弧边(见 .bararc__s):白栏面落在 #F8FAFC 的页面上只差 3 个色阶,只用 2px `--border`
|
|
258
|
+
画弧,弧只在中间(曲线压进栏里的那一段)看得见,两端 30% 一进屏就折平、直接消失 ——
|
|
259
|
+
键盘弹出时页面变矮、这 18px 弧就成了底栏唯一的样子,读出来就是「底栏没了」。
|
|
260
|
+
所以弧边单独给一档比 --border 深、比文字浅的灰。深色不用:玉面与页面之间本来就有明度阶梯。 */
|
|
261
|
+
--bar-edge:#CBD5E1;
|
|
152
262
|
/* 浅底:鎏金要压暗一档才看得清,丹血朱提亮一点 */
|
|
153
263
|
--q-gold:#B98A2E; --q-blood:#D13A2C;
|
|
154
264
|
--md-body:#111827; --md-muted:#64748B; --md-border:#E2E8F0; --md-code-bg:#F1F5F9;
|
|
@@ -188,6 +298,10 @@
|
|
|
188
298
|
这一轮深色把它改成深琥珀了,不钉就会连带把浅色的「待整理」徽标底色改深。
|
|
189
299
|
--tint-done / --q-bottle* 同样没写,但那几个 :root 的值这轮没动,无需重复钉。 */
|
|
190
300
|
--tint-wait:rgba(245,158,11,.20);
|
|
301
|
+
/* 警告卡底:浅色**钉住原写法**(压 --raised=#FFFFFF 的暖白)—— 深色那块把基底
|
|
302
|
+
换成了 --canvas,浅色不钉就会连带变深。 */
|
|
303
|
+
--warn-surface:color-mix(in srgb,var(--warning) 9%,var(--raised));
|
|
304
|
+
--warn-badge:color-mix(in srgb,var(--warning) 24%,transparent); --warn-badge-text:var(--ink);
|
|
191
305
|
}
|
|
192
306
|
}
|
|
193
307
|
/* 手动主题:在「我的」页显式选了才生效(属性选择器优先级高于上面的 :root / media) */
|
|
@@ -203,6 +317,7 @@ html[data-theme="light"]{color-scheme:light;
|
|
|
203
317
|
--activity:rgba(37,99,235,.08);
|
|
204
318
|
/* 玉青选中(同 prefers-color-scheme:light 那一份) */
|
|
205
319
|
--tab-on:#0C8F5C; --tab-on-bg:rgba(16,163,90,.10);
|
|
320
|
+
--bar-edge:#CBD5E1; /* 同 prefers-color-scheme:light 那一份 */
|
|
206
321
|
--q-gold:#B98A2E; --q-blood:#D13A2C;
|
|
207
322
|
--md-body:#111827; --md-muted:#64748B; --md-border:#E2E8F0; --md-code-bg:#F1F5F9;
|
|
208
323
|
--link:#2563EB; --prompt:#2563EB; --shimmer:#0F172A;
|
|
@@ -226,6 +341,10 @@ html[data-theme="light"]{color-scheme:light;
|
|
|
226
341
|
--queued:#8B5CF6; --tint-queued:rgba(139,92,246,.18);
|
|
227
342
|
/* --tint-wait 钉回原值(理由见 prefers-color-scheme:light 那份) */
|
|
228
343
|
--tint-wait:rgba(245,158,11,.20);
|
|
344
|
+
/* 警告卡底:浅色**钉住原写法**(压 --raised=#FFFFFF 的暖白)—— 深色那块把基底
|
|
345
|
+
换成了 --canvas,浅色不钉就会连带变深。 */
|
|
346
|
+
--warn-surface:color-mix(in srgb,var(--warning) 9%,var(--raised));
|
|
347
|
+
--warn-badge:color-mix(in srgb,var(--warning) 24%,transparent); --warn-badge-text:var(--ink);
|
|
229
348
|
/* 中间 FAB 材质:浅色钉回原样(同 prefers-color-scheme:light 那份) */
|
|
230
349
|
--fab-sheen:linear-gradient(178deg,rgba(255,255,255,.16),rgba(255,255,255,0) 60%);
|
|
231
350
|
--fab-base:#22C55E;
|
|
@@ -239,38 +358,55 @@ html[data-theme="light"]{color-scheme:light;
|
|
|
239
358
|
而是继续用浅色的值 —— 于是深色下出现蓝底头像(--tint-run 蓝)、暖米黄头像
|
|
240
359
|
(--btn-me-bg 硬编码米黄)、紫排队(--queued 紫)。补全后两套手动主题都自洽。 */
|
|
241
360
|
html[data-theme="dark"]{color-scheme:dark;
|
|
242
|
-
--canvas:#
|
|
361
|
+
--canvas:#06180F;
|
|
243
362
|
--ink:#F2FBF5; --ink-soft:#DCEAE2; --muted:#A7BFB2; --faint:#7C9488;
|
|
244
|
-
--border:rgba(
|
|
363
|
+
--border:rgba(71,184,128,.18); --subtle:#213F2F; --raised:#1B3A2B;
|
|
245
364
|
--primary-action:#6EE7A8; --on-primary-action:#07130E;
|
|
246
|
-
--fab-well:#
|
|
365
|
+
--fab-well:#152E1F; --fab-notch:rgba(0,0,0,.28);
|
|
247
366
|
--app-shadow:rgba(0,0,0,.5);
|
|
248
|
-
--
|
|
249
|
-
|
|
367
|
+
--bar-fill:color-mix(in srgb,var(--raised) 82%,var(--canvas));
|
|
368
|
+
/* 底栏弧边(深色):浅色块那档 #CBD5E1 会漏到这里 —— 那是给白栏面配的浅灰蓝,
|
|
369
|
+
压在墨绿玉面上就是**一道亮白曲线**横穿图标(2026-10-26 截图实测)。
|
|
370
|
+
深色要的是「玉面比页面亮一档」的**边缘微光**,不是分隔线:
|
|
371
|
+
用同色相、低不透明度(.28)提一档,弧线读得出走位又不抢图标。
|
|
372
|
+
见 .bararc__s 与 :root 里 --arc-dip 那段注释。 */
|
|
373
|
+
--bar-edge:rgba(110,231,168,.28);
|
|
374
|
+
--bubble:#1B3B27; --bubble-text:#F2FBF5;
|
|
375
|
+
--code:#04110B; --status-neutral:#213F2F; --status-neutral-text:#A7BFB2;
|
|
250
376
|
--activity:rgba(67,201,138,.14);
|
|
251
377
|
/* 玉青选中(同 :root 默认暗色那一份) */
|
|
252
378
|
--tab-on:#6EE7A8; --tab-on-bg:rgba(34,197,94,.16);
|
|
253
379
|
--tab-off:#A7BFB2;
|
|
254
380
|
--q-gold:#E8C77A; --q-blood:#E23B2E;
|
|
255
|
-
--md-body:#F2FBF5; --md-muted:#A7BFB2; --md-border:#
|
|
381
|
+
--md-body:#F2FBF5; --md-muted:#A7BFB2; --md-border:#2B5839; --md-code-bg:#1C3C27;
|
|
256
382
|
--link:#6EE7A8; --prompt:#6EE7A8; --shimmer:#F2FBF5;
|
|
257
|
-
--panel:#
|
|
383
|
+
--panel:#173322; --panel-border:#2B5839;
|
|
258
384
|
/* warning 压成深琥珀 —— 理由见 :root 里那段注释(与 queued 的 1.02 区分度问题) */
|
|
259
385
|
--accent:#6EE7A8; --warning:#D97706; --success:#22C55E;
|
|
260
|
-
--error-surface:#
|
|
261
|
-
--running:#
|
|
386
|
+
--error-surface:#3A1414; --error-border:#7F1D1D; --error-text:#F9B4A4; --error-icon:#F58E7A;
|
|
387
|
+
--running:#35D972; --transitioning:#6EE7A8; --active:#FBBF24;
|
|
262
388
|
/* ── 以下 30 个是「补齐原先漏写的深色值」(同 :root 那份,逐值相同)──
|
|
263
|
-
分四组:状态 tint / 招标示 / 底栏法器 / 顶栏头像。
|
|
264
|
-
|
|
389
|
+
分四组:状态 tint / 招标示 / 底栏法器 / 顶栏头像。
|
|
390
|
+
⚠️ 状态 tint 的数值是「叠完之后的色相」反推的,见 :root 第一段的第二轮第 ① 条。 */
|
|
391
|
+
--tint-run:rgba(0,200,83,.20); --tint-wait:rgba(174,112,4,.22); --tint-done:rgba(34,197,94,.18);
|
|
265
392
|
--tint-queued:rgba(232,199,122,.16); --queued:#E8C77A;
|
|
266
|
-
--
|
|
267
|
-
--tint-
|
|
393
|
+
--badge-accent:var(--warning); /* 需求页「待整理」徽标的主色(深色=琥珀 5.8) */
|
|
394
|
+
--tint-fail:rgba(255,64,78,.22); --fail:#F87171;
|
|
395
|
+
--tint-stop:rgba(120,132,126,.26); --stop:#A8A29E;
|
|
396
|
+
/* 警告卡两件(同 :root 深色那份,逐值相同) */
|
|
397
|
+
--warn-surface:color-mix(in srgb,var(--warn-layer) 25%,var(--canvas));
|
|
398
|
+
--warn-layer:#B08A00;
|
|
399
|
+
--warn-badge:var(--warn-layer); --warn-badge-text:var(--canvas);
|
|
400
|
+
--tint-queued:rgba(232,199,122,.16); --queued:#E8C77A;
|
|
401
|
+
--badge-accent:var(--warning); /* 需求页「待整理」徽标的主色(深色=琥珀 5.8) */
|
|
268
402
|
--q-bottle-hi:#7DF3A8; --q-bottle:#22C55E; --q-bottle-lo:#0F7B3C; --q-bottle-deep:#06371F;
|
|
269
403
|
--q-bottle-line:#43A06E; --q-bottle-rim:#D9FBE8; --q-bottle-cork:#8A7659;
|
|
270
404
|
--q-bottle-glow:rgba(34,197,94,.30);
|
|
271
405
|
--q-bottle-jade-hi:rgba(120,232,175,.44);
|
|
272
406
|
--q-bottle-jade:rgba(52,192,124,.46);
|
|
273
407
|
--q-bottle-jade-lo:rgba(8,96,60,.58);
|
|
408
|
+
/* 这三项 :root 里没有(:root 的 --gem-facet 是鎏金 #E8C77A):深色头像的灵石刻面用页面底墨色,
|
|
409
|
+
浅色两块各自钉回鎏金 —— 所以这里必须写「深色那一版」。 */
|
|
274
410
|
--gem-facet:var(--canvas);
|
|
275
411
|
--btn-me-bg:color-mix(in srgb,var(--q-gold) 34%,var(--raised)); --btn-me-fg:#12211A;
|
|
276
412
|
/* 中间 FAB(蜂云剑阵)的材质 —— 深色专属(2026-10-25 用户:「浅色皮肤不变 改深色皮肤配色」)。
|
|
@@ -278,7 +414,7 @@ html[data-theme="dark"]{color-scheme:dark;
|
|
|
278
414
|
同一个色相却高出 35 阶明度、40 阶饱和,读起来是一只**发光的塑料球**,
|
|
279
415
|
也是整屏最刺眼的一处(截图实测,1.68 万像素是这个色)。
|
|
280
416
|
深色改成「深玉盘」:径向从玉心 (#3FAE79) 落到墨底 (#093720),明度压在
|
|
281
|
-
底栏栏面 (#
|
|
417
|
+
底栏栏面 (#173322, L14) 与选中图标 (#6EE7A8, L67) 之间;
|
|
282
418
|
再补一圈 **1.5px 鎏金环** —— 主按钮的「这是可点的」不能靠更亮更大声,
|
|
283
419
|
而是靠金环这个**形状**(金与墨绿是这套配色的互补对,底栏法器纹样本来就有点金)。
|
|
284
420
|
玉光 halo 收到 .12:原来 .30 的 22px 绿晕是「发光」的另外一半原因。 */
|
|
@@ -395,9 +531,27 @@ textarea{font:inherit}
|
|
|
395
531
|
.history__title--btn.is-open .history__title-chev{transform:rotate(180deg)}
|
|
396
532
|
.history__disconnect{color:var(--primary-action);font-size:14px;font-weight:500;padding:8px 10px}
|
|
397
533
|
.history__disconnect:disabled{color:var(--muted);opacity:.6}
|
|
398
|
-
|
|
534
|
+
/* 列表底部留白:够「凸出的 +」+ 栏高 + 安全区。2026-10-26 起全走变量,
|
|
535
|
+
`--bar-h` 一改这里自动跟随(原来是写死的 96 = 56 + 34 + 6,改栏高就会压住最后一行)。 */
|
|
536
|
+
.history__list{flex:1;min-height:0;overflow-y:auto;
|
|
537
|
+
padding:4px 10px calc(var(--bar-h) + var(--sab) + 6px)}
|
|
399
538
|
.history__group{padding:12px 8px 6px;font-size:12px;font-weight:700;color:var(--muted)}
|
|
400
539
|
.history__hint{padding:22px 12px;color:var(--muted);font-size:14px;text-align:center}
|
|
540
|
+
/* 紧随卡片的附注(「我的」的分组说明):本来就小一号、灰一档,但 .history__hint 默认是
|
|
541
|
+
「整页居中 + 大上下留白」的空态样式,套在卡片下面会把版面撕开 ——
|
|
542
|
+
这里压成左对齐的一行小字,留白只留在卡片与附注之间。 */
|
|
543
|
+
.history__hint--foot{padding:0 14px 4px;font-size:12px;line-height:1.5;text-align:left}
|
|
544
|
+
.history__hint--foot b{color:var(--ink);font-weight:600}
|
|
545
|
+
/* 附注的**左缘要对齐卡片的文字**(2026-10-26 截图量出来的 13px 错位):
|
|
546
|
+
.history__list 左右各 10 内边距 → 卡片外缘在 10、边框 1 → 卡内 padding 12、行内 2
|
|
547
|
+
→ 行文字左缘在 25。附注自身的 padding 14 + 这个 margin 就是 25。
|
|
548
|
+
两端写死只会让「附注」和「卡片里那一行」差半个字,眼一看就是没对齐。 */
|
|
549
|
+
.mine .history__hint--foot{margin:0 5px}
|
|
550
|
+
/* 我的 → 飞书推送:整页是「组标题 + 行 + 整行按钮 + 说明」的流,
|
|
551
|
+
行内的值列不该再占一半宽(「已配置 · 本机配置」这种会被省略号吃掉后半截)。 */
|
|
552
|
+
#feishuBody .setrow{padding:12px 2px}
|
|
553
|
+
#feishuBody .setrow__v{max-width:none;overflow:visible;text-overflow:clip}
|
|
554
|
+
#feishuBody .btn{display:block;width:calc(100% - 40px);margin:6px auto;text-align:center}
|
|
401
555
|
/* 会话列表加载失败(连不上桌面端)的失败态:不再是一行灰字,给图标 + 人话原因 +
|
|
402
556
|
可复制地址 + 重试/编辑环境入口(2026-10-25:「失败时的界面显示要设计一下」)。
|
|
403
557
|
颜色全走变量,暗/亮主题自动跟随;图标与排队/失败行同一套「一枚图标 + 状态 tint」。 */
|
|
@@ -422,6 +576,9 @@ textarea{font:inherit}
|
|
|
422
576
|
.srow--stalled .srow__ico{background:var(--tint-wait);color:var(--warning)}
|
|
423
577
|
.srow--stalled .srow__ico--queued::after{display:none}
|
|
424
578
|
.srow--stalled .srow__s{color:var(--warning)}
|
|
579
|
+
/* 「排队等跑」那行:副标题是整行唯一的文字说明,不能停在小号淡色上 —— 深色下 12px 的 --faint
|
|
580
|
+
压在墨绿底上发糊(2026-10-25 第二轮)。提到 --muted(raised 7.0 / canvas 9.4),与「运行中」同档。 */
|
|
581
|
+
.srow__ico--queued~.srow__col .srow__s{color:var(--muted)}
|
|
425
582
|
.srow__ico{flex:0 0 auto;width:44px;height:44px;border-radius:50%;background:var(--subtle);
|
|
426
583
|
display:flex;align-items:center;justify-content:center;color:var(--ink);position:relative}
|
|
427
584
|
/* 会话状态:用颜色区分(无角标、无描边) */
|
|
@@ -442,6 +599,27 @@ textarea{font:inherit}
|
|
|
442
599
|
⚠️ 只有**一种**等待样式:新任务排队、以及会话有追问在排队,用的都是它
|
|
443
600
|
(2026-10-21 用户:「排队标志在会话左边的图标里设计 1 个等待样式即可」)。 */
|
|
444
601
|
.srow__ico--queued{background:var(--tint-queued);color:var(--queued)}
|
|
602
|
+
/* 自动模式判冲突的排队行:图标右下角一个「AI」小标(2026-10-26 用户:「AI判断的任务
|
|
603
|
+
需要串行排队的 显示 1 个特殊的标识」)。**不动**上面那套排队样式(紫色呼吸环是
|
|
604
|
+
「有一条在排队」的唯一视觉,2026-10-21 定的),只多一个「这是 AI 判的」的小标,
|
|
605
|
+
这样「没轮到」和「判了冲突」在列表上能一眼分开。 */
|
|
606
|
+
/* 形状与置顶图钉**同款**(2026-10-26 用户:「ai 串行排队的跟置顶的一样使用角标标识」):
|
|
607
|
+
18×18 圆、白底、1.5px 描边、右下角。只是颜色走排队紫、图标是四芒星(AI 判断),
|
|
608
|
+
所以「AI 在调度」与「我钉住的/在跑临时置顶」一眼能分开。 */
|
|
609
|
+
.srow__ico-ai{position:absolute;right:-4px;bottom:-4px;z-index:1;width:18px;height:18px;border-radius:50%;
|
|
610
|
+
display:flex;align-items:center;justify-content:center;pointer-events:none;
|
|
611
|
+
background:var(--raised);color:var(--queued);border:1.5px solid var(--queued)}
|
|
612
|
+
/* 运行中「临时置顶」标记:这类行被自动提到最前面(跑完归位),在**图标**上给一枚
|
|
613
|
+
小图钉角标(右下)。行尾不再画任何东西(2026-10 用户:行尾图钉无意义;
|
|
614
|
+
置顶状态只做在图标里)。与 AI 排队标共用右下角但互斥:那条只在 queued
|
|
615
|
+
出现,这条只在 run 出现,同一行不会两枚叠在一起。 */
|
|
616
|
+
.srow__ico-pin{position:absolute;right:-4px;bottom:-4px;z-index:1;width:18px;height:18px;border-radius:50%;
|
|
617
|
+
display:flex;align-items:center;justify-content:center;pointer-events:none;
|
|
618
|
+
background:var(--raised);color:var(--primary-action);border:1.5px solid var(--primary-action)}
|
|
619
|
+
/* 手动置顶(manual)= 实底;自动临时置顶(auto)= 白底描边。一眼分得出
|
|
620
|
+
「我钉住的」和「因为在跑临时提上来的」。 */
|
|
621
|
+
.srow__ico-pin.is-manual{background:var(--primary-action);color:var(--on-primary-action);
|
|
622
|
+
border-color:var(--primary-action)}
|
|
445
623
|
.srow__ico--queued::after{content:"";position:absolute;inset:-2px;border-radius:50%;pointer-events:none;
|
|
446
624
|
border:2px solid var(--queued);animation:srow-breathe 2.2s ease-in-out infinite}
|
|
447
625
|
@keyframes srow-breathe{
|
|
@@ -464,7 +642,6 @@ textarea{font:inherit}
|
|
|
464
642
|
.srow__time{flex:0 0 auto;font-size:12px;color:var(--muted)}
|
|
465
643
|
/* 运行中**自动临时置顶**的小图钉(2026-10-22):让「它凭什么在最上面」有据可查。
|
|
466
644
|
手动置顶靠「置顶」分段表达,不重复画;跑完 statusOf 不再是 run,图钉随之消失。 */
|
|
467
|
-
.srow__auto{flex:0 0 auto;display:flex;align-items:center;color:var(--primary-action);opacity:.75}
|
|
468
645
|
/* 子会话(团队模式拆出的子任务,2026-10-22):**缩进挂在主会话下面**,不占顶层一行。
|
|
469
646
|
左侧用**树形折线**(├─ / └─)接出来,不是一根通到底的竖线(2026-10-24 用户:
|
|
470
647
|
「团队模式的子会话的线设计成树状 不要一条竖线」)—— 多个子任务读起来是一棵树,
|
|
@@ -494,6 +671,10 @@ textarea{font:inherit}
|
|
|
494
671
|
.srow--sub{padding:9px 8px 9px 4px;gap:10px}
|
|
495
672
|
.srow--sub .srow__ico{width:32px;height:32px}
|
|
496
673
|
.srow--sub .srow__ico svg{width:16px;height:16px}
|
|
674
|
+
/* 子会话图标只有 32px:角标跟着缩一档,别盖住图标本身。 */
|
|
675
|
+
.srow--sub .srow__ico-pin,.srow--sub .srow__ico-ai{width:15px;height:15px}
|
|
676
|
+
.srow--sub .srow__ico-ai svg{width:9px;height:9px}
|
|
677
|
+
.srow--sub .srow__ico-pin svg{width:10px;height:10px}
|
|
497
678
|
.srow--sub .srow__t{font-size:14px;font-weight:500}
|
|
498
679
|
.srow--sub .srow__s,.srow--sub .srow__time{font-size:11px}
|
|
499
680
|
/* 团队会话的标志(2026-10-21):core 在 /desk/sessions 里给 mode:"team"。
|
|
@@ -541,9 +722,11 @@ textarea{font:inherit}
|
|
|
541
722
|
.wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
|
|
542
723
|
.wsrow__open:active{background:var(--subtle)}
|
|
543
724
|
.wsrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
544
|
-
/*
|
|
725
|
+
/* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致)。
|
|
726
|
+
主色走 --badge-accent(默认 = 排队金):需求页把「待整理」也复用这枚徽标时改挂警告琥珀,
|
|
727
|
+
11px 的字在墨绿底上要 ≥4.5,金色 7.5 / 琥珀 5.8 都够(2026-10-25 第二轮)。 */
|
|
545
728
|
.wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
|
|
546
|
-
color:var(--queued);background:var(--tint-queued);white-space:nowrap}
|
|
729
|
+
color:var(--badge-accent,var(--queued));background:var(--tint-queued);white-space:nowrap}
|
|
547
730
|
.wsrow__chev{color:var(--muted);display:inline-flex;transition:transform .15s}
|
|
548
731
|
.wsrow__chev.is-open{transform:rotate(90deg)}
|
|
549
732
|
.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)}
|
|
@@ -593,7 +776,7 @@ textarea{font:inherit}
|
|
|
593
776
|
font-size:15px;font-weight:500;color:var(--primary-action);text-align:left}
|
|
594
777
|
.envmenu__add:active{background:var(--subtle)}
|
|
595
778
|
/* toast(没有通知能力时的兜底提示) */
|
|
596
|
-
.toast{position:fixed;left:50%;bottom:calc(
|
|
779
|
+
.toast{position:fixed;left:50%;bottom:calc(var(--bar-h) + var(--sab) + 2px);transform:translateX(-50%);
|
|
597
780
|
max-width:82vw;padding:10px 14px;border-radius:12px;background:var(--raised);color:var(--ink);
|
|
598
781
|
border:1px solid var(--border);font-size:13px;text-align:center;z-index:1300;
|
|
599
782
|
box-shadow:0 12px 30px -12px var(--app-shadow)}
|
|
@@ -645,7 +828,11 @@ button.setrow{font-family:inherit}
|
|
|
645
828
|
.setcard{background:var(--raised);border:1px solid var(--border);border-radius:16px;margin:10px 6px;padding:0 12px}
|
|
646
829
|
.setrow{display:flex;align-items:center;gap:12px;width:100%;padding:14px 2px;text-align:left}
|
|
647
830
|
.setrow+.setrow{border-top:1px solid var(--border)}
|
|
648
|
-
|
|
831
|
+
/* 配置项标题:**永不换行**(2026-10-26 用户:「配置的标题要保证不换行 引擎和模型可换行显示」)。
|
|
832
|
+
原来是 flex:1 铺满,右值变宽(引擎 · 模型同一行)时标题被挤成「默认引 / 擎」两行。
|
|
833
|
+
现在标题按自然宽度排、不换行(真放不下才省略),剩下的空间交给右值;可换行的是**值**不是标题。 */
|
|
834
|
+
.setrow__l{flex:0 1 auto;min-width:0;margin-right:auto;font-size:15px;color:var(--ink);
|
|
835
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
649
836
|
.setrow__v{flex:0 0 auto;font-size:14px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
|
|
650
837
|
/* 主备值:主一行、备一行(右上对齐)。挤成「主 | 备 …」会被省略号吃掉后半截
|
|
651
838
|
(2026-10-24 用户:「没显示出来」),所以这里允许换行、两行各自省略。 */
|
|
@@ -654,10 +841,43 @@ button.setrow{font-family:inherit}
|
|
|
654
841
|
.setrow__v--pair .setrow__pair-main{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
655
842
|
.setrow__v--pair .setrow__pair-fb{font-size:12px;color:var(--muted);opacity:.9;
|
|
656
843
|
max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
844
|
+
/* 引擎 · 模型同一行(2026-10-26 用户:「引擎和模型显示在同一行即可」):
|
|
845
|
+
右格 = 主行「引擎 · 模型」,备用小字跟在后面。原来模型独占一行,合成一行后右格变宽,
|
|
846
|
+
单行省略会把模型名整个吃掉 —— 改成「放得下就一行,放不下折到第二行」,不截断。 */
|
|
847
|
+
.setrow--engmodel .setrow__v--pair{flex:1 1 auto;min-width:0;max-width:none;align-items:flex-end}
|
|
848
|
+
/* 引擎行:标题不收缩(给「默认引擎 / 建议引擎」留足),引擎+模型那格吃剩余宽度并允许换行。 */
|
|
849
|
+
.setrow--engmodel .setrow__l{flex:0 0 auto}
|
|
850
|
+
.setrow--engmodel .setrow__pair-main{white-space:normal;overflow-wrap:anywhere;text-align:right;
|
|
851
|
+
line-height:1.35}
|
|
852
|
+
/* 同一格里两个值:引擎用正文色、模型用次级色,中间加个淡分隔点,免得读成一个长名字。 */
|
|
853
|
+
.setrow--engmodel .setrow__pair-main .engmodel__mid{color:var(--faint);padding:0 3px}
|
|
854
|
+
.setrow--engmodel .setrow__pair-main .engmodel__model{color:var(--ink-soft)}
|
|
855
|
+
/* 内嵌的模型 span 只给测试 / 播报留 id(#defaultModel / #suggestModel),不单独占位。 */
|
|
856
|
+
.setrow__v--model{display:none}
|
|
657
857
|
/* 有新版本时这一格变成可点的「升级」提示(2026-10-22 用户:「版本现在也没点击升级的地方」) */
|
|
658
858
|
.setrow__v.is-upgradable{color:var(--primary-action);font-weight:600}
|
|
659
859
|
.setrow--danger{color:var(--error-text)}
|
|
660
860
|
.setrow--danger .setrow__l,.setrow--danger .setrow__v{color:var(--error-text)}
|
|
861
|
+
/* 「我的」:分组名**放进卡片里**当标题(2026-10-26 用户:「分类和下面的配置融合感太差」)。
|
|
862
|
+
原来是「卡外小标签 + 独立圆角卡」,两者之间 6px 的空隙把它读成两块各自的东西;
|
|
863
|
+
现在组名是卡内第一行,与下面的设置行同属一个圆角容器,中间只隔一段内边距。
|
|
864
|
+
⚠️ 只限 .mine:发现页 / 插件页的 `.history__group` 仍是卡外分组名,别一起改。 */
|
|
865
|
+
.mine .setcard{padding-top:2px}
|
|
866
|
+
.mine .history__group{padding:12px 2px 8px;font-size:12px;font-weight:700;color:var(--muted);
|
|
867
|
+
border-bottom:1px solid var(--border)}
|
|
868
|
+
/* 夹在行之间的包裹层(#teamCard):要让被包裹的 `.setrow` 与其它行读起来完全一样 ——
|
|
869
|
+
分隔线画在包裹层上,行自己不带线;否则 `行 + 行` 的相邻选择器跨不过这层,线会断。 */
|
|
870
|
+
.mine .mslot{border-top:1px solid var(--border)}
|
|
871
|
+
.mine .mslot>.setrow{border-top:0}
|
|
872
|
+
.mine .mslot[hidden]{display:none}
|
|
873
|
+
/* 组名后面那一行不带顶线(`.setrow + .setrow` 只给行与行之间画线),
|
|
874
|
+
分隔线只在同组内出现 —— 组名到第一行是「标题 → 内容」,不该有线。 */
|
|
875
|
+
.mine .setcard>.history__group+.setrow,
|
|
876
|
+
.mine .setcard>.history__group+.setrow--split{border-top:0}
|
|
877
|
+
/* 行尾小动作(「当前桌面 [切换]」):文字按钮,落在值后面,不抢主行的读取顺序。
|
|
878
|
+
用 --primary-action 而不是新造色 —— 全 App 的可点文字只有这一种颜色。 */
|
|
879
|
+
.setrow__act{flex:0 0 auto;margin-left:6px;padding:2px 8px;border-radius:var(--r-full);
|
|
880
|
+
font-size:13px;color:var(--primary-action);background:var(--subtle);font-family:inherit}
|
|
661
881
|
/* 环境编辑(我的 → 环境):名称 / 地址 / 令牌三栏。
|
|
662
882
|
环境不能只靠扫码「连过才记得」—— 用户要显式配置 IP 和名字(2026-10-22)。 */
|
|
663
883
|
.envfield{display:block;width:100%;height:44px;margin-top:6px;padding:0 12px;border-radius:10px;
|
|
@@ -713,44 +933,117 @@ button.setrow{font-family:inherit}
|
|
|
713
933
|
· 会话页由 composer 的下内边距负责,见下面的 `#conv .composer`
|
|
714
934
|
· 启动页不显示底栏(showLauncher 里 hidden),无需留白
|
|
715
935
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
936
|
+
**高度固定**(2026-10-25 用户:「弧线压在图标上了 底栏高度要固定」;
|
|
937
|
+
2026-10-26 用户:「底栏高度固定 图标距离底部的距离固定 弧线距离底部的距离也固定」):
|
|
938
|
+
· `height`(不是 min-height)+ 边框盒:栏体恒为 **--bar-h + safe**,不随内容长高。
|
|
939
|
+
· `padding-bottom` = safe-area 只把栏体往上抬,图标只在上面 --bar-h 的内容区里排布。
|
|
940
|
+
图标**不再居中**:居中会让距栏底的距离随图标自身高度变(26px 的离底 15、28px 的离底 14,
|
|
941
|
+
四枚不在一条基线上),也和 --ic-gap 这个显式常量冲突。改成 `align-items:flex-end`
|
|
942
|
+
+ `padding-bottom:var(--ic-gap)`(见 .tab)—— 距栏底恒为 --ic-gap,与图标尺寸无关。
|
|
943
|
+
弧线是**上缘**、不是栏外的装饰:最深处距栏底 --arc-dip(45.5),所以栏顶 56 − 45.5 = 10.5
|
|
944
|
+
是那条弧的沉深,端点仍落在栏顶(y=0)。三个数都是常量,互不推算。 */
|
|
723
945
|
.tabbar{position:absolute;left:0;right:0;bottom:0;z-index:60;
|
|
724
946
|
display:flex;align-items:stretch;
|
|
725
|
-
|
|
726
|
-
/* ⚠️
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
947
|
+
box-sizing:border-box;
|
|
948
|
+
/* ⚠️ `var(--sab)` 不是 `env(...)`:键盘弹起时 env 会归零,栏体就会矮掉一整条安全区
|
|
949
|
+
(根因见 :root 里 `--sab` 那段注释)。这一条是「两种状态高度一致」的唯一开关。 */
|
|
950
|
+
height:calc(var(--bar-h) + var(--sab));
|
|
951
|
+
/* ⚠️ 栏体**不画底色**(2026-10-25 用户:「底色只应该弧线以下才有」):
|
|
952
|
+
底色由子层 .bararc 按弧线裁剪着画 —— 弧以上露出页面。曾经在这里写 `background:var(--raised)`,
|
|
953
|
+
等于在整条矩形栏上又铺一层玉面,弧线上方就多出一块比页面亮的矩形(手机截图里弧上那层「绿板」)。
|
|
954
|
+
不透明这一点仍由 .bararc 的玉面 + FAB 井(.tabbar::before,自带不透明底)保证;
|
|
955
|
+
不用半透 + blur:滚动时下面那行「magic-stock · codebuddy」会透上来压在图标上(2026-10-25 实测)。 */
|
|
956
|
+
background:transparent;
|
|
957
|
+
/* 栏体**不画**自己的上边线(2026-10-25 用户:「也不存在横线 自然过渡的弧线」):
|
|
958
|
+
原来的 `0 -1px 0 var(--border)` 会在弧面落点之间从一头拉到另一头,
|
|
959
|
+
把「弧面 → 栏身」那道台阶读成一条贯穿的横线。栏沿交给下面的 .bararc 按同一张弧线画。 */
|
|
960
|
+
padding-bottom:var(--sab)}
|
|
961
|
+
/* 上缘 = 一条**整幅的、向上凹的弧**(2026-10-25 用户:「底栏弧度要反过来向上 也不存在横线
|
|
962
|
+
自然过渡的弧线 左右向上翘」):
|
|
963
|
+
两头翘、中间沉 —— 屏底像一片托起来的弧面。上一版是两块「飞檐」+ 中段一条直的栏边,
|
|
964
|
+
于是弧与弧之间横着一道线、弧的落点又和栏边拼出台阶;这一版**不再有任何直线与直角**:
|
|
965
|
+
**整幅上缘就是这一条弧**,弧以下全是栏的面,弧以上全是页面。
|
|
966
|
+
|
|
967
|
+
① 形状:`.bararc` 是一整块**背景层**,铺满底栏的整个盒子(inset:0),z-index:0 垫在五个 tab 下面。
|
|
968
|
+
这一层里画「弧线以下的玉面」+「弧线本身」两根描边,所以弧以上露出来的是页面。
|
|
969
|
+
⚠️ 不要改回「贴在栏顶上方的一条带」(bottom:100%):那样会同时踩两个老坑 ——
|
|
970
|
+
(a) 底栏是 z-index 的整层,弧要压住底栏自己的背景,就必须给底栏开 filter 强制开层,
|
|
971
|
+
而开层之后 SVG 按整数倍设备像素重画,暗色下弧沿会渗出一排阶梯和发丝竖纹;
|
|
972
|
+
(b) 弧与栏身分成两层画,拼接处要再补 1px 的缝。背景层这里两个坑都不存在。
|
|
973
|
+
② 弧(一条整幅宽的三次贝塞尔,两块对称的浅盆合起来):
|
|
974
|
+
端点放在 y=0(两端上翘到顶),两个控制点都放在 y=AMP:
|
|
975
|
+
M0 0 C x1 AMP, x2 AMP, W 0 x1 = W·K_END,x2 = W − x1
|
|
976
|
+
AMP = 14:屏幕上中间比两端低约 10.5px(曲线最深点到控制点高度的 3/4)。
|
|
977
|
+
历经的调整(2026-10-25):先压到 8 → 用户「要再弯一些」抬到 14 → 又改成拱桥形被否(「左高右低」)→
|
|
978
|
+
翻成现在这个嘴角形。K_END = 30%:两端一进屏就以约 30° 起翘,往中间连续过渡,没有平台段。
|
|
979
|
+
⚠️ AMP 与 K_END 是两个旋钮:AMP 决定「中心比两端低多少」,K_END 决定「两端翘得多急」。
|
|
980
|
+
K_END 太小(如 8%)两端会陡、中段接近一条平线(读作「平线 + 两头小翘」);太大则中段发鼓。
|
|
981
|
+
SVG 里 y 轴向下,所以**端点 y=0、控制点 y=AMP** 才是「中间低、两端高」的嘴角形;
|
|
982
|
+
反过来写就是拱桥形(左右虽然对称,但看着像「左高右低」)。改这里之前先看宿主脚本里的同段注释。
|
|
983
|
+
写法上不用 viewBox + preserveAspectRatio="none":拉伸会让描边宽度随屏宽变形
|
|
984
|
+
(窄屏约 0.6px、平板近 2px)。所以 JS 按栏的实测像素生成路径,**1 个用户单位 = 1px**,描边恒定 2px。
|
|
985
|
+
③ 取色:玉面是 `--bar-fill`(默认 = --raised)。深色主题给**比栏面暗一档、比页面亮一档**的
|
|
986
|
+
玉面,暗底上才读得出「栏浮在页面之上」;浅色不给,白栏面 + 灰弧线本身就是清晰的边界。
|
|
987
|
+
浮起感完全靠「页面 → 弧线 → 玉面」这一道明度阶梯,不投任何投影 ——
|
|
988
|
+
filter 一有位移/模糊,暗色下弧沿就出竖纹、台阶(见上面的老坑说明),代价比收益大。
|
|
989
|
+
④ pointer-events:none 不吃点击;FAB 槽(.tabbar::before,z-index:1)画在这层之上,互不越界。 */
|
|
990
|
+
/* ⚠️ 背景层必须挂在底栏的**子元素**(div)里画,不能直接给 `.tabbar` 加背景,也不能用伪元素:
|
|
991
|
+
① `.tabbar` 自己的 `background` 会垫在 `.tabbar::before`(FAB 井,z-index:1)下面 —— 那没问题;
|
|
992
|
+
但井是圆的,栏的直角背景会在井的圆角外露出一圈直角(浅色下读成「按钮垫了个盘子」)。
|
|
993
|
+
② 所以弧面 + 玉面放在子元素里,它的 z-index:0 与 `::before` 的 z-index:1 同处底栏的层叠上下文,
|
|
994
|
+
谁来压谁由 z-index 决定,井永远画在玉面之上 —— 亲眼踩过的回归:早先把弧层做成 SVG 根元素时,
|
|
995
|
+
右半屏的 tab 会被整块遮住(2026-10-25 无头实测)。所以 `.tab` 自己也带 position+z-index:2,
|
|
996
|
+
③ SVG 是替换元素:`left:0;right:0` 对它算不出宽度,会落到默认 300×150 再等比缩进
|
|
997
|
+
(2026-10-25 无头实测,弧面只铺到三分之二屏)。所以这里改成**原生尺寸 = 几何尺寸**:
|
|
998
|
+
外层 div 四边铺满栏盒子,SVG 不给 width/height/preserveAspectRatio,只由脚本把
|
|
999
|
+
viewBox 和 `width/height` 属性都设成栏的实测像素(1 个用户单位 = 1px)。
|
|
1000
|
+
⚠️ 不要退回「preserveAspectRatio="none" + 100% 尺寸」那种拉伸写法:无头 Chromium 2× DPR 下,
|
|
1001
|
+
拉伸后的 SVG 会把下缘抗锯齿按「像素块」重采样成 82px 宽的竖条(2026-10-25 掩膜实测)。
|
|
1002
|
+
⚠️ 不要给这一层加 `transform:translateZ(0)` / `will-change`:另开合成层后暗色下弧沿出竖纹。 */
|
|
1003
|
+
/* ⚠️ 只盖到栏底**之上**(bottom = safe-area),别让四边都铺满栏盒:
|
|
1004
|
+
否则弧线以下那整块玉面会一直画进安全区,屏幕最底下留出一条比页面亮的白带
|
|
1005
|
+
(2026-10-25 无头实测:栏高 56 + safe 34,底下 34px 是白的)。栏体只占 56,玉面也只该到 56。 */
|
|
1006
|
+
/* ⚠️ 这一层**铺满整个栏盒**(bottom:0,含安全区)—— 2026-10-26 改,原先是 `bottom:var(--sab)`。
|
|
1007
|
+
那版让玉面只铺到内容区(56),底下 34px 露出页面底色。浅色下页面 #F8FAFC 与栏面 #FFFFFF
|
|
1008
|
+
只差 3 个色阶,几乎看不出;深色下页面 #06180F 与栏面 #152E1F 差一大截,屏幕最底就是一条
|
|
1009
|
+
**黑带**(2026-10-26 深色截图实测)。而且用户给的实机截图里栏面是**一直铺到屏幕底**的。
|
|
1010
|
+
铺满之后栅面上方多出来的那 34px 由弧线自己切掉(`--bar-h` 决定的透明区,见脚本),
|
|
1011
|
+
所以「页面 → 弧线 → 玉面到底」这条阅读顺序不变,只是底部不再有接缝。 */
|
|
1012
|
+
.bararc{position:absolute;top:0;left:0;right:0;bottom:0;z-index:0;pointer-events:none;
|
|
1013
|
+
/* ⚠️ 这一层 filter 是逼合成层用的(值本身是恒等变换,不画任何东西)。无头 Chromium 2× DPR
|
|
1014
|
+
实测:不开层时,下缘抗锯齿的片段会和底栏背景一起被拆成一排 82px 宽的竖条
|
|
1015
|
+
(= 五个 tab 区的背面,深浅两主题都出现;2026-10-25 掩膜实测)。开层后这层按设备像素
|
|
1016
|
+
整层重画,竖条消失。⚠️ 值里**不能有位移或模糊**(别写成投影):重采样会把台阶和发丝纹渗回来。
|
|
1017
|
+
⚠️ 也别换成 transform:translateZ(0) / will-change:那会换一种光栅化路径,暗色下弧沿又出竖纹。 */
|
|
1018
|
+
filter:drop-shadow(0 0 0 transparent)}
|
|
1019
|
+
.bararc__svg{display:block;width:100%;height:100%;fill:var(--bar-fill,var(--raised))}
|
|
1020
|
+
.bararc__d{fill:var(--bar-fill,var(--raised))}
|
|
1021
|
+
/* 弧边:三套主题各自给 --bar-edge(浅色 #CBD5E1 灰、深色 rgba(110,231,168,.28) 玉光)。
|
|
1022
|
+
⚠️ 不要再让它回落到 --border:深色 --border 是 .18 的低透明绿,压在玉面上几乎消失;
|
|
1023
|
+
而浅色那档 #CBD5E1 漏进深色又会变成刺眼白线(2026-10-26 两种都实测过)。
|
|
1024
|
+
每个主题块都要显式写一次 --bar-edge(见 :root / media:light / html[data-theme=dark])。 */
|
|
1025
|
+
.bararc__s{fill:none;stroke:var(--bar-edge,var(--border));stroke-width:2px}
|
|
751
1026
|
/* 只有图标:文案从底栏拿掉(2026-10-22 用户),语义交给 aria-label,不靠视觉标签。
|
|
752
1027
|
命中区仍是 flex:1 × 56,比"图标 + 文字"那版更高更宽,点起来更稳。 */
|
|
753
|
-
|
|
1028
|
+
/* ⚠️ 图标**底边对齐**(`align-items:flex-end`),不再垂直居中。
|
|
1029
|
+
居中会让「距栏底的距离」随图标自身高度变:26px 的器物离底 15、28px 的「我的」离底 14
|
|
1030
|
+
(2026-10-26 无头实测 gapSvgToBarBottom 15/15/15/14)—— 用户要的是「图标距离底部的距离固定」,
|
|
1031
|
+
四枚必须落在同一条基线上,而且这个距离要能单独调。
|
|
1032
|
+
现在距离由 `--ic-gap` 单点控制:`.icw` 的 `margin-bottom` 吃它,与图标尺寸、栏高都无关。
|
|
1033
|
+
⚠️ 不要退回垂直居中 + 固定内边距的写法:那样「我的」那枚 28px 会比别的高 2px 顶到弧线更近。 */
|
|
1034
|
+
.tab{flex:1;display:flex;align-items:flex-end;justify-content:center;
|
|
1035
|
+
/* ⚠️ 这里**不能扣 safe-area**(2026-10-26 修)。
|
|
1036
|
+
旧写法是 `calc(var(--sab) + 13px)`,那是 `min-height` 时代的补丁:当时栏体被
|
|
1037
|
+
padding 撑到 56 + 34,tab 也长到 90,图标在 90 里居中会往下掉 17,所以扣「sab」把它拽回来。
|
|
1038
|
+
但栏体现在是**定高 `--bar-h + sab` 且自带 `padding-bottom:sab`** —— 内容区永远只有 --bar-h,
|
|
1039
|
+
tab 的高度也是 --bar-h。再扣一遍 sab 就是**重复扣减**:图标被挤到栏顶之上 12.5px
|
|
1040
|
+
(实测 icw.top=654.5 而栏顶 667),弧线正好横穿图标,也就是用户看到的「弧线压在图标上」。
|
|
1041
|
+
现在位移只由 `--ic-gap` 决定,弧线位置只由 `--arc-dip` 决定,两者互不推算。 */
|
|
1042
|
+
/* ⚠️ 必须自成一层并压过 .bararc:弧面是整块背景层,若和 tab 同层,「弧线以上露页面」
|
|
1043
|
+
的透明部分盖在 tab 上是靠设备坐标对位画的,无头 Chromium 2× DPR 下对不上,
|
|
1044
|
+
右半屏图标被整块抹掉(2026-10-25 实测)。position+z-index 让每个 tab 单独一层,
|
|
1045
|
+
叠放顺序只由 z-index 决定,与弧层怎么画无关。 */
|
|
1046
|
+
position:relative;z-index:2;
|
|
754
1047
|
/* 未选中用 --tab-off 而不是 --muted:--muted 是「次要文字」的档位,
|
|
755
1048
|
拿来画 25px 的器物剪影偏淡 —— 浅色下在白栏面上只有 5.1:1,
|
|
756
1049
|
而剪影全靠实心面积撑辨识度,实测比正文更吃对比(正文有字重兜底,剪影没有)。
|
|
@@ -771,11 +1064,21 @@ html:not([data-theme="dark"]) .tab.is-on svg{
|
|
|
771
1064
|
@media (prefers-color-scheme:dark){
|
|
772
1065
|
html:not([data-theme]) .tab.is-on svg{filter:drop-shadow(0 0 9px color-mix(in srgb,var(--tab-on) 85%,transparent))}
|
|
773
1066
|
}
|
|
774
|
-
/*
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1067
|
+
/* 选中态**只换图标颜色 + 一圈灵光,不铺底、不描边**(2026-10-26 用户:
|
|
1068
|
+
「tab 图标选中时不需要背景色和边框」)。原来还有一块 44×34 的圆角底 + 1px 内描边,
|
|
1069
|
+
理由是「没有文字之后颜色是唯一线索,得补个形状」;实机看下来那两块方形色块在
|
|
1070
|
+
弧面玉青上像贴了补丁(四枚并排时尤其明显),而且浅色主题下 10% 的玉青本来就几乎看不见。
|
|
1071
|
+
语义现在完全由「玉青 + 灵光」承担 —— 未选中是墨绿/深灰的实心剪影,选中是同形状的玉青发光,
|
|
1072
|
+
明度 + 色相 + 光晕三个通道同时变,不靠色块的形状也读得出。
|
|
1073
|
+
⚠️ `--tab-on-bg` 令牌仍留在主题表里(别的皮肤 / 后续方案可能要用),但**底栏不再消费它**。 */
|
|
1074
|
+
/* ⚠️ `.icw` 的**高度必须是 auto**(2026-10-26 改):原来写死 34,SVG 在里面居中,
|
|
1075
|
+
26px 的图标上下各留 4px —— 于是 `margin-bottom:--ic-gap` 只能把「盒子底」放到 15px,
|
|
1076
|
+
真实图标底还差 4px;而 28px 那枚只差 3px,四枚又错开了。高度交给内容撑,
|
|
1077
|
+
盒底 = 图标底,`--ic-gap` 就是图标到栏底的**真实**距离,与图标尺寸无关。
|
|
1078
|
+
⚠️ 那个 44×34 的尺寸原本是给选中底(已删,见上面的注释)用的,现在只剩宽度还有意义
|
|
1079
|
+
(让四枚的点击/对齐中心一致);高度留着反而制造上面那个 4px 的暗坑。 */
|
|
1080
|
+
.tab .icw{display:grid;place-items:center;width:44px;height:auto;border-radius:11px;
|
|
1081
|
+
margin-bottom:var(--ic-gap)}
|
|
779
1082
|
.tab svg{display:block;transition:transform .16s}
|
|
780
1083
|
.tab:active svg{transform:scale(.92)}
|
|
781
1084
|
/* ── 「快速新建」:**半嵌入**底栏的圆形主按钮(2026-10-22 定稿)
|
|
@@ -805,8 +1108,10 @@ html:not([data-theme="dark"]) .tab.is-on svg{
|
|
|
805
1108
|
几何全用相对量(`bottom:calc(100% - 44px)`、`top:-27px`),安全区变化时洞和按钮一起走。
|
|
806
1109
|
|
|
807
1110
|
**两页一致**:会话页不再把「+」收进栏内(2026-10-22 用户:「圆没有突出了 …两个效果要一致」)。
|
|
808
|
-
凸出的圆在列表页和对话页长得完全一样,只用 composer 的下内边距给它让出空间。
|
|
809
|
-
|
|
1111
|
+
凸出的圆在列表页和对话页长得完全一样,只用 composer 的下内边距给它让出空间。
|
|
1112
|
+
⚠️ `.tab` / `.tabfab` 都要 `padding-bottom:env(safe-area-inset-bottom)`:底栏高度是 `56 + safe-area`,
|
|
1113
|
+
不扣的话图标会在整条栏里居中、掉进安全区(2026-10-25 用户:「我的 tab 下面还没好」)。 */
|
|
1114
|
+
.tabfab{flex:0 0 84px;padding-bottom:0} /* 与 .tab 同一口径:栏体已扣过一次 safe-area,这里不再扣 */
|
|
810
1115
|
/* 洞 = 半圆 + 接触影。
|
|
811
1116
|
槽底是 `--fab-well`(栏面稍暗一档的**同色系**),不是 `--canvas`:
|
|
812
1117
|
用页面底色在暗色下会成黑箍,在浅色下又几乎看不见 —— 两头都不对。
|
|
@@ -816,18 +1121,58 @@ html:not([data-theme="dark"]) .tab.is-on svg{
|
|
|
816
1121
|
**露出的槽宽只有 3px**(槽半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
|
|
817
1122
|
正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。 */
|
|
818
1123
|
.tabbar::before{content:"";position:absolute;z-index:1;pointer-events:none;
|
|
819
|
-
left:50%;
|
|
1124
|
+
left:50%;
|
|
1125
|
+
/* 与按钮**同心**:按钮圆心距内容区底 `--fab-gap + 34`,所以井的圆心也在这里,
|
|
1126
|
+
井顶 = 内容区顶 − (圆心离顶) − 井半径(37)。以前写死 `top:-27px`(= 56 − 46 − 37),
|
|
1127
|
+
改栏高或改 --fab-gap 就会和按钮分开 —— 现在跟按钮一起走。 */
|
|
1128
|
+
top:calc(var(--bar-h) - (var(--fab-gap) + 34px) - 37px);
|
|
1129
|
+
transform:translateX(-50%);
|
|
820
1130
|
width:74px;height:74px;border-radius:50%;
|
|
821
|
-
|
|
1131
|
+
/* ⚠️ 只留**上半圈**,下缘正好压在弧线上(2026-10-26 用户:「圆的灰色光圈应该有上半圈跟弧线形成连接」)。
|
|
1132
|
+
曾经写的是 `inset(27px 0 0 0)`(切顶留底),露出 y=27~74 这 47px —— 其中 y=37.5~74
|
|
1133
|
+
那 36.5px 整个落在**弧线以下**,于是按钮腰线以下兜出一道完整的下半圈灰带,
|
|
1134
|
+
弧线以上只剩零头 10.5px,读作「按钮垫了个孤立的浅灰盘子」(用户截图确认的形态)。
|
|
1135
|
+
现在反过来:切掉底部,只保留 y=0~37.5 的上半圈,它的**下缘就落在弧线最深处那条线上**,
|
|
1136
|
+
于是灰环从弧线处起兜住按钮上半圈 —— 环与弧线是同一条边界,接成一体而不是两截。
|
|
1137
|
+
裁剪线位置 = 井内弧线 y = (--fab-gap + 34 + 37) − --arc-dip(= 12+34+37−45.5 = 37.5,
|
|
1138
|
+
与井内圆心 37 只差 0.5,即弧线基本穿过圆心);要切的底部 = 井高 74 − 该值 = 36.5。
|
|
1139
|
+
⚠️ 弧线是弯的(两端高、中间低),这里用水平线裁,两端各差 0.34px(弧线在 x=±37 处 y=10.16 对 10.5),
|
|
1140
|
+
肉眼不可见,但**别把 --arc-dip 调得比 --fab-gap + 34 还小**,否则裁剪线会跑到井底下面去。 */
|
|
1141
|
+
clip-path:inset(0 0 calc(74px - (var(--fab-gap) + 34px + 37px - var(--arc-dip))) 0);
|
|
1142
|
+
/* ⚠️ 井的底必须是不透明的(var(--fab-well) 本身是实色):栏体已经透明,
|
|
1143
|
+
底色只在弧线以下,井露出的那一圈只能靠它自己兜住,否则会露出背后的页面/滚动内容。
|
|
1144
|
+
这里**只画接触影,不画外圈的线**(线交给下面的 ::after 描边,见那里的注释)。 */
|
|
822
1145
|
background:
|
|
823
1146
|
radial-gradient(circle 37px at 50% 50%,
|
|
824
1147
|
var(--fab-notch) 33.5px,
|
|
825
1148
|
var(--fab-notch) 34.5px,
|
|
826
|
-
color-mix(in srgb,var(--fab-notch) 35%,transparent)
|
|
827
|
-
transparent
|
|
1149
|
+
color-mix(in srgb,var(--fab-notch) 35%,transparent) 35px,
|
|
1150
|
+
transparent 35.5px),
|
|
828
1151
|
var(--fab-well)}
|
|
1152
|
+
/* 井的外圈线:**必须是一条真正的描边圆**,不能用径向渐变的末圈(2026-10-26 用户:
|
|
1153
|
+
「弧线要与外圈的线样式一致才有连贯感」)。
|
|
1154
|
+
⚠️ 为什么不能用渐变:径向渐变的「环」在数学上是一个圆环带(半径 35→37),
|
|
1155
|
+
它在**水平剖面**上的厚度随高度剧烈变化 —— 圆心处 2.00px、y=20 处 2.41、
|
|
1156
|
+
y=30 处 3.63、y=36 处 8.54。而被 clip-path 切出来的井正好是上半圈,
|
|
1157
|
+
弧线接入的位置(井的 9 点/3 点,y≈37)恰好在圆环最宽的地方:实测那一竖条暗色
|
|
1158
|
+
从 y=183.75 一直连到 y=193(9.3px 厚),旁边纯弧线只有 2px(y=190→191.7)。
|
|
1159
|
+
于是「线」变成「带」,与弧线完全不是一个东西。
|
|
1160
|
+
`border` 的宽度是**恒定**的(沿法线 2px),所以这样画出来的圈任何角度都是 2px,
|
|
1161
|
+
与 `.bararc__s` 的 `stroke-width:2px` 完全同宽、同色(同一个 `--bar-edge` 令牌)。
|
|
1162
|
+
⚠️ 用 inset:0 + border 而不是缩小尺寸:宽度 74 = 井的直径,border 2 后内容盒正好 70,
|
|
1163
|
+
`box-sizing:border-box` 保证外径仍是 74,与井严丝合缝。
|
|
1164
|
+
⚠️ 同样要跟着切底:否则下半圈又冒出来(用户上一轮刚否掉的形态)。 */
|
|
1165
|
+
.tabbar::after{content:"";position:absolute;z-index:1;pointer-events:none;
|
|
1166
|
+
left:50%;top:calc(var(--bar-h) - (var(--fab-gap) + 34px) - 37px);
|
|
1167
|
+
transform:translateX(-50%);
|
|
1168
|
+
box-sizing:border-box;width:74px;height:74px;border-radius:50%;
|
|
1169
|
+
border:2px solid var(--bar-edge);
|
|
1170
|
+
clip-path:inset(0 0 calc(74px - (var(--fab-gap) + 34px + 37px - var(--arc-dip))) 0)}
|
|
829
1171
|
.tabfab__b{position:absolute;z-index:2;
|
|
830
|
-
|
|
1172
|
+
/* ⚠️ 从**栏底**量,不用 `100% - 44px`(那是拿整条栏高反推的:栏高一变,按钮就跟着漂)。
|
|
1173
|
+
`bottom` 的基准是绝对定位包含块的 **padding box 底边** = 栏盒底边(含安全区),
|
|
1174
|
+
而「距底部固定」指的是距**栏面底边**(= 内容区底 = 栏底 − 安全区),所以要先跨过 --sab。 */
|
|
1175
|
+
left:50%;bottom:calc(var(--sab) + var(--fab-gap));
|
|
831
1176
|
transform:translateX(-50%);
|
|
832
1177
|
width:68px;height:68px;
|
|
833
1178
|
display:flex;align-items:center;justify-content:center;
|
|
@@ -1026,9 +1371,11 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1026
1371
|
「上露 24 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆弧上方 12px,不遮不压。
|
|
1027
1372
|
原来 composer 下留 60(= 上探 30 + 呼吸 30)再叠占位的 68 栏,屏幕底到输入框 162;
|
|
1028
1373
|
现在 56 + 24 + 12 = 92,对话区多出 70px。
|
|
1029
|
-
|
|
1374
|
+
⚠️ 2026-10-26:`56px` 换成 `var(--bar-h)`,「24」换成「--bar-h − --fab-gap − 34」的实算值。
|
|
1375
|
+
三个常量改一个,这里的留白自动跟着走(以前写死 56 和 24,改栏高就会压住输入框)。
|
|
1376
|
+
「上露」= 按钮顶到栏顶的距离 = --bar-h − (--fab-gap + 34)(按钮高 68,底距 --fab-gap)。
|
|
1030
1377
|
**只叠一次**:safe-area 由这里一次性算进来,别再给 .composer 的基础 padding 加一遍。 */
|
|
1031
|
-
#conv .composer{padding-bottom:calc(12px +
|
|
1378
|
+
#conv .composer{padding-bottom:calc(12px + var(--bar-h) - var(--fab-gap) - 34px + var(--bar-h) + var(--sab))}
|
|
1032
1379
|
|
|
1033
1380
|
/* 组件挂载点:占满剩余高度 */
|
|
1034
1381
|
#conv{flex:1;min-height:0;display:flex}
|
|
@@ -1105,6 +1452,23 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1105
1452
|
.browser.is-float .browser__stage{flex:0 0 auto;aspect-ratio:16/9;width:100%}
|
|
1106
1453
|
.browser.is-float .browser__frame{transform-origin:0 0;overflow:hidden;scrollbar-width:none}
|
|
1107
1454
|
.browser.is-float .browser__frame::-webkit-scrollbar{display:none}
|
|
1455
|
+
/* 横屏提示条:叠在页面顶部,一条就够(见 brSetLandscape)。
|
|
1456
|
+
工作台按桌面宽屏排版(magic-video 三栏、magic-shorts 双栏),手机竖屏里右半边
|
|
1457
|
+
直接被切掉,人看到的就是「打不开」。检测到溢出就自动转横屏,并用这条说明现状 +
|
|
1458
|
+
给一条退路 —— 自动改变屏幕方向却不解释,比不改变更让人慌。 */
|
|
1459
|
+
.browser__land{position:absolute;z-index:3;left:50%;top:10px;transform:translateX(-50%);
|
|
1460
|
+
display:flex;align-items:center;gap:8px;max-width:calc(100% - 24px);
|
|
1461
|
+
padding:7px 8px 7px 12px;border-radius:999px;
|
|
1462
|
+
background:var(--raised);color:var(--ink);font-size:12px;line-height:1.2;
|
|
1463
|
+
box-shadow:0 4px 14px rgba(15,23,42,.16),inset 0 0 0 1px var(--border)}
|
|
1464
|
+
.browser__land[hidden]{display:none}
|
|
1465
|
+
.browser__land span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1466
|
+
/* 横屏时挪到角落:居中的话正好压在旋转后正中间的内容上(实测最挡事的是分章表格)。 */
|
|
1467
|
+
.browser.is-land .browser__land{left:10px;top:10px;transform:none;max-width:calc(100% - 20px)}
|
|
1468
|
+
.browser__land b{font-weight:600}
|
|
1469
|
+
.browser__landbtn{flex:0 0 auto;border:none;border-radius:999px;padding:5px 10px;
|
|
1470
|
+
background:var(--subtle);color:var(--primary-action);font-size:12px;font-weight:600}
|
|
1471
|
+
.browser__landbtn:active{opacity:.6}
|
|
1108
1472
|
.brball{position:fixed;z-index:90;width:46px;height:46px;border-radius:50%;
|
|
1109
1473
|
display:flex;align-items:center;justify-content:center;border:none;
|
|
1110
1474
|
background:var(--raised);color:var(--primary-action);
|
|
@@ -1291,7 +1655,10 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1291
1655
|
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><path d="M12 8v4.3l3 1.8"/></symbol>
|
|
1292
1656
|
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M10 3.5v3M14 3.5v3M10 17.5v3M14 17.5v3M3.5 10h3M3.5 14h3M17.5 10h3M17.5 14h3"/></symbol>
|
|
1293
1657
|
<symbol id="i-db" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6" rx="7" ry="3"/><path d="M5 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6"/><path d="M5 12v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6"/></symbol>
|
|
1294
|
-
|
|
1658
|
+
<!-- 置顶 = 图钉,**正立**(用户 2026-10:「显示的钉子是直的」)。
|
|
1659
|
+
形状按 Material push_pin 的比例加粗:宽帽 + 柱身 + 底翼 + 粗针,
|
|
1660
|
+
小尺寸(12px 角标)也认得出是钉子,不是书签 / 十字。 -->
|
|
1661
|
+
<symbol id="i-pin" viewBox="0 0 24 24" fill="currentColor"><path d="M17.6 9.2V4.8h1a1.2 1.2 0 0 0 0-2.4H5.4a1.2 1.2 0 0 0 0 2.4h1v4.4c0 2-1.5 3.6-3.4 3.6v2.7h7.3v6.6a1.7 1.7 0 0 0 3.4 0v-6.6h7.3v-2.7c-1.9 0-3.4-1.6-3.4-3.6z"/></symbol>
|
|
1295
1662
|
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7a2 2 0 0 1 2-2h3.2l1.6 2H18a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/></symbol>
|
|
1296
1663
|
<symbol id="i-chev-r" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></symbol>
|
|
1297
1664
|
<symbol id="i-chev-d" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
|
|
@@ -1390,14 +1757,14 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1390
1757
|
填金在选中绿上只有 1.29,填孔在 28px 下糊成一团黑;
|
|
1391
1758
|
细线两头都不犯:暗底当亮线、亮底当暗线,且不占体积。
|
|
1392
1759
|
金丹垫 canvas 暗衬后才立得住(朱 vs 绿 2.0 → 朱 vs 暗衬 4.0)。 -->
|
|
1393
|
-
<g fill="none" stroke="var(--canvas,#
|
|
1760
|
+
<g fill="none" stroke="var(--canvas,#06180F)" stroke-width=".62" stroke-linecap="round">
|
|
1394
1761
|
<path d="M9.15 16.45q.55 2.4 2.85 2.4"/>
|
|
1395
1762
|
<path d="M14.85 16.45q-.55 2.4-2.85 2.4"/>
|
|
1396
1763
|
<path d="M10.45 16.15 12 14.5 13.55 16.15"/>
|
|
1397
1764
|
</g>
|
|
1398
1765
|
<!-- 金丹:卧在印心,外面垫一圈 canvas 暗衬 —— 朱在绿身上只有 2.0,
|
|
1399
1766
|
垫了暗衬才有 4.0(深)/ 4.5(浅),28px 才立得住;明灭 = 吐纳 -->
|
|
1400
|
-
<circle cx="12" cy="16.15" r=".78" fill="var(--canvas,#
|
|
1767
|
+
<circle cx="12" cy="16.15" r=".78" fill="var(--canvas,#06180F)" stroke="none"/>
|
|
1401
1768
|
<circle class="yypill" cx="12" cy="16.15" r=".5" fill="var(--q-blood,#E23B2E)" stroke="none"/>
|
|
1402
1769
|
</g>
|
|
1403
1770
|
</svg></button>
|
|
@@ -1416,7 +1783,11 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1416
1783
|
<div class="history__list" id="historyList"></div>
|
|
1417
1784
|
</div>
|
|
1418
1785
|
|
|
1419
|
-
<!-- ══════════ 我的(设置)══════════
|
|
1786
|
+
<!-- ══════════ 我的(设置)══════════
|
|
1787
|
+
结构沿用全站设置页的既有语言:`.history__top` 标题栏 + `.history__group` 分组名 +
|
|
1788
|
+
`.setcard` 卡片 + `.setrow` 行 + `.seg` / `.tgl`。不新造视觉,也没有说明文字。
|
|
1789
|
+
分组只解决「找得到」:账户 / 引擎 / 本机偏好 / 通知与连接 / 服务 →
|
|
1790
|
+
危险操作(断开连接)单独一张卡。 -->
|
|
1420
1791
|
<div class="mine" id="mine" hidden>
|
|
1421
1792
|
<div class="history__top">
|
|
1422
1793
|
<button class="topbar__icon" id="btnMineBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
@@ -1425,7 +1796,38 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1425
1796
|
</div>
|
|
1426
1797
|
<div class="history__list">
|
|
1427
1798
|
<div class="setcard">
|
|
1428
|
-
<div class="
|
|
1799
|
+
<div class="history__group">账户</div>
|
|
1800
|
+
<div class="setrow">
|
|
1801
|
+
<span class="setrow__l">当前桌面</span>
|
|
1802
|
+
<span class="setrow__v" id="mineDevice">—</span>
|
|
1803
|
+
<button class="setrow__act" type="button" id="btnMineSwitchEnv">切换</button>
|
|
1804
|
+
</div>
|
|
1805
|
+
<!-- 飞书推送:绑定码 / 开关 / 凭据放子页面(#feishuPage),这里只留一行入口。 -->
|
|
1806
|
+
<button class="setrow" id="btnFeishuPage" type="button">
|
|
1807
|
+
<span class="setrow__l">飞书推送</span>
|
|
1808
|
+
<span class="setrow__v" id="feishuSummary">未绑定</span>
|
|
1809
|
+
</button>
|
|
1810
|
+
</div>
|
|
1811
|
+
|
|
1812
|
+
<div class="setcard">
|
|
1813
|
+
<div class="history__group">引擎</div>
|
|
1814
|
+
<!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
|
|
1815
|
+
**引擎与模型同一行**(2026-10-26 用户:「引擎和模型显示在同一行即可」):
|
|
1816
|
+
右格主行「引擎 · 模型」(放不下就折到第二行,不截断),备用再小一档跟在后面。
|
|
1817
|
+
⚠️ #defaultModel / #suggestModel 这两个 id 被 tests/check-conv-component.mjs 读,
|
|
1818
|
+
现在挂在同一行里内嵌的 span 上 —— 别删 id、别把模型拆回独立一行。 -->
|
|
1819
|
+
<button class="setrow setrow--engmodel" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v setrow__v--pair" id="defaultEngine">自动(首个可用)</span><span class="setrow__v setrow__v--model" id="defaultModel">跟随引擎</span></button>
|
|
1820
|
+
<button class="setrow setrow--engmodel" id="btnSuggestEngine"><span class="setrow__l">建议引擎</span><span class="setrow__v setrow__v--pair" id="suggestEngine">不开启</span><span class="setrow__v setrow__v--model" id="suggestModel"></span></button>
|
|
1821
|
+
<!-- 团队入口:外层 #teamCard 是**显隐开关**(paintTeamPref 按 core 能力位翻 hidden),
|
|
1822
|
+
内层 #btnTeamPage 是**点击目标**。两个 id 缺一不可 —— 合成一个会让入口点不动,
|
|
1823
|
+
或让「core 不支持团队」时仍亮着一个点了必然失败的设置行。 -->
|
|
1824
|
+
<div class="mslot" id="teamCard">
|
|
1825
|
+
<button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
|
|
1826
|
+
</div>
|
|
1827
|
+
</div>
|
|
1828
|
+
|
|
1829
|
+
<div class="setcard">
|
|
1830
|
+
<div class="history__group">本机偏好</div>
|
|
1429
1831
|
<div class="setrow"><span class="setrow__l">主题</span>
|
|
1430
1832
|
<span class="seg" id="segTheme">
|
|
1431
1833
|
<button class="seg__b" data-v="system">系统</button>
|
|
@@ -1442,45 +1844,29 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1442
1844
|
</div>
|
|
1443
1845
|
<div class="setrow"><span class="setrow__l">任务模式</span>
|
|
1444
1846
|
<span class="seg" id="segMode">
|
|
1847
|
+
<button class="seg__b" data-v="auto">自动</button>
|
|
1445
1848
|
<button class="seg__b" data-v="parallel">并行</button>
|
|
1446
1849
|
<button class="seg__b" data-v="serial">串行</button>
|
|
1447
1850
|
</span>
|
|
1448
1851
|
</div>
|
|
1449
|
-
<!-- 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」;
|
|
1450
|
-
同日再定:「跟团队模式一样改成挂在会话上,子会话继承,不再是全局」)。
|
|
1451
|
-
所以这里**没有**全局开关 —— 与团队模式同一处入口:会话页右上角 → 任务运行设置。 -->
|
|
1452
|
-
<!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
|
|
1453
|
-
两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
|
|
1454
|
-
<!-- 默认引擎(主 + 备一屏配完,2026-10-24 用户:「这样布局太浪费了,要在一次点击里面设置两个」)。
|
|
1455
|
-
一行显示「主 · 模型 | 备 …」;点开抽屉顶部切 主/备,选完**不关**,接着配另一档。 -->
|
|
1456
|
-
<button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v setrow__v--pair" id="defaultEngine">自动(首个可用)</span></button>
|
|
1457
|
-
<!-- 建议引擎同口径:主备一个抽屉里配,默认不开启(引擎空 = 不请求 /desk/suggest)。 -->
|
|
1458
|
-
<button class="setrow" id="btnSuggestEngine"><span class="setrow__l">建议引擎</span><span class="setrow__v setrow__v--pair" id="suggestEngine">不开启</span></button>
|
|
1459
|
-
<!-- 2026-10-22 用户:「触感反馈 / 语言」这两项不需要 —— 已移除(设备级偏好只剩主题/字体)。 -->
|
|
1460
1852
|
<div class="setrow"><span class="setrow__l">网络代理</span><button class="tgl" id="tglVpn" aria-label="网络代理"></button></div>
|
|
1461
|
-
<button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
|
|
1462
1853
|
</div>
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
<button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
|
|
1854
|
+
|
|
1855
|
+
<div class="setcard">
|
|
1856
|
+
<div class="history__group">通知与连接</div>
|
|
1857
|
+
<button class="setrow" id="btnNotify"><span class="setrow__l">系统通知</span><span class="setrow__v" id="notifyState">—</span></button>
|
|
1468
1858
|
</div>
|
|
1859
|
+
|
|
1469
1860
|
<div class="setcard">
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
显示的是 **APK 自己的版本**(构建时从 magic-agent/package.json 注入,闸门页
|
|
1473
|
-
通过 ?appv= 带进来);浏览器预览没有 appv 时才回落到桌面端 health.version。
|
|
1474
|
-
点这一行就去问桌面端内置的 APK 是哪一版,新了就直接触发下载安装。 -->
|
|
1861
|
+
<div class="history__group">服务</div>
|
|
1862
|
+
<!-- 版本:显示 APK 自己的版本(构建时注入),点它去问桌面端是否带了更新。 -->
|
|
1475
1863
|
<button class="setrow" id="btnMineVersion" type="button">
|
|
1476
1864
|
<span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span>
|
|
1477
1865
|
</button>
|
|
1478
|
-
<button class="setrow" id="btnUpgrade"><span class="setrow__l">重新加载界面</span><span class="setrow__v" id="upgradeState"
|
|
1479
|
-
<!--
|
|
1480
|
-
走 POST /desk/restart:桌面端先答再由一个脱离终端的助手停旧起新,端口 / 令牌沿用。
|
|
1481
|
-
⚠️ 会中断桌面端**正在跑的所有会话** —— 点击前必须二次确认。 -->
|
|
1866
|
+
<button class="setrow" id="btnUpgrade"><span class="setrow__l">重新加载界面</span><span class="setrow__v" id="upgradeState"></span></button>
|
|
1867
|
+
<!-- 重启会中断桌面端**正在跑的所有会话**,点击前二次确认。 -->
|
|
1482
1868
|
<button class="setrow" id="btnRestartClient" type="button">
|
|
1483
|
-
<span class="setrow__l">重启服务</span><span class="setrow__v" id="restartClientState"
|
|
1869
|
+
<span class="setrow__l">重启服务</span><span class="setrow__v" id="restartClientState"></span>
|
|
1484
1870
|
</button>
|
|
1485
1871
|
</div>
|
|
1486
1872
|
<div class="setcard">
|
|
@@ -1489,6 +1875,21 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1489
1875
|
</div>
|
|
1490
1876
|
</div>
|
|
1491
1877
|
|
|
1878
|
+
<!-- ══════════ 我的 → 飞书推送(绑定 + 开关 + 凭据)══════════
|
|
1879
|
+
2026-10-26 用户:「另增加飞书绑定支持登录后推送通知」。
|
|
1880
|
+
目标:手机在后台 / 锁屏 / 离开局域网时也收得到「任务完成 / 待确认」——
|
|
1881
|
+
手机端系统通知只在本页开着轮询时才响(见 pollOnce),飞书推送由**桌面端**发。
|
|
1882
|
+
行由 paintFeishu() 按 GET /desk/feishu 的状态渲染(服务端宣布什么就显示什么)。 -->
|
|
1883
|
+
<div class="history" id="feishuPage" hidden>
|
|
1884
|
+
<div class="history__top">
|
|
1885
|
+
<button class="topbar__icon" id="btnFeishuBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
1886
|
+
<span class="history__title">飞书推送</span>
|
|
1887
|
+
</div>
|
|
1888
|
+
<div class="history__list" id="feishuBody">
|
|
1889
|
+
<div class="history__hint">正在读取飞书配置…</div>
|
|
1890
|
+
</div>
|
|
1891
|
+
</div>
|
|
1892
|
+
|
|
1492
1893
|
<!-- ══════════ 我的 → 团队模式(计划 + 7 类子任务)══════════
|
|
1493
1894
|
从「我的」那一行点进来。这里配的全都是**设备级偏好**(开关本身按会话,在会话页运行设置里)。
|
|
1494
1895
|
**只有一套划分**:计划 + 7 个角色(writer / researcher / reviewer / coder / designer /
|
|
@@ -1586,6 +1987,15 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1586
1987
|
<!-- 脚本的二级视图:详情 + 启停按钮 + 实时日志(内容由 scripts.js 渲染) -->
|
|
1587
1988
|
<div class="history" id="scriptDetail" hidden></div>
|
|
1588
1989
|
|
|
1990
|
+
<!-- ══════════ 发现 → 应用(工作台:magic-shorts / magic-video)══════════
|
|
1991
|
+
**独立子页面**:内容、样式、逻辑都在 apps.js / apps.css,本文件只留两个空容器。
|
|
1992
|
+
工作台在**这台桌面**上跑,服务端负责启停;手机在内置浏览器里打开(走桌面网络)。
|
|
1993
|
+
数据形状与端点见 CLIENT-CONTRACT §2(/desk/apps)。 -->
|
|
1994
|
+
<div class="history" id="apps" hidden></div>
|
|
1995
|
+
|
|
1996
|
+
<!-- 应用的二级视图:启动 / 停止 / 在内置浏览器打开(内容由 apps.js 渲染) -->
|
|
1997
|
+
<div class="history" id="appDetail" hidden></div>
|
|
1998
|
+
|
|
1589
1999
|
<!-- ══════════ 发现 → 插件(工程 CLI 目录)══════════
|
|
1590
2000
|
列表与详情都是**服务端宣布什么就显示什么**(GET /desk/plugins)。 -->
|
|
1591
2001
|
<div class="history" id="plugins" hidden>
|
|
@@ -1660,6 +2070,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1660
2070
|
<!-- 截图:只有需求页的「页面完成情况」流程走中转时才出现(reqshot.js 打开它)。
|
|
1661
2071
|
默认 hidden —— 平时内置浏览器就是浏览器,多一枚按钮只会让人以为是别的功能。 -->
|
|
1662
2072
|
<button class="browser__ib" id="brShot" type="button" aria-label="截图" title="截图(需求页的页面完成情况)" hidden><svg width="18" height="18"><use href="#i-shot"/></svg></button>
|
|
2073
|
+
<button class="browser__ib" id="brLand" type="button" aria-label="横屏显示" title="横屏显示" hidden><svg width="18" height="18"><use href="#i-phone"/></svg></button>
|
|
1663
2074
|
<button class="browser__ib" id="brStar" type="button" aria-label="收藏"><svg width="18" height="18"><use href="#i-star"/></svg></button>
|
|
1664
2075
|
<button class="browser__ib" id="brRefresh" type="button" aria-label="刷新"><svg width="18" height="18"><use href="#i-refresh"/></svg></button>
|
|
1665
2076
|
<button class="browser__ib" id="brShare" type="button" aria-label="分享"><svg width="18" height="18"><use href="#i-share"/></svg></button>
|
|
@@ -1670,7 +2081,16 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1670
2081
|
<button class="browser__app" id="brClose" type="button" aria-label="关闭浏览器">✕</button>
|
|
1671
2082
|
</header>
|
|
1672
2083
|
<div class="browser__stage" id="brStage">
|
|
1673
|
-
|
|
2084
|
+
<!-- referrerpolicy:**不能**用 no-referrer(2026-10-26)。工作台(发现 → 应用)的页面从
|
|
2085
|
+
/workbench/{id}/ 跳到 "/" 后,页面里的 '/api/…' 要靠 Referer 认出「这是哪个工作台」
|
|
2086
|
+
(见 magic-client 的 apps.go / workbenchFor)。no-referrer 会让这些请求一个头都不带,
|
|
2087
|
+
页面出来、数据全空。no-referrer-when-downgrade = 同源照带、只在不安全的降级时省略,
|
|
2088
|
+
跨站隐私口径不变。 -->
|
|
2089
|
+
<div class="browser__land" id="brLandHint" hidden>
|
|
2090
|
+
<span><b>已转横屏</b></span>
|
|
2091
|
+
<button class="browser__landbtn" id="brLandOff" type="button">竖屏</button>
|
|
2092
|
+
</div>
|
|
2093
|
+
<iframe class="browser__frame" id="brFrame" src="about:blank" referrerpolicy="no-referrer-when-downgrade" allow="fullscreen"></iframe>
|
|
1674
2094
|
</div>
|
|
1675
2095
|
<nav class="browser__nav">
|
|
1676
2096
|
<button class="browser__nb" id="brBack" type="button" aria-label="返回"><svg width="22" height="22"><use href="#i-back"/></svg></button>
|
|
@@ -1728,7 +2148,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1728
2148
|
**只有图标、没有文字**(2026-10-22 用户:「底栏只保留图标不需要文字了」):
|
|
1729
2149
|
语义靠 aria-label 承担。 -->
|
|
1730
2150
|
<nav class="tabbar" id="tabbar" hidden>
|
|
1731
|
-
<
|
|
2151
|
+
<div class="bararc" aria-hidden="true"><svg class="bararc__svg"><path class="bararc__d"/><path class="bararc__s"/></svg></div>
|
|
1732
2152
|
<button class="tab" type="button" data-tab="tasks" aria-label="任务"><span class="icw"><svg class="furnsvg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1733
2153
|
<defs>
|
|
1734
2154
|
<linearGradient id="i-furn-fire" x1="0" y1="0" x2="0" y2="1">
|
|
@@ -1881,14 +2301,14 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1881
2301
|
填金在选中绿上只有 1.29,填孔在 28px 下糊成一团黑;
|
|
1882
2302
|
细线两头都不犯:暗底当亮线、亮底当暗线,且不占体积。
|
|
1883
2303
|
金丹垫 canvas 暗衬后才立得住(朱 vs 绿 2.0 → 朱 vs 暗衬 4.0)。 -->
|
|
1884
|
-
<g fill="none" stroke="var(--canvas,#
|
|
2304
|
+
<g fill="none" stroke="var(--canvas,#06180F)" stroke-width=".62" stroke-linecap="round">
|
|
1885
2305
|
<path d="M9.15 16.45q.55 2.4 2.85 2.4"/>
|
|
1886
2306
|
<path d="M14.85 16.45q-.55 2.4-2.85 2.4"/>
|
|
1887
2307
|
<path d="M10.45 16.15 12 14.5 13.55 16.15"/>
|
|
1888
2308
|
</g>
|
|
1889
2309
|
<!-- 金丹:卧在印心,外面垫一圈 canvas 暗衬 —— 朱在绿身上只有 2.0,
|
|
1890
2310
|
垫了暗衬才有 4.0(深)/ 4.5(浅),28px 才立得住;明灭 = 吐纳 -->
|
|
1891
|
-
<circle cx="12" cy="16.15" r=".78" fill="var(--canvas,#
|
|
2311
|
+
<circle cx="12" cy="16.15" r=".78" fill="var(--canvas,#06180F)" stroke="none"/>
|
|
1892
2312
|
<circle class="yypill" cx="12" cy="16.15" r=".5" fill="var(--q-blood,#E23B2E)" stroke="none"/>
|
|
1893
2313
|
</g>
|
|
1894
2314
|
</svg></span></button>
|
|
@@ -1934,6 +2354,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1934
2354
|
<!-- 脚本页(独立子页面模块):只在这里加载并注册 window.MagicScripts,
|
|
1935
2355
|
真正的 DOM/逻辑都在 scripts.js,主 IIFE 末尾 mount 时把宿主能力注入进去。 -->
|
|
1936
2356
|
<script src="scripts.js"></script>
|
|
2357
|
+
<!-- 应用页(独立子页面模块):只在这里加载并注册 window.MagicApps,
|
|
2358
|
+
真正的 DOM/逻辑都在 apps.js,主 IIFE 末尾 mount 时把宿主能力注入进去。 -->
|
|
2359
|
+
<script src="apps.js"></script>
|
|
1937
2360
|
<script>
|
|
1938
2361
|
(function () {
|
|
1939
2362
|
'use strict';
|
|
@@ -1958,12 +2381,126 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1958
2381
|
var taskSearchBar = document.getElementById('taskSearchBar');
|
|
1959
2382
|
var taskSearchInput = document.getElementById('taskSearchInput');
|
|
1960
2383
|
var mineEl = document.getElementById('mine');
|
|
2384
|
+
var feishuPageEl = document.getElementById('feishuPage');
|
|
1961
2385
|
var mineDevice = document.getElementById('mineDevice');
|
|
1962
2386
|
var mineVersion = document.getElementById('mineVersion');
|
|
1963
2387
|
var segTheme = document.getElementById('segTheme');
|
|
1964
2388
|
var segFont = document.getElementById('segFont');
|
|
1965
2389
|
var segMode = document.getElementById('segMode');
|
|
1966
2390
|
var tabbarEl = document.getElementById('tabbar');
|
|
2391
|
+
/* 底栏弧面(见 .bararc 注释):背景层铺满整条栏,脚本只负责往里写两条路径。
|
|
2392
|
+
坐标必须用 getBoundingClientRect 量(= 屏幕上的显示尺寸),不能用 clientWidth:WebView 里设了
|
|
2393
|
+
字体大小偏好时会给 body 打 zoom,那时 clientWidth 是「未缩放的布局宽度」、rect 是「缩放后的
|
|
2394
|
+
显示宽度」,混用会让弧按错误的宽度画 —— 屏幕上就是左右不等宽(2026-10-25 手机截图实测)。
|
|
2395
|
+
|
|
2396
|
+
⚠️⚠️ 弧的朝向(2026-10-25 用户:「现在的线是左高右低」):
|
|
2397
|
+
SVG 的 y 轴向下(y 越大越靠下)。要让「中间高、两端低、两端往上翘」的嘴角形,端点必须放在 y=0、
|
|
2398
|
+
控制点放在 y=amp(把中段往下拽)。反过来(端点 y=amp、控制点 y=0)是拱桥形,左右虽然对称,
|
|
2399
|
+
但人会按屏幕基线把左端读成「高」、右端一路压下去 → 就是用户看到的「左高右低」。
|
|
2400
|
+
当前形状:
|
|
2401
|
+
M0 0 C x1 amp, x2 amp, W 0 —— 起点 / 终点都在 y=0(两端上翘到顶)
|
|
2402
|
+
控制点两边都是 y=amp(把中段往下拽,形成对称的浅盆)
|
|
2403
|
+
x1 = W·K_END、x2 = W − x1 保证左右严格镜像;amp = AMP(14),屏幕上中间比两端低约 10.5px。
|
|
2404
|
+
页面底色那一大片也用玉面填(路径一直落到盒子底),弧以上被切掉的部分露出页面。 */
|
|
2405
|
+
(function () {
|
|
2406
|
+
var arc = tabbarEl && tabbarEl.querySelector('.bararc');
|
|
2407
|
+
if (!arc) return;
|
|
2408
|
+
var svg = arc.querySelector('.bararc__svg');
|
|
2409
|
+
var line = arc.querySelector('.bararc__s');
|
|
2410
|
+
var fill = arc.querySelector('.bararc__d');
|
|
2411
|
+
if (!svg || !line || !fill) return;
|
|
2412
|
+
/* 端点 y=0、控制点 y=AMP → 中间低、两端翘。改这里要同步 .bararc 注释②(两处不一致会画错朝向)。 */
|
|
2413
|
+
var K_END = 0.30;
|
|
2414
|
+
function draw() {
|
|
2415
|
+
var r = svg.getBoundingClientRect();
|
|
2416
|
+
if (!r.width || !r.height) return; /* 隐藏时量不到,等下一次触发 */
|
|
2417
|
+
var W = Math.round(r.width), HH = Math.round(r.height);
|
|
2418
|
+
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + HH);
|
|
2419
|
+
svg.setAttribute('width', String(W));
|
|
2420
|
+
svg.setAttribute('height', String(HH));
|
|
2421
|
+
/* ── 下沉深度**从 --arc-dip 反算**(2026-10-26 用户:「弧线距离底部的距离也固定」)──
|
|
2422
|
+
弧层现在铺满**整个栏盒**(bottom:0,含安全区),端点恒定在 y=0(栏顶)。
|
|
2423
|
+
要让它最深处距**内容区底**恒为 --arc-dip,则下沉量 sink = barH − arcDip。
|
|
2424
|
+
而三次贝塞尔(端点 y=0、两个控制点 y=AMP)的最深处恰好是 AMP 的 3/4,
|
|
2425
|
+
所以 AMP = sink / 0.75。
|
|
2426
|
+
这样改 --bar-h 只会把栏顶抬高,弧线相对栏底的落点一动不动(以前 AMP 是写死的 14,
|
|
2427
|
+
栏高一变,「距栏底多少」就跟着变 —— 那正是三个数互相推算的来源之一)。
|
|
2428
|
+
⚠️ 0.75 这个系数来自「控制点等高、端点等高」的对称三次贝塞尔:t=0.5 处
|
|
2429
|
+
y = 3/4·AMP,只有这种形状才成立。换成两段圆弧(方案 C)要另算。
|
|
2430
|
+
⚠️⚠️ 基准必须是 **--bar-h(内容区高)**,不是 HH(= 栏盒高,含安全区)。
|
|
2431
|
+
这一层铺满栏盒之后,HH 多出 --sab 那 34px;拿它算 sink 会让弧线下移 34px,
|
|
2432
|
+
直接跑到图标下面去(2026-10-26 实测:最深处 y=798.5 而图标底 795)。 */
|
|
2433
|
+
var barH = parseFloat(getComputedStyle(document.documentElement)
|
|
2434
|
+
.getPropertyValue('--bar-h')) || 56;
|
|
2435
|
+
var arcDip = parseFloat(getComputedStyle(document.documentElement)
|
|
2436
|
+
.getPropertyValue('--arc-dip')) || 45.5;
|
|
2437
|
+
var sink = Math.max(0, barH - arcDip);
|
|
2438
|
+
var AMP = sink / 0.75;
|
|
2439
|
+
var x1 = Math.round(W * K_END), x2 = W - x1;
|
|
2440
|
+
/* 描边:两端 y=0,控制点 y=AMP(中间下沉,两端上翘,左右镜像)。 */
|
|
2441
|
+
line.setAttribute('d', 'M0 0C' + x1 + ' ' + AMP + ' ' + x2 + ' ' + AMP + ' ' + W + ' 0');
|
|
2442
|
+
/* 玉面 = 弧线以下一直到盒子底(含安全区,屏幕最底不再露页面);沿同一条曲线封口。 */
|
|
2443
|
+
fill.setAttribute('d',
|
|
2444
|
+
'M0 0C' + x1 + ' ' + AMP + ' ' + x2 + ' ' + AMP + ' ' + W + ' 0V' + HH + 'H0Z');
|
|
2445
|
+
}
|
|
2446
|
+
requestAnimationFrame(draw);
|
|
2447
|
+
if (window.ResizeObserver) new ResizeObserver(draw).observe(svg);
|
|
2448
|
+
window.addEventListener('resize', draw);
|
|
2449
|
+
/* 字体大小偏好(body zoom)改完不会触发 resize,切主题/字体后补画一次。 */
|
|
2450
|
+
window.addEventListener('magic:fontpref', draw);
|
|
2451
|
+
})();
|
|
2452
|
+
/* ── 安全区冻结(见 :root 里 `--sab` 那段注释)──
|
|
2453
|
+
目标:**不管键盘有没有弹起,底栏都是 56 + 34**。做法是把 env() 量成像素写进 `--sab`,
|
|
2454
|
+
并且只在「键盘收起」时才允许这个值变小(键盘弹起时 env 会掉到 0 恰恰就是信号)。
|
|
2455
|
+
|
|
2456
|
+
为什么不用 visualViewport 的高度差判键盘:
|
|
2457
|
+
Android 的 adjustResize 会**同时**缩 layout viewport 和 visual viewport,两者高度一直相等,
|
|
2458
|
+
差值恒为 0 —— 拿它判键盘在真机上永远判不到(Chrome 调试器里却能复现,是典型的假阳性)。
|
|
2459
|
+
env(safe-area-inset-bottom) 反而是可靠的:键盘盖住 home indicator 那一条时它必须归零。
|
|
2460
|
+
|
|
2461
|
+
两个状态:
|
|
2462
|
+
键盘收起 → `--sab` 跟着 env 走(横竖屏切换 / 换设备时该变就变);
|
|
2463
|
+
键盘弹起(env 变小)→ `--sab` 冻住,栏高与图标位置一根毫毛不动。
|
|
2464
|
+
|
|
2465
|
+
⚠️ 键盘态**不做任何额外视觉补偿**(早先试过让弧面在键盘态铺满整条栏,已撤掉):
|
|
2466
|
+
目标是两种状态长得完全一样,多一条 `html.is-kb` 分支就又多出两种样子。
|
|
2467
|
+
那 34px 在键盘上面读作「栏体的下沿」,跟常规态留白的下沿是同一条线。
|
|
2468
|
+
|
|
2469
|
+
⚠️ 转屏也会让 env 变小(横屏常常是 0 / 21),不能把它误判成键盘、否则横屏会白留一条。
|
|
2470
|
+
判据是**宽度**:`innerWidth` 变了 80px 以上就是转屏,这时重置基线、信任新值。 */
|
|
2471
|
+
var sabMax = 0, sabProbe = null, lastW = 0;
|
|
2472
|
+
function sabMeasure() {
|
|
2473
|
+
if (!sabProbe) {
|
|
2474
|
+
sabProbe = document.createElement('div');
|
|
2475
|
+
/* 高度 = env(safe-area-inset-bottom),其余全部归零,量它不掉性能 */
|
|
2476
|
+
sabProbe.style.cssText =
|
|
2477
|
+
'position:fixed;left:0;bottom:0;width:0;height:env(safe-area-inset-bottom,0px);' +
|
|
2478
|
+
'visibility:hidden;pointer-events:none;z-index:-1';
|
|
2479
|
+
(document.body || document.documentElement).appendChild(sabProbe);
|
|
2480
|
+
}
|
|
2481
|
+
return sabProbe.getBoundingClientRect().height || 0;
|
|
2482
|
+
}
|
|
2483
|
+
function sabSync() {
|
|
2484
|
+
var w = window.innerWidth || 0;
|
|
2485
|
+
if (lastW && Math.abs(w - lastW) > 80) sabMax = 0; /* 转屏:旧基线作废 */
|
|
2486
|
+
lastW = w;
|
|
2487
|
+
var cur = sabMeasure();
|
|
2488
|
+
var kb = sabMax > 0 && cur < sabMax - 0.5;
|
|
2489
|
+
/* 键盘收起(cur 追平)才允许刷新上限;转屏时基线刚清空,这里会把新值收进来。 */
|
|
2490
|
+
if (!kb) sabMax = cur;
|
|
2491
|
+
document.documentElement.style.setProperty('--sab', Math.round(sabMax) + 'px');
|
|
2492
|
+
}
|
|
2493
|
+
sabSync();
|
|
2494
|
+
window.addEventListener('resize', sabSync);
|
|
2495
|
+
if (window.visualViewport) {
|
|
2496
|
+
window.visualViewport.addEventListener('resize', sabSync);
|
|
2497
|
+
window.visualViewport.addEventListener('scroll', sabSync);
|
|
2498
|
+
}
|
|
2499
|
+
/* 兜底:某些 WebView 弹键盘时不一定同步触发上面的事件,输入框进出再补一次。
|
|
2500
|
+
`magic:fontpref` 与弧面重画同一个来源(字体偏好改动会让 body 缩放、几何全变)。 */
|
|
2501
|
+
document.addEventListener('focusin', function () { setTimeout(sabSync, 60); });
|
|
2502
|
+
document.addEventListener('focusout', function () { setTimeout(sabSync, 120); });
|
|
2503
|
+
window.addEventListener('magic:fontpref', sabSync);
|
|
1967
2504
|
var reqsEl = document.getElementById('reqs');
|
|
1968
2505
|
var reqDetailEl = document.getElementById('reqDetail');
|
|
1969
2506
|
var discoverEl = document.getElementById('discover');
|
|
@@ -1981,6 +2518,8 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1981
2518
|
var knowledgeDetailBody = document.getElementById('knowledgeDetailBody');
|
|
1982
2519
|
var scriptsEl = document.getElementById('scripts');
|
|
1983
2520
|
var scriptDetailEl = document.getElementById('scriptDetail');
|
|
2521
|
+
var appsEl = document.getElementById('apps');
|
|
2522
|
+
var appDetailEl = document.getElementById('appDetail');
|
|
1984
2523
|
var brEl = document.getElementById('browser');
|
|
1985
2524
|
var brFrame = document.getElementById('brFrame');
|
|
1986
2525
|
var brAddr = document.getElementById('brAddr');
|
|
@@ -1988,6 +2527,8 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1988
2527
|
var brStar = document.getElementById('brStar');
|
|
1989
2528
|
var brRelayBtn = document.getElementById('brRelay');
|
|
1990
2529
|
var brShotBtn = document.getElementById('brShot');
|
|
2530
|
+
var brLandBtn = document.getElementById('brLand');
|
|
2531
|
+
var brLandHint = document.getElementById('brLandHint');
|
|
1991
2532
|
var readerEl = document.getElementById('reader');
|
|
1992
2533
|
var rdTitle = document.getElementById('rdTitle');
|
|
1993
2534
|
var rdSub = document.getElementById('rdSub');
|
|
@@ -1998,6 +2539,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
1998
2539
|
|
|
1999
2540
|
/* ── 当前连接:连上后由它驱动 transport ── */
|
|
2000
2541
|
var BASE = '', TOKEN = '';
|
|
2542
|
+
var TOKEN_INJECTED = ''; // 壳 / 同源页注入的令牌(见首屏),apiToken 用它兜底
|
|
2001
2543
|
var conv = null;
|
|
2002
2544
|
/* 当前组件里装着哪条会话 / 哪个空间:同一条会话再次打开时直接复用这一屏
|
|
2003
2545
|
(见 mountConversation)。返回列表只是 hideAll(),组件与画面本来就还在。 */
|
|
@@ -2338,6 +2880,10 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2338
2880
|
}
|
|
2339
2881
|
function saveConns(list) {
|
|
2340
2882
|
try { localStorage.setItem(CONN_KEY, JSON.stringify((list || []).slice(0, CONN_MAX))); } catch (e) {}
|
|
2883
|
+
/* 环境列表是 PREF_SYNC_KEYS 的一员(2026-10-26:「重新安装 apk 环境就没了」),
|
|
2884
|
+
所以每次落盘都要推回桌面,否则重装后拉到的还是旧的。
|
|
2885
|
+
schedulePushPrefs 自带 TOKEN 守卫与 700ms 防抖,初始化阶段(还没连上)调了也不发。 */
|
|
2886
|
+
try { schedulePushPrefs(); } catch (e) {}
|
|
2341
2887
|
}
|
|
2342
2888
|
function findConn(id) {
|
|
2343
2889
|
var list = loadConns();
|
|
@@ -2407,6 +2953,25 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2407
2953
|
否则这条「未命名」没有 URL,既存不下来也就没法编辑(2026-10-22 用户:
|
|
2408
2954
|
「现在已有的未命名都编辑不了」)。 */
|
|
2409
2955
|
function connUrlNow() { return BASE || (TOKEN ? location.origin : ''); }
|
|
2956
|
+
/* 「请求此刻打向哪个原点」:BASE 为空时是页面自身,否则是 BASE。
|
|
2957
|
+
不能直接用 connUrlNow() 判断请求地址 —— 同源页的 BASE 是空串,
|
|
2958
|
+
`'' + '/desk/restart'` 打的是**页面自己**(2026-10-25 实测:重启请求发到了静态页)。 */
|
|
2959
|
+
/* ⚠️ 只认 BASE **的空串**:这里若用 `BASE || …`,BASE 还是空串(连接逻辑还没落定)时
|
|
2960
|
+
就会退化成 location.origin —— 同源页打自己不说,跨主机页(远程界面)会把请求打回
|
|
2961
|
+
静态页上。连接逻辑落定之后 BASE 恒非空且不带尾斜杠(parseTarget / 环境记录都是),
|
|
2962
|
+
空 BASE 就是「与 /desk/ 同源」。 */
|
|
2963
|
+
function apiOrigin() { return BASE === '' ? location.origin : BASE; }
|
|
2964
|
+
/* 本次连接用哪个令牌访问 /desk/*(同源页没注入令牌时为空串 = 不带鉴权头)。 */
|
|
2965
|
+
function apiToken() { return TOKEN || (BASE === '' ? injectedToken : ''); }
|
|
2966
|
+
/* 会话 / 环境用的「连接值」:同源页保持空串(与 /desk/ 同源),其它就是 BASE。 */
|
|
2967
|
+
function toConnValue(u) {
|
|
2968
|
+
var abs = normOrigin(u);
|
|
2969
|
+
if (BASE || !abs) return String(u || '');
|
|
2970
|
+
try {
|
|
2971
|
+
var same = abs === location.origin;
|
|
2972
|
+
} catch (e) { return String(u || ''); }
|
|
2973
|
+
return same ? '' : abs;
|
|
2974
|
+
}
|
|
2410
2975
|
function activeConn() {
|
|
2411
2976
|
var url = connUrlNow();
|
|
2412
2977
|
var list = loadConns(), i;
|
|
@@ -2419,11 +2984,29 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2419
2984
|
}
|
|
2420
2985
|
return null;
|
|
2421
2986
|
}
|
|
2422
|
-
/* 环境显示名:扫码带的 name > 桌面 health 报的 app > host。
|
|
2987
|
+
/* 环境显示名:扫码带的 name > 桌面 health 报的 app > host。
|
|
2988
|
+
⚠️ 这是**列表里**的标签(环境菜单 / 最近连接),它可以回落到 host;
|
|
2989
|
+
「我的 → 当前桌面」那类「这台是谁」的位置要用下面的 envName()(不显示地址)。 */
|
|
2423
2990
|
function connLabel(c) {
|
|
2424
2991
|
if (!c) return '';
|
|
2425
2992
|
return c.name || c.app || c.host || connHost(c.url);
|
|
2426
2993
|
}
|
|
2994
|
+
/* 「我的 → 当前桌面」/ 运行设置里的设备名:**只给名字,不给地址**
|
|
2995
|
+
(2026-10-26 用户:「环境显示名字不是地址」)。
|
|
2996
|
+
名字来源:用户起的 name > 桌面 health 报的 app > 同源壳连接「本机」>「未命名环境」。
|
|
2997
|
+
不回落到 host / IP:端口 —— 地址属于「怎么连」,不是「这是谁」;
|
|
2998
|
+
要地址的用户点「切换」进环境菜单,那里每行的副标题就是地址。
|
|
2999
|
+
⚠️ 这里返回的是「名字」,别拿它去查列表 / 做 id(那些用 connLabel / connIdOf)。 */
|
|
3000
|
+
function envName(c) {
|
|
3001
|
+
if (c == null) c = activeConn();
|
|
3002
|
+
if (!c) return '本机';
|
|
3003
|
+
var name = String(c.name || '').trim();
|
|
3004
|
+
if (name) return name;
|
|
3005
|
+
var app = String(c.app || '').trim();
|
|
3006
|
+
if (app) return app;
|
|
3007
|
+
if (String(c.url) === String(location.origin)) return '本机';
|
|
3008
|
+
return '未命名环境';
|
|
3009
|
+
}
|
|
2427
3010
|
function envLabel() {
|
|
2428
3011
|
var c = activeConn();
|
|
2429
3012
|
if (!c) return histSubtitle || '';
|
|
@@ -2535,7 +3118,13 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2535
3118
|
var o = list[k];
|
|
2536
3119
|
/* 隧道换域名 = 同一台桌面换了入口:把它的 eid 带过去,
|
|
2537
3120
|
否则新记录会发新号,这台环境排队中的任务与配置槽全部变孤儿。 */
|
|
2538
|
-
if (o && o.seed && o.name === SEED_NAME && o.url !== url) {
|
|
3121
|
+
if (o && o.seed && o.name === SEED_NAME && o.url !== url) {
|
|
3122
|
+
/* 公网域名是稳定入口:新地址只是局域网(隧道没探到 / 换了网卡)时**不要**把域名
|
|
3123
|
+
那条删掉换成局域网 —— 那正是「重启 / 换网后地址变了」的来源(2026-10-25)。
|
|
3124
|
+
反过来,新地址也是公网域名(隧道真换了域名)时才替换。 */
|
|
3125
|
+
var oldPublic = /^https:\/\//i.test(o.url), newPublic = /^https:\/\//i.test(url);
|
|
3126
|
+
if (!oldPublic || newPublic) { replacedEid = o.eid || replacedEid; continue; }
|
|
3127
|
+
}
|
|
2539
3128
|
kept.push(o);
|
|
2540
3129
|
}
|
|
2541
3130
|
if (kept.length !== list.length) saveConns(kept);
|
|
@@ -2682,16 +3271,67 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2682
3271
|
所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
|
|
2683
3272
|
· 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
|
|
2684
3273
|
· 本地任何一处改了,防抖推回远端。
|
|
2685
|
-
纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。
|
|
3274
|
+
纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。
|
|
3275
|
+
|
|
3276
|
+
⚠️⚠️ **环境列表(CONN_KEY)必须在这张表里**(2026-10-26 用户:「环境配置现在存在
|
|
3277
|
+
哪里 重新安装apk就没了」「要解决环境保留的问题」):
|
|
3278
|
+
它原先只躺在 localStorage 里,卸载 / 清除数据 / 换手机就**全丢** —— 用户得把
|
|
3279
|
+
每台桌面的地址和令牌重新手输一遍,而令牌是 magic-client 落盘复用、跨重启不变的
|
|
3280
|
+
机器身份,人根本记不住。这正是「同一个环境多个客户端配置还要一致」最该覆盖的一条。
|
|
3281
|
+
放进来的代价很小(就是个 JSON 数组,量级几百字节),收益是重装后自动回来。
|
|
3282
|
+
⚠️ 但**不能只靠它**:能拉到远端配置的前提是**已经连上过某台桌面**,
|
|
3283
|
+
而重装后连谁都不知道(CONN_KEY 空 → 闸门页只会去试打包时注入的 DEFAULT_CONNS)。
|
|
3284
|
+
所以 /desk/prefs **只管「回到那之后」的多环境列表**,
|
|
3285
|
+
「第一台怎么连回去」由闸门页的多入口兜底负责 —— 见 magic-test 的
|
|
3286
|
+
tools/build-mobile-www.cjs 注入的 DEFAULT_CONNS(公网 + 局域网 + 一键召回)。 */
|
|
2686
3287
|
var PREF_SYNC_KEYS = [
|
|
2687
3288
|
'magic.engine', 'magic.model', 'magic.fallback.engine', 'magic.fallback.model',
|
|
2688
3289
|
'magic.suggest.engine', 'magic.suggest.model',
|
|
2689
3290
|
'magic.suggest.fallback.engine', 'magic.suggest.fallback.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
|
|
3291
|
+
/* 任务模式(2026-10-26):applyTaskModeToQueued 的注释一直写着它「随 /desk/prefs 同步」,
|
|
3292
|
+
但这张表里其实从来没有它 —— 换台设备/重装后模式会退回默认。三档(自动/并行/串行)
|
|
3293
|
+
是环境级设置,必须跟着环境走;判定引擎那几个键已经在上面了。 */
|
|
3294
|
+
'magic.taskmode',
|
|
2690
3295
|
'magic.hosting.sessions.v1', 'magic.reasoning.sessions.v1',
|
|
2691
3296
|
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
2692
|
-
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
|
|
3297
|
+
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive',
|
|
3298
|
+
'magic.remote.conns.v1'
|
|
2693
3299
|
];
|
|
3300
|
+
/* ── 环境列表:**合并**,不是覆盖 ──
|
|
3301
|
+
上面那张表其余键都是「整份语义」(默认引擎就那一个,远端说了算)。
|
|
3302
|
+
环境列表不一样:它是**累积**的 —— 手机连过 A 也连过 B,这份名单就该同时有 A、B。
|
|
3303
|
+
如果照抄其余键的「远端权威、有差异就覆盖」,会出两种丢数据:
|
|
3304
|
+
· 远端的 prefs 是三天前推的(那时还没连 B)→ 拉下来把 B 抹掉;
|
|
3305
|
+
· 换一台桌面连(它的 prefs 只有它自己)→ 拉下来把别家全抹掉,
|
|
3306
|
+
用户点「切换环境」发现只剩一台,还以为是自己删了。
|
|
3307
|
+
所以这个键单独走 merge:按 id 取并集,同 id 以 at 更新的那条为准
|
|
3308
|
+
(改过名/改过令牌也算新),并受 CONN_MAX 上限约束。
|
|
3309
|
+
⚠️ 它也不参与「有差异就 reload」:名单变了只要重画环境菜单即可,
|
|
3310
|
+
为此重载整页会把用户正在看的会话打断,得不偿失。 */
|
|
3311
|
+
var CONN_SYNC_KEY = 'magic.remote.conns.v1';
|
|
3312
|
+
function mergeConnJson(localStr, remoteStr) {
|
|
3313
|
+
function parse(s) {
|
|
3314
|
+
try {
|
|
3315
|
+
var v = JSON.parse(s || '[]');
|
|
3316
|
+
return Array.isArray(v) ? v.filter(function (c) { return c && c.url; }) : [];
|
|
3317
|
+
} catch (e) { return []; }
|
|
3318
|
+
}
|
|
3319
|
+
var byId = {};
|
|
3320
|
+
parse(localStr).concat(parse(remoteStr)).forEach(function (c) {
|
|
3321
|
+
var id = c.id || connIdOf(c);
|
|
3322
|
+
var old = byId[id];
|
|
3323
|
+
/* 同一条:保留 at 更大(更新)的那份;at 缺失时优先远端(远端通常更"久")。 */
|
|
3324
|
+
if (!old || (Number(c.at) || 0) > (Number(old.at) || 0)) byId[id] = c;
|
|
3325
|
+
});
|
|
3326
|
+
var out = Object.keys(byId).map(function (k) { return byId[k]; });
|
|
3327
|
+
out.sort(function (a, b) { return (Number(b.at) || 0) - (Number(a.at) || 0); });
|
|
3328
|
+
return JSON.stringify(out.slice(0, CONN_MAX));
|
|
3329
|
+
}
|
|
2694
3330
|
var PREFS_PUSH_TIMER = 0, PREFS_PULLED = false, PREFS_PULLING = false;
|
|
3331
|
+
/* pullPrefs 里合并环境列表后要顺手回推一次(把远端缺的条目补齐)。 */
|
|
3332
|
+
var PREFS_NEED_PUSH = false;
|
|
3333
|
+
/* 有推送在等首次 pull 落地(见 pushPrefs 的门闩)。 */
|
|
3334
|
+
var PREFS_PUSH_PENDING = false;
|
|
2695
3335
|
function collectPrefs() {
|
|
2696
3336
|
var o = {};
|
|
2697
3337
|
for (var i = 0; i < PREF_SYNC_KEYS.length; i++) {
|
|
@@ -2703,6 +3343,10 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2703
3343
|
}
|
|
2704
3344
|
function pushPrefs() {
|
|
2705
3345
|
if (!TOKEN) return;
|
|
3346
|
+
/* 首次 pull 还没落地:这一枪先不打(否则就是拿「本地残缺名单」覆盖远端完整名单)。
|
|
3347
|
+
改由 pullPrefs 完成时补发 —— 那里会检查 PREFS_PUSH_PENDING。 */
|
|
3348
|
+
if (!PREFS_PULLED) { PREFS_PUSH_PENDING = true; return; }
|
|
3349
|
+
PREFS_PUSH_PENDING = false;
|
|
2706
3350
|
try {
|
|
2707
3351
|
fetch(BASE + '/desk/prefs', {
|
|
2708
3352
|
method: 'PUT',
|
|
@@ -2711,6 +3355,13 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2711
3355
|
}).catch(function () {});
|
|
2712
3356
|
} catch (e) {}
|
|
2713
3357
|
}
|
|
3358
|
+
/* ⚠️⚠️ 推送必须等**首次拉取完成**(2026-10-26 实测踩过,非常隐蔽):
|
|
3359
|
+
saveConns 一改就 schedulePushPrefs,而启动流程里 saveConns 发生得**很早**
|
|
3360
|
+
(闸门页跳过来 / 种子环境落盘 / noteActiveMeta 补 app 名),往往早于 pullPrefs 回来。
|
|
3361
|
+
结果是:远端那份「完整的多环境列表」还没读下来,本地这份「只有当前一条」就先推上去了
|
|
3362
|
+
—— 一次静默覆盖,远端从此也只剩一条,用户点「切换环境」永远只有一台。
|
|
3363
|
+
这不是理论风险:清数据重装后实测,远端被覆盖成 1 条。
|
|
3364
|
+
所以 push 必须挂牌:等首次 pull 落地(PREFS_PULLED)后再发,否则排队等到那时候。 */
|
|
2714
3365
|
function schedulePushPrefs() {
|
|
2715
3366
|
if (!TOKEN) return;
|
|
2716
3367
|
if (PREFS_PUSH_TIMER) clearTimeout(PREFS_PUSH_TIMER);
|
|
@@ -2722,10 +3373,18 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2722
3373
|
if (!TOKEN || PREFS_PULLING) return;
|
|
2723
3374
|
PREFS_PULLING = true;
|
|
2724
3375
|
var seq = CONN_SEQ;
|
|
3376
|
+
/* 拉取卡住 / 被吞(代理、WebView 挂起)时也要放行推送,否则一次没回来的 GET
|
|
3377
|
+
会把后续所有配置改动**永久**堵在门口。8 秒后无脑放行。 */
|
|
3378
|
+
var release = setTimeout(function () {
|
|
3379
|
+
if (PREFS_PULLED) return;
|
|
3380
|
+
PREFS_PULLED = true;
|
|
3381
|
+
if (PREFS_PUSH_PENDING) { PREFS_PUSH_PENDING = false; schedulePushPrefs(); }
|
|
3382
|
+
}, 8000);
|
|
2725
3383
|
fetch(BASE + '/desk/prefs', { headers: { 'X-Magic-Token': TOKEN } })
|
|
2726
3384
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
2727
3385
|
.then(function (v) {
|
|
2728
3386
|
PREFS_PULLING = false;
|
|
3387
|
+
clearTimeout(release);
|
|
2729
3388
|
if (seq !== CONN_SEQ) return;
|
|
2730
3389
|
if (!v || typeof v !== 'object' || Array.isArray(v)) { PREFS_PULLED = true; return; }
|
|
2731
3390
|
if (!Object.keys(v).length) { PREFS_PULLED = true; pushPrefs(); return; }
|
|
@@ -2735,6 +3394,20 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2735
3394
|
var cur = null;
|
|
2736
3395
|
try { cur = localStorage.getItem(k); } catch (e) {}
|
|
2737
3396
|
var want = Object.prototype.hasOwnProperty.call(v, k) ? String(v[k]) : null;
|
|
3397
|
+
/* 环境列表走合并,不走「远端权威覆盖」(见 CONN_SYNC_KEY 上面那段)。
|
|
3398
|
+
远端没这个键时也**不能**按 want===null 把它删掉 —— 那正是老版本 prefs
|
|
3399
|
+
(还没同步过环境列表)会把用户多环境清空的路径。 */
|
|
3400
|
+
if (k === CONN_SYNC_KEY) {
|
|
3401
|
+
/* merged 是「本地 ∪ 远端」按 id 去重后的结果(见 mergeConnJson)。
|
|
3402
|
+
它必然 ⊇ 本地,所以只在**真的多出新条目**时才写回;
|
|
3403
|
+
写回后顺手推一次,把远端缺的那几条补齐。 */
|
|
3404
|
+
var merged = mergeConnJson(cur, want);
|
|
3405
|
+
if (merged !== cur) {
|
|
3406
|
+
try { localStorage.setItem(k, merged); } catch (e) {}
|
|
3407
|
+
PREFS_NEED_PUSH = true;
|
|
3408
|
+
}
|
|
3409
|
+
continue;
|
|
3410
|
+
}
|
|
2738
3411
|
if (want === cur) continue;
|
|
2739
3412
|
try {
|
|
2740
3413
|
if (want === null) localStorage.removeItem(k);
|
|
@@ -2743,9 +3416,28 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2743
3416
|
changed = true;
|
|
2744
3417
|
}
|
|
2745
3418
|
PREFS_PULLED = true;
|
|
3419
|
+
/* 合并出来的新名单只影响「切换环境」菜单,而那个菜单是每次点开时
|
|
3420
|
+
现读 loadConns() 重画的,所以这里**不用重画、更不用重载整页**
|
|
3421
|
+
(重载会打断用户正在看的会话)。只需把补齐的那几条推回远端。 */
|
|
3422
|
+
if (PREFS_NEED_PUSH) {
|
|
3423
|
+
PREFS_NEED_PUSH = false;
|
|
3424
|
+
schedulePushPrefs();
|
|
3425
|
+
changed = false;
|
|
3426
|
+
}
|
|
3427
|
+
/* 启动期间排队的推送(saveConns 那类)现在可以发了。
|
|
3428
|
+
注意放在合并之后:这时本地已经是「并集」,推上去才不会把远端削短。 */
|
|
3429
|
+
if (PREFS_PUSH_PENDING) {
|
|
3430
|
+
PREFS_PUSH_PENDING = false;
|
|
3431
|
+
schedulePushPrefs();
|
|
3432
|
+
}
|
|
2746
3433
|
if (changed) { try { location.reload(); } catch (e) {} }
|
|
2747
3434
|
})
|
|
2748
|
-
.catch(function () {
|
|
3435
|
+
.catch(function () {
|
|
3436
|
+
PREFS_PULLING = false;
|
|
3437
|
+
clearTimeout(release);
|
|
3438
|
+
PREFS_PULLED = true;
|
|
3439
|
+
if (PREFS_PUSH_PENDING) { PREFS_PUSH_PENDING = false; schedulePushPrefs(); }
|
|
3440
|
+
});
|
|
2749
3441
|
}
|
|
2750
3442
|
|
|
2751
3443
|
/* 桌面 health 回来的应用名(app)补进当前连接:它比 host 更像「这台设备是谁」。 */
|
|
@@ -2794,10 +3486,20 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2794
3486
|
|
|
2795
3487
|
/* ── 环境切换菜单(任务页标题点开)── */
|
|
2796
3488
|
var envMenuEl = null, envScrimEl = null;
|
|
3489
|
+
function envSwitchBtn() { return document.getElementById('btnEnvSwitch'); }
|
|
3490
|
+
/* 菜单和遮罩挂在 body 上、**不随哪一页一起隐藏**:点「切换环境」会 switchEnv() → location.reload(),
|
|
3491
|
+
WebView 把重载当成一次导航、旧文档不一定立刻卸载 —— 那道全屏 scrim(z-index:1200)
|
|
3492
|
+
就会跟着菜单盖在新页面上,整屏只剩一张菜单(2026-10-25 实测)。
|
|
3493
|
+
所以:切换前列好「重载后必关」,新文档一起步再扫一遍残留(下面这一行)。
|
|
3494
|
+
只扫 .envmenu*:别的浮层(抽屉 / 图片查看)在子页面里合法存在,不能一刀切。 */
|
|
3495
|
+
(function () {
|
|
3496
|
+
Array.prototype.forEach.call(document.querySelectorAll('.envmenu, .envmenu__scrim'),
|
|
3497
|
+
function (n) { n.remove(); });
|
|
3498
|
+
})();
|
|
2797
3499
|
function closeEnvMenu() {
|
|
2798
3500
|
if (envMenuEl) { envMenuEl.remove(); envMenuEl = null; }
|
|
2799
3501
|
if (envScrimEl) { envScrimEl.remove(); envScrimEl = null; }
|
|
2800
|
-
var btn =
|
|
3502
|
+
var btn = envSwitchBtn();
|
|
2801
3503
|
if (btn) btn.classList.remove('is-open');
|
|
2802
3504
|
}
|
|
2803
3505
|
/* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
|
|
@@ -2821,6 +3523,8 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
2821
3523
|
var rec = upsertConn(c); // 保证这条在册(临时记录也能落盘)
|
|
2822
3524
|
try { localStorage.setItem(ACTIVE_ENV_KEY, rec.url || c.url || ''); } catch (e) {}
|
|
2823
3525
|
toast('正在切换到「' + (connLabel(c) || connHost(c.url)) + '」');
|
|
3526
|
+
/* 重载前先把菜单与遮罩收掉(见 closeEnvMenu 上面那段:旧文档可能还活一会儿)。 */
|
|
3527
|
+
closeEnvMenu();
|
|
2824
3528
|
try { location.reload(); } catch (e) { location.href = location.href; }
|
|
2825
3529
|
}
|
|
2826
3530
|
function openEnvMenu() {
|
|
@@ -3037,7 +3741,17 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3037
3741
|
}
|
|
3038
3742
|
|
|
3039
3743
|
/* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
|
|
3040
|
-
|
|
3744
|
+
/* 最近一次「离开会话页」的时刻(见 hideAll 与 fireQueued 的 byRegistryOnly):
|
|
3745
|
+
不在会话页里下发的任务没有 open 帧可等,验收只能靠会话登记表。 */
|
|
3746
|
+
var LEAVE_CONV_AT = 0;
|
|
3747
|
+
function hideAll() {
|
|
3748
|
+
launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; feishuPageEl.hidden = true; reqsEl.hidden = true; reqDetailEl.hidden = true; discoverEl.hidden = true; teamPageEl.hidden = true; runSettingsPageEl.hidden = true; pluginsEl.hidden = true; pluginDetailEl.hidden = true; knowledgeEl.hidden = true; knowledgeDetailEl.hidden = true; scriptsEl.hidden = true; scriptDetailEl.hidden = true; appsEl.hidden = true; appDetailEl.hidden = true; sessionEl.hidden = true;
|
|
3749
|
+
/* 离开会话页 = 用户决定不看这一轮了。宿主下发的那条(restoreQueue 的 fireQueued)
|
|
3750
|
+
只「发、不收画面」:不在会话页里时它把 SSE 立刻掐掉,open 帧因此永远来不了,
|
|
3751
|
+
那条排队行就只能一直挂着「等待中」(2026-10-23 用户:「现在是并行模式怎么还在串行等待」
|
|
3752
|
+
截图里两行都停在等待中)。这里把这个等待判据放开,验收改看会话登记表(见 fireQueued)。 */
|
|
3753
|
+
LEAVE_CONV_AT = Date.now();
|
|
3754
|
+
}
|
|
3041
3755
|
function setActive(name) {
|
|
3042
3756
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
3043
3757
|
b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
|
|
@@ -3074,6 +3788,11 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3074
3788
|
if (window.MagicReqs && window.MagicReqs.show) window.MagicReqs.show();
|
|
3075
3789
|
}
|
|
3076
3790
|
function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
|
|
3791
|
+
/* 我的 → 飞书推送(2026-10-26)。返回走 showMine():家里那一行要按新状态重画。 */
|
|
3792
|
+
function showFeishuPage() {
|
|
3793
|
+
hideAll(); feishuPageEl.hidden = false; tabbarEl.hidden = true;
|
|
3794
|
+
paintFeishu();
|
|
3795
|
+
}
|
|
3077
3796
|
/* 脚本页:内容与逻辑在 scripts.js(独立子页面模块),这里只负责切页。
|
|
3078
3797
|
进页面即拉一次清单(GET /desk/scripts,只读、不启停任何东西);
|
|
3079
3798
|
启停与日志流由 scripts.js 自己管。 */
|
|
@@ -3082,9 +3801,15 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3082
3801
|
if (window.MagicScripts && window.MagicScripts.show) window.MagicScripts.show();
|
|
3083
3802
|
}
|
|
3084
3803
|
function showScriptDetail() { hideAll(); scriptDetailEl.hidden = false; tabbarEl.hidden = true; }
|
|
3804
|
+
/* 应用页(「发现 → 应用」):内容与逻辑在 apps.js(独立子页面模块),这里只负责切页。
|
|
3805
|
+
启停与状态轮询由 apps.js 自己管。 */
|
|
3806
|
+
function showApps() { hideAll(); appsEl.hidden = false; tabbarEl.hidden = true; if (window.MagicApps && window.MagicApps.show) window.MagicApps.show(); }
|
|
3807
|
+
function showAppDetail() { hideAll(); appDetailEl.hidden = false; tabbarEl.hidden = true; }
|
|
3085
3808
|
function showMine() {
|
|
3086
3809
|
hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
|
|
3087
|
-
|
|
3810
|
+
/* 当前桌面 = 环境**名字**(用户 2026-10-26:「环境显示名字不是地址」)。 */
|
|
3811
|
+
mineDevice.textContent = envName();
|
|
3812
|
+
refreshFeishuSummary(); // 「账户」组的飞书推送一行:先画缓存,再拉一次状态
|
|
3088
3813
|
paintSuggestPref();
|
|
3089
3814
|
var ns = document.getElementById('notifyState');
|
|
3090
3815
|
/* 权限状态只能问原生才知道(异步)—— 先按缓存画一个,再探一次回来刷新。
|
|
@@ -3109,13 +3834,22 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3109
3834
|
/* ══════════ 版本号 + 升级(「我的」)══════════
|
|
3110
3835
|
显示:优先 APK 自己的版本(构建时从 magic-agent/package.json 注入,闸门页用 ?appv=
|
|
3111
3836
|
带进来);浏览器预览没有 appv 时才回落到桌面端 `GET /desk/health` 的 `version`。
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3837
|
+
升级:**两条链一起看** ——
|
|
3838
|
+
· App 自己:`GET /desk/app` 问桌面端「你包里带的 APK 是哪一版」,新版本就下载安装
|
|
3839
|
+
(APK 壳里走原生 MagicUpdate.open 交给系统安装器;浏览器预览退化成直接下载)。
|
|
3840
|
+
· 桌面端 CLI(npm 装的那份):`GET /desk/core` 报当前 / 盘上 / npm 最新三档,
|
|
3841
|
+
`POST /desk/core/upgrade` 走 SSE 执行 `npm i -g`,成功后服务原地重启;刚装好
|
|
3842
|
+
还没重启时 `restartOnly` 为真,界面只说「重启后生效」,绝不喊第二次升级(再点
|
|
3843
|
+
就是降级安装)。
|
|
3844
|
+
老插件没有 /desk/app 或 /desk/core(404)→ 那一条链静默跳过,不把「—」当最新版。 */
|
|
3116
3845
|
var SERVER_VERSION = '';
|
|
3117
3846
|
var APK_LATEST = ''; // 桌面端 npm 包里带的 APK 版本(/desk/app);空 = 没包或还没查
|
|
3847
|
+
var CORE_INFO = null; // /desk/core 的最近一次结果(npm 那份);null = 老插件/还没查
|
|
3118
3848
|
function shownVersion() { return APP_VERSION || SERVER_VERSION; }
|
|
3849
|
+
function coreUpdateReady() {
|
|
3850
|
+
return !!(CORE_INFO && CORE_INFO.upgradable && CORE_INFO.latest &&
|
|
3851
|
+
cmpVersion(CORE_INFO.latest, CORE_INFO.installed || CORE_INFO.version) > 0);
|
|
3852
|
+
}
|
|
3119
3853
|
function paintMineVersion() {
|
|
3120
3854
|
if (!mineVersion) return;
|
|
3121
3855
|
var v = shownVersion();
|
|
@@ -3124,35 +3858,58 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3124
3858
|
if (v && APK_LATEST && cmpVersion(APK_LATEST, v) > 0) {
|
|
3125
3859
|
mineVersion.textContent = txt + ' · 升级 v' + APK_LATEST;
|
|
3126
3860
|
mineVersion.classList.add('is-upgradable');
|
|
3861
|
+
} else if (coreUpdateReady()) {
|
|
3862
|
+
mineVersion.textContent = txt + ' · 桌面可升级 v' + CORE_INFO.latest;
|
|
3863
|
+
mineVersion.classList.add('is-upgradable');
|
|
3864
|
+
} else if (CORE_INFO && CORE_INFO.restartOnly) {
|
|
3865
|
+
mineVersion.textContent = txt + ' · 桌面待重启';
|
|
3866
|
+
mineVersion.classList.add('is-upgradable');
|
|
3127
3867
|
} else {
|
|
3128
3868
|
mineVersion.textContent = txt;
|
|
3129
3869
|
mineVersion.classList.remove('is-upgradable');
|
|
3130
3870
|
}
|
|
3131
3871
|
/* 两个版本不一致时把桌面端版本放进 title:排查「App 是新的、桌面还是旧的」用得上。 */
|
|
3132
|
-
|
|
3133
|
-
|
|
3872
|
+
var bits = [];
|
|
3873
|
+
if (APP_VERSION && SERVER_VERSION && APP_VERSION !== SERVER_VERSION) {
|
|
3874
|
+
bits.push('App v' + APP_VERSION + ' · 桌面 v' + SERVER_VERSION);
|
|
3875
|
+
}
|
|
3876
|
+
if (CORE_INFO && CORE_INFO.installed && SERVER_VERSION && CORE_INFO.installed !== SERVER_VERSION) {
|
|
3877
|
+
bits.push('桌面盘上 v' + CORE_INFO.installed);
|
|
3878
|
+
}
|
|
3879
|
+
mineVersion.title = bits.join(' · ');
|
|
3134
3880
|
}
|
|
3135
|
-
|
|
3136
|
-
|
|
3881
|
+
function appInfo() {
|
|
3882
|
+
if (!TOKEN) return Promise.resolve(null);
|
|
3883
|
+
return fetch(BASE + '/desk/app', { headers: { 'X-Magic-Token': TOKEN } })
|
|
3884
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
3885
|
+
.catch(function () { return null; });
|
|
3886
|
+
}
|
|
3887
|
+
function coreInfo() {
|
|
3888
|
+
if (!TOKEN) return Promise.resolve(null);
|
|
3889
|
+
return fetch(BASE + '/desk/core', { headers: { 'X-Magic-Token': TOKEN } })
|
|
3890
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
3891
|
+
.catch(function () { return null; });
|
|
3892
|
+
}
|
|
3893
|
+
/* 进入「我的」时静默问一次(APK / npm 各一条):有更新就把版本格变成「升级」。
|
|
3894
|
+
失败(老插件没有这两个接口)就不动,不打扰。 */
|
|
3137
3895
|
function probeAppUpdate() {
|
|
3138
3896
|
if (!TOKEN) return;
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
.then(function (a) { APK_LATEST = (a && a.version) || ''; paintMineVersion(); })
|
|
3142
|
-
.catch(function () {});
|
|
3897
|
+
appInfo().then(function (a) { APK_LATEST = (a && a.version) || ''; paintMineVersion(); });
|
|
3898
|
+
coreInfo().then(function (c) { CORE_INFO = c; paintMineVersion(); });
|
|
3143
3899
|
}
|
|
3144
3900
|
function refreshMineVersion() {
|
|
3145
|
-
|
|
3146
|
-
|
|
3901
|
+
/* 同源页(壳 / 远程界面)BASE 为空、令牌是注入的:用 apiOrigin / apiToken 别打到自己身上。 */
|
|
3902
|
+
if (!apiToken()) { paintMineVersion(); return; }
|
|
3903
|
+
var hdr = { 'X-Magic-Token': apiToken() };
|
|
3147
3904
|
var seq = CONN_SEQ; // 切环境后旧桌面的响应不许写到新连接上
|
|
3148
3905
|
/* ⚠️ /desk/health.version 是 magic-client **启动时**缓存的 core 版本:客户端比 core 旧
|
|
3149
3906
|
(升级了 CLI 但没重启服务)时会一直报旧值(实测 v1.0.111 / 实际 2.1.3)。
|
|
3150
3907
|
/desk/contract 每次都重新问 core,才是「此刻真正的 magic-agent 版本」。
|
|
3151
3908
|
两个并行拿:contract 权威,拿不到(老插件)才退回 health;health 还带 app 身份。 */
|
|
3152
|
-
var pContract = fetch(
|
|
3909
|
+
var pContract = fetch(apiOrigin() + '/desk/contract', { headers: hdr })
|
|
3153
3910
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
3154
3911
|
.catch(function () { return null; });
|
|
3155
|
-
var pHealth = fetch(
|
|
3912
|
+
var pHealth = fetch(apiOrigin() + '/desk/health', { headers: hdr })
|
|
3156
3913
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
3157
3914
|
.catch(function () { return null; });
|
|
3158
3915
|
Promise.all([pContract, pHealth]).then(function (a) {
|
|
@@ -3194,22 +3951,119 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3194
3951
|
} catch (e) {}
|
|
3195
3952
|
location.href = url;
|
|
3196
3953
|
}
|
|
3954
|
+
/* 跑 `POST /desk/core/upgrade` 的 SSE 流。用 XHR 而不是 fetch().body:Android WebView
|
|
3955
|
+
上 response.body 的可用性随版本漂,XHR 的 onprogress 到处都有,还能逐行拿到进度。
|
|
3956
|
+
返回的 Promise 以 end 事件({ok, error, note, restarting, core})为准。 */
|
|
3957
|
+
function postCoreUpgrade(onEvent) {
|
|
3958
|
+
return new Promise(function (resolve, reject) {
|
|
3959
|
+
var xhr = new XMLHttpRequest();
|
|
3960
|
+
xhr.open('POST', BASE + '/desk/core/upgrade', true);
|
|
3961
|
+
xhr.setRequestHeader('X-Magic-Token', TOKEN);
|
|
3962
|
+
var seen = 0, buf = '', endEvt = null;
|
|
3963
|
+
function drain() {
|
|
3964
|
+
var txt = xhr.responseText || '';
|
|
3965
|
+
if (txt.length <= seen) return;
|
|
3966
|
+
buf += txt.slice(seen);
|
|
3967
|
+
seen = txt.length;
|
|
3968
|
+
var chunks = buf.split('\n\n');
|
|
3969
|
+
buf = chunks.pop();
|
|
3970
|
+
chunks.forEach(function (chunk) {
|
|
3971
|
+
var ev = 'message', data = '';
|
|
3972
|
+
chunk.split('\n').forEach(function (ln) {
|
|
3973
|
+
if (ln.indexOf('event:') === 0) ev = ln.slice(6).trim();
|
|
3974
|
+
else if (ln.indexOf('data:') === 0) data = ln.slice(5).trim();
|
|
3975
|
+
});
|
|
3976
|
+
if (!data) return;
|
|
3977
|
+
var obj = null;
|
|
3978
|
+
try { obj = JSON.parse(data); } catch (e) { return; }
|
|
3979
|
+
if (ev === 'end') endEvt = obj;
|
|
3980
|
+
else if (onEvent) onEvent(ev, obj);
|
|
3981
|
+
});
|
|
3982
|
+
}
|
|
3983
|
+
xhr.onprogress = drain;
|
|
3984
|
+
xhr.onerror = function () { reject(new Error('网络中断,升级结果未知(可重进「我的」看版本)')); };
|
|
3985
|
+
xhr.onload = function () {
|
|
3986
|
+
drain();
|
|
3987
|
+
if (xhr.status !== 200) {
|
|
3988
|
+
var m = '';
|
|
3989
|
+
try { m = (JSON.parse(xhr.responseText) || {}).error || ''; } catch (e) {}
|
|
3990
|
+
reject(new Error(m || ('HTTP ' + xhr.status)));
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
if (!endEvt) { reject(new Error('升级流没有正常结束,可重进「我的」看版本')); return; }
|
|
3994
|
+
resolve(endEvt);
|
|
3995
|
+
};
|
|
3996
|
+
xhr.send();
|
|
3997
|
+
});
|
|
3998
|
+
}
|
|
3999
|
+
/* 桌面端(npm)升级:进度写进版本格,结束后把结果如实说出来。 */
|
|
4000
|
+
function upgradeCoreCli() {
|
|
4001
|
+
var last = '正在升级桌面端…';
|
|
4002
|
+
mineVersion.textContent = last;
|
|
4003
|
+
mineVersion.classList.add('is-upgradable');
|
|
4004
|
+
return postCoreUpgrade(function (ev, o) {
|
|
4005
|
+
if (ev !== 'line' || !o || !o.line) return;
|
|
4006
|
+
var ln = String(o.line).replace(/\s+$/, '');
|
|
4007
|
+
if (!ln) return;
|
|
4008
|
+
last = '升级中 · ' + (ln.length > 16 ? ln.slice(0, 16) + '…' : ln);
|
|
4009
|
+
mineVersion.textContent = last;
|
|
4010
|
+
}).then(function (end) {
|
|
4011
|
+
CORE_INFO = (end && end.core) || CORE_INFO;
|
|
4012
|
+
paintMineVersion();
|
|
4013
|
+
if (!end || !end.ok) {
|
|
4014
|
+
toast('桌面端升级失败:' + ((end && end.error) || '未知原因'));
|
|
4015
|
+
return false;
|
|
4016
|
+
}
|
|
4017
|
+
toast(end.note || '桌面端已升级');
|
|
4018
|
+
return true;
|
|
4019
|
+
}, function (err) {
|
|
4020
|
+
paintMineVersion();
|
|
4021
|
+
toast('桌面端升级未执行:' + ((err && err.message) || err));
|
|
4022
|
+
return false;
|
|
4023
|
+
});
|
|
4024
|
+
}
|
|
4025
|
+
/* 「检测更新」:APK 与桌面端(npm)一起查,一次性把两条链的结果摆出来。
|
|
4026
|
+
顺序上先升桌面端、再下 APK —— 打开系统安装器会切走 App,之后就走不了 SSE 了。 */
|
|
3197
4027
|
function checkAppUpdate() {
|
|
3198
4028
|
var cur = shownVersion();
|
|
3199
4029
|
if (!TOKEN) { toast('请先连接桌面端'); return; }
|
|
3200
4030
|
if (!cur) { toast('版本还没拿到,等列表加载完再试'); return; }
|
|
3201
4031
|
mineVersion.textContent = '检测中…';
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
4032
|
+
Promise.all([appInfo(), coreInfo()]).then(function (r) {
|
|
4033
|
+
var a = r[0], c = r[1];
|
|
4034
|
+
APK_LATEST = (a && a.version) || '';
|
|
4035
|
+
CORE_INFO = c;
|
|
4036
|
+
paintMineVersion();
|
|
4037
|
+
|
|
4038
|
+
var appNew = !!(a && a.version && cmpVersion(a.version, cur) > 0);
|
|
4039
|
+
var coreNew = !!(c && c.upgradable && c.latest &&
|
|
4040
|
+
cmpVersion(c.latest, c.installed || c.version) > 0);
|
|
4041
|
+
var coreRestart = !!(c && c.restartOnly);
|
|
4042
|
+
var coreCan = !!(c && c.canUpgrade);
|
|
4043
|
+
|
|
4044
|
+
var lines = [];
|
|
4045
|
+
if (appNew) lines.push('App v' + cur + ' → v' + a.version);
|
|
4046
|
+
if (coreNew) {
|
|
4047
|
+
lines.push('桌面端 v' + (c.installed || c.version) + ' → v' + c.latest +
|
|
4048
|
+
(coreCan ? '(装好需重启 magic-client)' : '(本机没有 npm,请手工升级)'));
|
|
4049
|
+
}
|
|
4050
|
+
if (coreRestart && !coreNew) lines.push('桌面端 v' + c.installed + ' 已装好,重启 magic-client 后生效');
|
|
4051
|
+
if (!lines.length) {
|
|
4052
|
+
toast('已是最新:App v' + cur + (c && c.installed ? ' · 桌面 v' + c.installed : ''));
|
|
4053
|
+
return;
|
|
4054
|
+
}
|
|
4055
|
+
if (!confirm('发现更新:\n' + lines.join('\n') + '\n\n现在升级?')) return;
|
|
4056
|
+
|
|
4057
|
+
var doCore = coreNew && coreCan;
|
|
4058
|
+
var doApp = appNew;
|
|
4059
|
+
var chain = Promise.resolve(true);
|
|
4060
|
+
if (doCore) chain = upgradeCoreCli();
|
|
4061
|
+
chain.then(function () {
|
|
4062
|
+
if (!doApp) { if (coreRestart && !doCore) refreshMineVersion(); return; }
|
|
3209
4063
|
openApkDownload(a);
|
|
3210
|
-
toast('正在下载 v' + a.version + ',完成后点安装');
|
|
3211
|
-
})
|
|
3212
|
-
|
|
4064
|
+
toast('正在下载 App v' + a.version + ',完成后点安装');
|
|
4065
|
+
});
|
|
4066
|
+
}).catch(function () { paintMineVersion(); toast('检查更新失败,请确认桌面端已升级'); });
|
|
3213
4067
|
}
|
|
3214
4068
|
/* 「我的」→ 团队模式:整块配置进子页面(与插件列表同一套子页面范式)。 */
|
|
3215
4069
|
function showTeamPage() {
|
|
@@ -3443,7 +4297,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3443
4297
|
renderPlugins();
|
|
3444
4298
|
}
|
|
3445
4299
|
}
|
|
3446
|
-
if (!line) return;
|
|
4300
|
+
if (!svg || !line) return;
|
|
3447
4301
|
PLUGIN_LOG[id] = (PLUGIN_LOG[id] || '') + line;
|
|
3448
4302
|
if (log) { log.textContent = PLUGIN_LOG[id]; log.scrollTop = log.scrollHeight; }
|
|
3449
4303
|
});
|
|
@@ -3705,7 +4559,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3705
4559
|
renderKnowledge();
|
|
3706
4560
|
}
|
|
3707
4561
|
}
|
|
3708
|
-
if (!line) return;
|
|
4562
|
+
if (!svg || !line) return;
|
|
3709
4563
|
KNOWLEDGE_LOG[id] = (KNOWLEDGE_LOG[id] || '') + line;
|
|
3710
4564
|
if (log) { log.textContent = KNOWLEDGE_LOG[id]; log.scrollTop = log.scrollHeight; }
|
|
3711
4565
|
});
|
|
@@ -3773,7 +4627,20 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3773
4627
|
catch (e) { return []; }
|
|
3774
4628
|
}
|
|
3775
4629
|
function saveStars(a) { try { localStorage.setItem(BR_STAR_KEY, JSON.stringify(a)); } catch (e) {} }
|
|
4630
|
+
/* 应用页(发现 → 应用)打开的工作台:地址栏显示应用名,而不是 "/?via=magic-shorts"。 */
|
|
4631
|
+
var BR_APP_NAMES = { 'magic-shorts': 'magic-shorts 工作台', 'magic-video': 'magic-video 工作台' };
|
|
4632
|
+
function brAppId(u) {
|
|
4633
|
+
try {
|
|
4634
|
+
var a = new URL(u, location.origin);
|
|
4635
|
+
var m = /^\/workbench\/([^/]+)/.exec(a.pathname);
|
|
4636
|
+
if (m) return decodeURIComponent(m[1]);
|
|
4637
|
+
var via = a.searchParams.get('via');
|
|
4638
|
+
return via || '';
|
|
4639
|
+
} catch (e) { return ''; }
|
|
4640
|
+
}
|
|
3776
4641
|
function siteName(u) {
|
|
4642
|
+
var appId = brAppId(u);
|
|
4643
|
+
if (appId) return BR_APP_NAMES[appId] || appId;
|
|
3777
4644
|
for (var i = 0; i < BR_SITES.length; i++) { if (BR_SITES[i].url === u) return BR_SITES[i].name; }
|
|
3778
4645
|
try { return new URL(u).host; } catch (e) { return u; }
|
|
3779
4646
|
}
|
|
@@ -3786,6 +4653,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3786
4653
|
}
|
|
3787
4654
|
function brNav(url) {
|
|
3788
4655
|
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
|
4656
|
+
brAppEntry = ''; // 手输 / 点快捷站点 = 离开应用入口,刷新照旧重载当前站
|
|
3789
4657
|
brStack.push(url);
|
|
3790
4658
|
brLoad(url);
|
|
3791
4659
|
brPaint();
|
|
@@ -3817,6 +4685,11 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3817
4685
|
/* iframe 加载:**默认直连**,走手机自己的网络 —— 内置浏览器首先得是个浏览器
|
|
3818
4686
|
(2026-10-25 用户:「没开网络代理时就应该直连啊」)。brRelayOn 开时走 /b/ 中转。 */
|
|
3819
4687
|
function brLoad(u) { brFrame.src = brRelayOn() ? brProxyURL(u) : u; }
|
|
4688
|
+
/* 每次导航完重新判断要不要横屏;换站时把上一次的手动选择清掉(新页面重新自动判断)。 */
|
|
4689
|
+
brFrame.addEventListener('load', function () {
|
|
4690
|
+
if (brFrame.src === 'about:blank') return;
|
|
4691
|
+
brScheduleLandscape();
|
|
4692
|
+
});
|
|
3820
4693
|
function brPaintRelay() {
|
|
3821
4694
|
if (brRelayBtn) brRelayBtn.classList.toggle('is-on', brRelayOn());
|
|
3822
4695
|
}
|
|
@@ -3829,6 +4702,10 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3829
4702
|
if (brStack.length) brReload(); // 立刻按新方式重载当前页
|
|
3830
4703
|
}
|
|
3831
4704
|
function openBrowser(url) {
|
|
4705
|
+
brAppEntry = '';
|
|
4706
|
+
brLandAppId = '';
|
|
4707
|
+
brLandByUser = false;
|
|
4708
|
+
brSetLandscape(false);
|
|
3832
4709
|
brStack = [url || HOME_URL];
|
|
3833
4710
|
brLoad(brStack[0]);
|
|
3834
4711
|
brPaint();
|
|
@@ -3836,7 +4713,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3836
4713
|
brEl.hidden = false;
|
|
3837
4714
|
}
|
|
3838
4715
|
function closeBrowser() {
|
|
3839
|
-
brEl.hidden = true; brStack = []; brFrame.src = 'about:blank';
|
|
4716
|
+
brEl.hidden = true; brStack = []; brAppEntry = ''; brFrame.src = 'about:blank';
|
|
4717
|
+
brLandByUser = false;
|
|
4718
|
+
brSetLandscape(false);
|
|
3840
4719
|
brResetFloat();
|
|
3841
4720
|
/* 截图流程收尾:关掉浏览器就意味着这次截图不做了 —— 把通道与按钮都收掉,
|
|
3842
4721
|
否则下一次打开浏览器还挂着一个指向旧任务的截图入口。 */
|
|
@@ -3867,9 +4746,12 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3867
4746
|
else closeBrowser();
|
|
3868
4747
|
}
|
|
3869
4748
|
function brHome() { brStack = [HOME_URL]; brLoad(HOME_URL); brPaint(); }
|
|
3870
|
-
function brReload() {
|
|
4749
|
+
function brReload() {
|
|
4750
|
+
if (brAppEntry) { brStack = [brAppEntry]; brLoad(brAppEntry); brPaint(); return; }
|
|
4751
|
+
if (brStack.length) { brLoad(brStack[brStack.length - 1]); brPaint(); }
|
|
4752
|
+
}
|
|
3871
4753
|
function brShare() {
|
|
3872
|
-
var u = brStack[brStack.length - 1] || HOME_URL;
|
|
4754
|
+
var u = brAppEntry ? new URL(brAppEntry, location.origin).href : (brStack[brStack.length - 1] || HOME_URL);
|
|
3873
4755
|
if (navigator.share) { navigator.share({ title: siteName(u), url: u }).catch(function () {}); return; }
|
|
3874
4756
|
try {
|
|
3875
4757
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
@@ -3880,7 +4762,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3880
4762
|
toast('当前环境不支持分享');
|
|
3881
4763
|
}
|
|
3882
4764
|
function brToggleStar() {
|
|
3883
|
-
var u = brStack[brStack.length - 1] || HOME_URL;
|
|
4765
|
+
var u = brAppEntry ? new URL(brAppEntry, location.origin).href : (brStack[brStack.length - 1] || HOME_URL);
|
|
3884
4766
|
var stars = loadStars();
|
|
3885
4767
|
var i = stars.indexOf(u);
|
|
3886
4768
|
if (i >= 0) { stars.splice(i, 1); toast('已取消收藏'); }
|
|
@@ -3995,6 +4877,31 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
3995
4877
|
else { brEl.style.left = ''; brEl.style.top = ''; brUnfitStage(); }
|
|
3996
4878
|
}
|
|
3997
4879
|
|
|
4880
|
+
/* 应用页(发现 → 应用)打开工作台:**专用入口**。
|
|
4881
|
+
与 openBrowser 只差一点:对它来说「刷新 / 分享 / 收藏」指的是**工作台入口**
|
|
4882
|
+
(/workbench/{id}/),而不是页面最后停在的 "/" —— "/" 是手机界面的入口,
|
|
4883
|
+
分享出去或点了刷新都毫无意义,而带 via= 的最后的地址刷新时会先探活,
|
|
4884
|
+
工作台没在跑就得到一句人话(而不是一个打不开的白屏)。
|
|
4885
|
+
⚠️ 打开后不重新加载:工作台自己 302 到 "/" 是服务端的正常一跳,不是「来了个新站」。 */
|
|
4886
|
+
var brAppEntry = ''; // 非空 = 这次打开的是应用工作台,值是它的入口
|
|
4887
|
+
function openAppInBrowser(u, appOpts) {
|
|
4888
|
+
appOpts = appOpts || {};
|
|
4889
|
+
brAppEntry = u || '';
|
|
4890
|
+
brLandAppId = String(appOpts.id || brAppId(u) || '');
|
|
4891
|
+
brLandByUser = false;
|
|
4892
|
+
brSetLandscape(false);
|
|
4893
|
+
brStack = [u || HOME_URL];
|
|
4894
|
+
brLoad(brStack[0]);
|
|
4895
|
+
brPaint();
|
|
4896
|
+
brResetFloat();
|
|
4897
|
+
brEl.hidden = false;
|
|
4898
|
+
/* 应用自己声明「按宽屏排版」且用户没拒绝过 → 竖屏下直接转。
|
|
4899
|
+
页面加载后的溢出检测(brScheduleLandscape)只作兜底,见 brNeedLandscape 的注释。 */
|
|
4900
|
+
var wantLand = ('landscape' in appOpts) ? !!appOpts.landscape : !!BR_APP_LANDSCAPE[brLandAppId];
|
|
4901
|
+
if (wantLand && !brLandRefused(brLandAppId) && window.innerWidth <= window.innerHeight) {
|
|
4902
|
+
brSetLandscape(true);
|
|
4903
|
+
}
|
|
4904
|
+
}
|
|
3998
4905
|
/* 复位到全屏初始态:openBrowser(新开一站)与 closeBrowser(彻底关)都走这里。 */
|
|
3999
4906
|
function brResetFloat() {
|
|
4000
4907
|
brMode = 'full';
|
|
@@ -4031,9 +4938,123 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4031
4938
|
var k = sw / BR_VW; // 等比缩放系数
|
|
4032
4939
|
brFrame.style.transform = 'scale(' + k + ')';
|
|
4033
4940
|
}
|
|
4941
|
+
/* ── 横屏(2026-10-27)──
|
|
4942
|
+
magic-video / magic-shorts 的工作台是**桌面宽屏**排版(三栏 / 双栏)。塞进手机竖屏
|
|
4943
|
+
的 iframe 里,右半边直接被切掉,用户看到的就是「打不开」。两个工作台又都是同源
|
|
4944
|
+
反代进来的,所以能直接读 iframe 里的真实布局宽度做判断 —— 不靠应用自己声明:
|
|
4945
|
+
1) 页面加载完(工作台是 JS 渲染,得等一会儿)量 scrollWidth;
|
|
4946
|
+
2) 溢出超过 25% 就转横屏:iframe 自身尺寸换成「高×宽」再 rotate 90°,
|
|
4947
|
+
旋转后的可视区域正好铺满舞台(origin 0 0 + translateX(W));
|
|
4948
|
+
3) 顶部一条提示说明「已按横屏显示」并给「切回竖屏」;手动切过就记住,
|
|
4949
|
+
不再自动转(自动改变方向又不听人劝,是最烦的那种聪明)。
|
|
4950
|
+
手机自己横过来(innerWidth > innerHeight)时不转 —— 那时设备已经横了,
|
|
4951
|
+
再转一次就变成歪的。悬浮态不支持横屏:尺寸模型是 16:9 等比缩放,会打架。 */
|
|
4952
|
+
var brLand = false; // 当前是否软件横屏
|
|
4953
|
+
var brLandByUser = false; // 用户手动切过:之后不再自动判断
|
|
4954
|
+
var brLandTimer = 0;
|
|
4955
|
+
var brLandAppId = ''; // 这次打开的是哪个应用('' = 普通网页)
|
|
4956
|
+
/* 用户手动选的「这个应用不要横屏」要记住 —— 否则每次打开都被转一次,
|
|
4957
|
+
那不叫自动,那叫跟人较劲。key 里带应用 id,互不影响。 */
|
|
4958
|
+
var BR_LAND_KEY = 'magic.browser.landoff.v1';
|
|
4959
|
+
/* 与 magic-client 的 apps.go(appSpec.Landscape)同一份口径:这些工作台按桌面宽屏排版。
|
|
4960
|
+
前端留一份的原因:界面是静态资源,同步后刷新就生效,不用为了一个布尔值重启服务;
|
|
4961
|
+
后端 /desk/apps 里的 landscape 字段优先(有就以它为准)。 */
|
|
4962
|
+
var BR_APP_LANDSCAPE = { 'magic-video': true };
|
|
4963
|
+
function brLandOffList() {
|
|
4964
|
+
try { var a = JSON.parse(localStorage.getItem(BR_LAND_KEY) || '[]'); return Array.isArray(a) ? a : []; }
|
|
4965
|
+
catch (e) { return []; }
|
|
4966
|
+
}
|
|
4967
|
+
function brLandRefused(id) { return !!id && brLandOffList().indexOf(id) >= 0; }
|
|
4968
|
+
function brRememberLandOff(id, off) {
|
|
4969
|
+
if (!id) return;
|
|
4970
|
+
var a = brLandOffList().filter(function (x) { return x !== id; });
|
|
4971
|
+
if (off) a.push(id);
|
|
4972
|
+
try { localStorage.setItem(BR_LAND_KEY, JSON.stringify(a)); } catch (e) {}
|
|
4973
|
+
}
|
|
4974
|
+
|
|
4975
|
+
function brStageEl() { return document.getElementById('brStage'); }
|
|
4976
|
+
|
|
4977
|
+
function brSetLandscape(on, byUser) {
|
|
4978
|
+
if (byUser) {
|
|
4979
|
+
brLandByUser = true;
|
|
4980
|
+
if (brLandAppId) brRememberLandOff(brLandAppId, !on); // 切回竖屏 = 记住「这个应用别转」
|
|
4981
|
+
}
|
|
4982
|
+
if (!on) {
|
|
4983
|
+
brLand = false;
|
|
4984
|
+
brEl.classList.remove('is-land');
|
|
4985
|
+
brUnfitStage();
|
|
4986
|
+
if (brLandHint) brLandHint.hidden = true;
|
|
4987
|
+
brPaintLandBtn();
|
|
4988
|
+
return;
|
|
4989
|
+
}
|
|
4990
|
+
if (brMode !== 'full') brResetFloat(); // 悬浮态与横屏互斥
|
|
4991
|
+
var stage = brStageEl();
|
|
4992
|
+
if (!stage) return;
|
|
4993
|
+
var W = stage.clientWidth, H = stage.clientHeight;
|
|
4994
|
+
if (!W || !H) return;
|
|
4995
|
+
brLand = true;
|
|
4996
|
+
brEl.classList.add('is-land');
|
|
4997
|
+
brFrame.style.width = H + 'px';
|
|
4998
|
+
brFrame.style.height = W + 'px';
|
|
4999
|
+
brFrame.style.transformOrigin = '0 0';
|
|
5000
|
+
brFrame.style.transform = 'translateX(' + W + 'px) rotate(90deg)';
|
|
5001
|
+
if (brLandHint) brLandHint.hidden = false;
|
|
5002
|
+
brPaintLandBtn();
|
|
5003
|
+
}
|
|
5004
|
+
|
|
5005
|
+
function brPaintLandBtn() {
|
|
5006
|
+
if (brLandBtn) brLandBtn.classList.toggle('is-on', brLand);
|
|
5007
|
+
}
|
|
5008
|
+
|
|
5009
|
+
/* 页面到底需不需要横屏:读 iframe 文档的真实宽度(同源才读得到)。
|
|
5010
|
+
溢出 25% 以上才算 —— 差几个像素是滚动条和圆角,不该把屏幕转过去。 */
|
|
5011
|
+
function brNeedLandscape() {
|
|
5012
|
+
if (!brFrame || brEl.hidden) return false;
|
|
5013
|
+
try {
|
|
5014
|
+
var d = brFrame.contentDocument;
|
|
5015
|
+
if (!d || !d.documentElement) return false;
|
|
5016
|
+
var sw = Math.max(d.documentElement.scrollWidth || 0, d.body ? d.body.scrollWidth || 0 : 0);
|
|
5017
|
+
var vw = brFrame.clientWidth || 0;
|
|
5018
|
+
if (!sw || !vw) return false;
|
|
5019
|
+
/* 阈值 1.05:magic-video 竖屏下只溢出 34/390 ≈ 8.7%,却是**顶栏那排 tab 被切掉**
|
|
5020
|
+
(实测 2026-10-27)。等溢出到 25% 才转,等于永远不转。 */
|
|
5021
|
+
return sw > vw * 1.05;
|
|
5022
|
+
} catch (e) { return false; } // 跨域读不到 → 不自动转,用户可以点按钮
|
|
5023
|
+
}
|
|
5024
|
+
|
|
5025
|
+
function brAutoLandscape() {
|
|
5026
|
+
if (brLandByUser || brEl.hidden) return;
|
|
5027
|
+
if (brLandRefused(brLandAppId)) { // 用户明确说过这个应用别转
|
|
5028
|
+
if (brLandBtn) brLandBtn.hidden = !brLand;
|
|
5029
|
+
return;
|
|
5030
|
+
}
|
|
5031
|
+
if (window.innerWidth > window.innerHeight) { // 设备已经横着
|
|
5032
|
+
if (brLand) brSetLandscape(false);
|
|
5033
|
+
return;
|
|
5034
|
+
}
|
|
5035
|
+
var need = brNeedLandscape();
|
|
5036
|
+
if (brLandBtn) brLandBtn.hidden = !need && !brLand;
|
|
5037
|
+
if (need && !brLand) brSetLandscape(true);
|
|
5038
|
+
else if (!need && brLand) brSetLandscape(false);
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
/* 加载后量三次:工作台先出骨架、再填数据,宽度会变两次。 */
|
|
5042
|
+
function brScheduleLandscape() {
|
|
5043
|
+
if (brLandTimer) { clearTimeout(brLandTimer); brLandTimer = 0; }
|
|
5044
|
+
if (brLandByUser) return;
|
|
5045
|
+
[700, 1800, 3500].forEach(function (ms) {
|
|
5046
|
+
setTimeout(function () {
|
|
5047
|
+
if (brEl.hidden) return;
|
|
5048
|
+
brAutoLandscape();
|
|
5049
|
+
if (brLand) brSetLandscape(true); // 尺寸可能已变,重算旋转
|
|
5050
|
+
}, ms);
|
|
5051
|
+
});
|
|
5052
|
+
}
|
|
5053
|
+
|
|
4034
5054
|
function brUnfitStage() {
|
|
4035
5055
|
brFrame.style.width = '';
|
|
4036
5056
|
brFrame.style.height = '';
|
|
5057
|
+
brFrame.style.transformOrigin = '';
|
|
4037
5058
|
brFrame.style.transform = '';
|
|
4038
5059
|
}
|
|
4039
5060
|
|
|
@@ -4116,10 +5137,47 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4116
5137
|
else document.documentElement.removeAttribute('data-theme');
|
|
4117
5138
|
}
|
|
4118
5139
|
/* 字体大小:组件尺寸是 px,改 root font-size 无效;用 body zoom 整体缩放(WebView 支持)。 */
|
|
4119
|
-
function applyFontPref(v) {
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
5140
|
+
function applyFontPref(v) {
|
|
5141
|
+
try { document.body.style.zoom = (v && v !== '1') ? v : ''; } catch (e) {}
|
|
5142
|
+
/* zoom 改变后布局宽度不变、显示宽度变了,靠 resize 事件补不到;通知底栏弧面重画(见 .bararc 脚本)。 */
|
|
5143
|
+
try { window.dispatchEvent(new Event('magic:fontpref')); } catch (e) {}
|
|
5144
|
+
}
|
|
5145
|
+
/* 任务模式(2026-10-26 增加 auto,并成为默认):
|
|
5146
|
+
· auto —— 新会话提交时由服务端判断「本轮要干的事」与同空间运行中任务是否冲突:
|
|
5147
|
+
冲突走串行等待,不冲突照常并行(先规则,再建议引擎快速判断,默认并行)。
|
|
5148
|
+
· parallel —— 用户明确要全部并行,不做冲突判断。
|
|
5149
|
+
· serial —— 用户明确要同一空间同时只跑一个。
|
|
5150
|
+
老值原样兼容:存过 parallel / serial 的用户不受影响。 */
|
|
5151
|
+
var TASK_MODE = 'auto';
|
|
5152
|
+
try {
|
|
5153
|
+
var _tm = localStorage.getItem('magic.taskmode');
|
|
5154
|
+
/* 白名单兜底:存过什么怪值(手改 / 老版本遗留)都退回默认 auto,别让界面无高亮、
|
|
5155
|
+
下发时又落到某个隐含档位。 */
|
|
5156
|
+
if (_tm === 'auto' || _tm === 'parallel' || _tm === 'serial') TASK_MODE = _tm;
|
|
5157
|
+
} catch (e) {}
|
|
5158
|
+
/* 任务模式 → 下发字段。三档的差别全在这里,别再各处写 `TASK_MODE === 'serial'` ——
|
|
5159
|
+
那正是「客户端以为并行、服务端按串行处理」反复出问题的根源(见下面几处下发点)。
|
|
5160
|
+
· auto:mode=auto,串行与否由服务端裁决;serial 传 false 只是给不认识 mode 的老服务端兜底
|
|
5161
|
+
(不传的话它按「nil = 串行」处理,自动模式就退化成串行)。
|
|
5162
|
+
· parallel:必须**显式** false(服务端默认是串行)。
|
|
5163
|
+
· serial:true。
|
|
5164
|
+
判定引擎用「建议引擎」那套配置(SUGGEST.*):同一份引擎/模型/额度,用户不用为判定再配一遍。 */
|
|
5165
|
+
/* 三档都写成 mode(2026-10-26 用户:「模式判断要拦截每个会话,如果不是自动就按
|
|
5166
|
+
选择模式处理」)。以前只有 auto 发 mode、并行/串行靠 serial 布尔,服务端只认 auto,
|
|
5167
|
+
于是「显式选了并行」在只发 mode 的调用方那里会被缺省串行吃掉。现在 mode 说了算,
|
|
5168
|
+
serial 照旧一起发 —— 老服务端不认 mode 时还有它兜底,不会把并行变成串行。 */
|
|
5169
|
+
function applyTaskModeToPayload(p) {
|
|
5170
|
+
if (!p) return p;
|
|
5171
|
+
p.mode = (TASK_MODE === 'parallel' || TASK_MODE === 'serial') ? TASK_MODE : 'auto';
|
|
5172
|
+
if (TASK_MODE === 'auto') {
|
|
5173
|
+
p.serial = false;
|
|
5174
|
+
if (SUGGEST && SUGGEST.engine) p.judgeEngine = SUGGEST.engine;
|
|
5175
|
+
if (SUGGEST && SUGGEST.model) p.judgeModel = SUGGEST.model;
|
|
5176
|
+
} else {
|
|
5177
|
+
p.serial = (TASK_MODE === 'serial');
|
|
5178
|
+
}
|
|
5179
|
+
return p;
|
|
5180
|
+
}
|
|
4123
5181
|
/* 会话托管(2026-10-23):跟团队模式一样**挂在会话上**(用户:「跟团队模式一样改成
|
|
4124
5182
|
挂在会话上,子会话继承,不再是全局」)。`sessions` 是 sid → 开/关;`fresh` 只临时装
|
|
4125
5183
|
「新会话还没拿到 session_id 时」的开关(首帧带回 id 就落到 `sessions` 上)。
|
|
@@ -4476,7 +5534,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4476
5534
|
try { localStorage.setItem(ACTIVE_ENV_KEY, c.url || target.url || ''); } catch (e) {}
|
|
4477
5535
|
showError('');
|
|
4478
5536
|
showHistory(c.host || '');
|
|
4479
|
-
|
|
5537
|
+
ensureNotifyPoll(); // 通知轮询(见 ensureNotifyPoll)
|
|
4480
5538
|
restoreQueue(); // 上次没轮到的排队任务:连上就接着排(见 restoreQueue)
|
|
4481
5539
|
}
|
|
4482
5540
|
function disconnect() {
|
|
@@ -4519,8 +5577,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4519
5577
|
在这份列表上不会自己冒出来(得先退出这张页再进来)。而用户要的正是「多个客户端马上就能
|
|
4520
5578
|
看到正在跑的回话」。这里每 5s 拉一次 `/desk/sessions`;**只有在清单真的变了时才重绘** ——
|
|
4521
5579
|
没变就不动 DOM(整表重绘会闪、还会丢滚动位置,见 renderSessions 的注释)。
|
|
4522
|
-
|
|
4523
|
-
var LIST_POLL = null;
|
|
5580
|
+
运行中那一列不靠重绘:RUN_TICK 每 3s 就地刷运行时长(见 tickRunning)。 */
|
|
4524
5581
|
function sessionSig(list) {
|
|
4525
5582
|
/* 带 status:会话级状态变化(status 变了、row 的 state 可能没变)要能触发重绘。
|
|
4526
5583
|
父字段也要比:子会话**挂在哪条主会话下面**是结构信息,父会话晚到/换 id 时
|
|
@@ -4530,36 +5587,109 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4530
5587
|
+ ':' + (s.parent_session_id || '') + ':' + (s.parent_run_id || '');
|
|
4531
5588
|
}).join('|');
|
|
4532
5589
|
}
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
5590
|
+
/* ── 页面级的「那一份」清单(2026-10-26 合并轮询)──
|
|
5591
|
+
合并前页面上有两个各跑 5s 的定时器,**各自**拉一次 /desk/sessions:
|
|
5592
|
+
· startListPoll —— 列表页可见时才拉,比签名决定要不要重绘;
|
|
5593
|
+
· startPoll —— 通知用,判状态跳变(列表页不可见时也在跑,因为要弹通知)。
|
|
5594
|
+
两者一拍撞在一起时就是同一秒两个请求;再叠上 loadSessions 一次并发 5 个接口,
|
|
5595
|
+
就是用户看到的「一会儿卡一下」。后端已加了 TTL 缓存 + 单飞(见
|
|
5596
|
+
internal/client/sessions_cache.go),但**这里省掉的是整条往返**,比缓存命中更便宜。
|
|
5597
|
+
|
|
5598
|
+
合成一份之后的口径:一次 tick 只拉一次清单,**同时**喂给两个消费者 ——
|
|
5599
|
+
① 列表:可见时才比签名重绘(不可见就不动 DOM);
|
|
5600
|
+
② 通知:永远判跳变(这是后台通知的唯一来源,不能因为列表不可见就停)。
|
|
5601
|
+
jobs 只在列表页可见时才拉(它只影响列表里的「等待中」行,通知不看它)。
|
|
5602
|
+
|
|
5603
|
+
⚠️ 谁在什么时候 tick:**只有** startPoller 里的那一个 setInterval(幂等)。
|
|
5604
|
+
startListPoll / startPoll 都只往 pollConsumers 里登记一个消费者,
|
|
5605
|
+
谁也不许自己再 setInterval —— 自挂的两个 5s 钟相位必然错开,
|
|
5606
|
+
「同一拍复用」挡不住错开两秒的撞车,请求数照样翻倍。
|
|
5607
|
+
⚠️ BASE/TOKEN 还没落定时不拉(还没配对,拉了也是 401)。
|
|
5608
|
+
|
|
5609
|
+
消费者契约(pollConsumers 的每个成员):
|
|
5610
|
+
wants() → 现在要不要跑(列表那条用它判「这张页可见吗」)
|
|
5611
|
+
jobs() → 要不要顺带拉 /desk/jobs(只有列表需要)
|
|
5612
|
+
run(list, jobs) → 拿到清单后做什么 */
|
|
5613
|
+
var pollConsumers = {};
|
|
5614
|
+
var POLLER = null;
|
|
5615
|
+
var POLL_INTERVAL_MS = 5000;
|
|
5616
|
+
/* startPoller 幂等:把所有该跑的消费者合成**一个** tick。 */
|
|
5617
|
+
function startPoller() {
|
|
5618
|
+
if (POLLER) return;
|
|
5619
|
+
POLLER = setInterval(tickPoller, POLL_INTERVAL_MS);
|
|
5620
|
+
}
|
|
5621
|
+
/* tickPoller 一轮:先看有没有消费者要跑;要跑就**只拉一次**清单,
|
|
5622
|
+
再分发给每个消费者(通知消费者恒在,见 startPoll)。 */
|
|
5623
|
+
function tickPoller() {
|
|
5624
|
+
if (!BASE && !TOKEN) return;
|
|
5625
|
+
var active = [];
|
|
5626
|
+
var wantJobs = false;
|
|
5627
|
+
for (var k in pollConsumers) {
|
|
5628
|
+
if (!Object.prototype.hasOwnProperty.call(pollConsumers, k)) continue;
|
|
5629
|
+
var c = pollConsumers[k];
|
|
5630
|
+
if (!c || typeof c.run !== 'function') continue;
|
|
5631
|
+
if (typeof c.wants === 'function' && !c.wants()) continue;
|
|
5632
|
+
active.push(c);
|
|
5633
|
+
if (typeof c.jobs === 'function' && c.jobs()) wantJobs = true;
|
|
5634
|
+
}
|
|
5635
|
+
if (!active.length) return;
|
|
5636
|
+
pollSessionsShared(wantJobs, function (list, jobs) {
|
|
5637
|
+
active.forEach(function (c) { try { c.run(list, jobs); } catch (e) {} });
|
|
5638
|
+
});
|
|
5639
|
+
}
|
|
5640
|
+
/* pollSessionsShared 唯一的取数实现:拉清单(可选带 jobs),交给 onDone。
|
|
5641
|
+
onDone 拿到的 list 一定是数组,jobs 可能是 null(没拉 / 拉失败 / 旧服务端)。 */
|
|
5642
|
+
function pollSessionsShared(wantJobs, onDone) {
|
|
5643
|
+
if (!BASE && !TOKEN) return null;
|
|
5644
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
5645
|
+
var seq = CONN_SEQ;
|
|
5646
|
+
var pList = fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
5647
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
5648
|
+
.catch(function () { return null; });
|
|
5649
|
+
var pJobs = wantJobs
|
|
5650
|
+
? fetch(BASE + '/desk/jobs?limit=200', { headers: hdr })
|
|
4546
5651
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
4547
|
-
.catch(function () { return null; })
|
|
4548
|
-
Promise.
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
5652
|
+
.catch(function () { return null; })
|
|
5653
|
+
: Promise.resolve(null);
|
|
5654
|
+
return Promise.all([pList, pJobs])
|
|
5655
|
+
.then(function (a) {
|
|
5656
|
+
if (seq !== CONN_SEQ) return; // 响应回来时已经切到另一台桌面了
|
|
5657
|
+
var list = Array.isArray(a[0]) ? a[0] : null;
|
|
5658
|
+
if (!list) { notifyWarningOnce(); return; } // 拉失败:本轮跳过(不清基线、不重绘)
|
|
5659
|
+
/* 记下这一拍:pollOnce(手动/兼容入口)若在 500ms 内醒来可直接复用。 */
|
|
5660
|
+
LAST_FETCH = { at: Date.now(), list: list };
|
|
5661
|
+
/* 通知消费者先吃:它不发请求、不改 DOM,只比状态。 */
|
|
5662
|
+
notifyFromList(list);
|
|
5663
|
+
if (typeof onDone === 'function') onDone(list, a[1] && a[1].jobs);
|
|
5664
|
+
})
|
|
5665
|
+
.catch(function () {});
|
|
5666
|
+
}
|
|
5667
|
+
function startListPoll() {
|
|
5668
|
+
/* ⚠️ 2026-10-26:列表这条**不再自挂定时器**,改为向唯一调度器登记一个消费者
|
|
5669
|
+
(见 tickPoller)。自挂定时器的两个 5s 钟各跑各的,相位必然错开 ——
|
|
5670
|
+
500ms 的同一拍复用挡不住「错开 2 秒」那种撞车,请求数还是翻倍。 */
|
|
5671
|
+
pollConsumers.list = {
|
|
5672
|
+
/* 只在这张页可见时画(不可见就别动 DOM,通知那条照常跑)。 */
|
|
5673
|
+
wants: function () { return !historyEl.hidden && !!TOKEN; },
|
|
5674
|
+
/* jobs 只在列表页可见时才拉:它只影响列表里的「等待中」行,通知不看它。 */
|
|
5675
|
+
jobs: function () { return true; },
|
|
5676
|
+
run: function (list, jobs) {
|
|
5677
|
+
/* 排队 / 运行状态变化也要能触发重绘:旧服务端的排队作业根本不在 /desk/sessions 里;
|
|
5678
|
+
新服务端虽然有登记行(RegisterQueued),但「排队 → 开跑」的占位替换也靠这里
|
|
5679
|
+
把本地那条「等待中」撤掉(见 syncServerJobs)。
|
|
5680
|
+
⚠️ 清单本身由 tickPoller 统一拉,这里只消费。 */
|
|
5681
|
+
/* 上一帧还是失败态、这次拉通了 → 安静重拉一次把失败块换回列表。
|
|
5682
|
+
不做这个的话,失败态永远不会自己恢复:签名比对里 LAST_SESSIONS 还是旧值,
|
|
5683
|
+
「没变就不重绘」恰好把失败块钉在屏幕上(只能退出重进)。 */
|
|
5684
|
+
if (LIST_FAIL) { LIST_FAIL = false; loadSessions(true); return; }
|
|
5685
|
+
if (sessionSig(list) !== sessionSig(LAST_SESSIONS)) {
|
|
5686
|
+
loadSessions(true); // 变了才走完整刷新(含标题归纳 / 健康),且不许闪
|
|
5687
|
+
return;
|
|
5688
|
+
}
|
|
5689
|
+
if (serverJobsSig(jobs) !== LAST_JOBS_SIG) loadSessions(true);
|
|
5690
|
+
},
|
|
5691
|
+
};
|
|
5692
|
+
startPoller();
|
|
4563
5693
|
}
|
|
4564
5694
|
/* ── 列表加载失败态(2026-10-25)──
|
|
4565
5695
|
以前是拼一行灰字「读取会话失败:Failed to fetch」,没有任何出路 —— 重试要退出重进,
|
|
@@ -4676,8 +5806,34 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4676
5806
|
**之前**:它改的是 PENDING,用户看到的第一帧就该带着「等待中」那几行。
|
|
4677
5807
|
会话清单一起传:判断某条作业「跑起来了没」要看登记表(见 jobLanded)。 */
|
|
4678
5808
|
LAST_JOBS_SIG = serverJobsSig(a[4] && a[4].jobs);
|
|
4679
|
-
|
|
4680
|
-
|
|
5809
|
+
/* ⚠️ 2026-10-26(用户:「还是在排队」,截图上是一条 state=queued 的会话行,时间 15:06):
|
|
5810
|
+
那行是**受理时写的排队占位**(core 的 RegisterQueued,run_id = request_id)。
|
|
5811
|
+
正常由服务端在开跑/终结时删掉,但多实例共用同一个作业目录时,占位可能没人清
|
|
5812
|
+
(现场实测:job 早已 done,占位文件还留在登记目录里,mtime 停在受理那一秒)。
|
|
5813
|
+
服务端侧已加周期回收;客户端这里再兜一道 —— 拿到的作业清单里,
|
|
5814
|
+
同一个 request_id 已经是 running / done / failed,就**别再画那条 queued 行**。
|
|
5815
|
+
只在「作业清单里确实有它、且状态不是 queued」时才丢:清单可能被 limit 截断,
|
|
5816
|
+
认不出对应作业的一律保留,宁可多一行也不能把真排队的任务弄丢。 */
|
|
5817
|
+
var sessRows = reconcileQueuedRows(Array.isArray(a[0]) ? a[0] : [], a[4] && a[4].jobs);
|
|
5818
|
+
/* 哪个目录正被谁占着(2026-10-26)。core 的 --sessions 已经把 workspace_locked /
|
|
5819
|
+
lock_holder 给了(见 CLIENT-CONTRACT.md),但**没人用过** —— 于是同目录的新任务
|
|
5820
|
+
只显示「等待中 · 同一空间有任务在跑,它结束后自动开始」,用户不知道在等谁、
|
|
5821
|
+
更不知道要等十分钟才超时。这里把占用者按目录收成一张表,排队行的副标题据此
|
|
5822
|
+
写清「等 PC 上那条『任务模式自动调度方案』」。 */
|
|
5823
|
+
LOCK_BY_WS = {};
|
|
5824
|
+
sessRows.forEach(function (s) {
|
|
5825
|
+
var h = s && s.lock_holder;
|
|
5826
|
+
if (!h || !s.workspace_locked) return;
|
|
5827
|
+
var k = wsKey(s.workspace);
|
|
5828
|
+
if (!k || LOCK_BY_WS[k]) return; // 一个目录只留第一条(同目录的多条指向同一个持有者)
|
|
5829
|
+
LOCK_BY_WS[k] = {
|
|
5830
|
+
title: String(s.title || s.prompt_head || '').trim(),
|
|
5831
|
+
session: String(s.session_id || ''),
|
|
5832
|
+
source: h.source || '', client: h.client || '', pid: h.pid || 0
|
|
5833
|
+
};
|
|
5834
|
+
});
|
|
5835
|
+
syncServerJobs(a[4] && a[4].jobs, sessRows);
|
|
5836
|
+
renderSessions(sessRows);
|
|
4681
5837
|
/* 排队任务的接续要等 DW 到手(空间键靠它归一)—— 首次列表到手正是这个时刻。
|
|
4682
5838
|
幂等:QUEUE_RESTORED 保证只接一次。之前若因 DW 未就绪被跳过,这里补上。 */
|
|
4683
5839
|
restoreQueue();
|
|
@@ -4728,7 +5884,13 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4728
5884
|
var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
|
|
4729
5885
|
var CUSTOM_SPACES = [];
|
|
4730
5886
|
try { CUSTOM_SPACES = normSpaces(JSON.parse(localStorage.getItem('magic.spaces') || '[]')); } catch (e) { CUSTOM_SPACES = []; }
|
|
4731
|
-
/*
|
|
5887
|
+
/* 空间登记册:每项 = {path, alias, hidden}。
|
|
5888
|
+
⚠️ 2026-10-26 起它**不再只是「自定义空间」清单** —— 空间有三个来源(根目录 magic-*、
|
|
5889
|
+
自定义、会话产生),以前只有「自定义」那一类能改名/删除,根目录目录与会话产生的空间
|
|
5890
|
+
删不掉也改不了名(用户:「空间都可以修改和删除」)。现在三类统一登记到这一张册子里:
|
|
5891
|
+
· alias = 用户给这个目录起的**显示名**(可以为空 = 用文件夹名)
|
|
5892
|
+
· hidden = 用户把它从空间列表里移除过(**不删磁盘目录**,只是不再出现在列表里)
|
|
5893
|
+
alias 与 hidden 都以**路径**为键 —— 同一个目录不管从哪个来源冒出来,共用一条登记。
|
|
4732
5894
|
⚠️ 显示口径统一走 spaceLabel():**有别名显示别名,没有就显示最后一级文件夹名,
|
|
4733
5895
|
永远不显示整条路径** —— /Users/x/work/某某项目 这种一长串路径在手机上一行放不下,
|
|
4734
5896
|
用户扫一眼也认不出「到底是哪个目录」(用户 2026-10-24:「否则显示最后1级的文件夹不要路径」)。
|
|
@@ -4744,12 +5906,19 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4744
5906
|
p = reqNormDir(p); // 统一分隔符 + 去尾斜杠:与服务端登记的 workspace 同一口径,
|
|
4745
5907
|
if (!p || seen[spaceKey(p)]) return; // 否则同一个空间会在列表里出现两行
|
|
4746
5908
|
seen[spaceKey(p)] = 1;
|
|
4747
|
-
out.push({
|
|
5909
|
+
out.push({
|
|
5910
|
+
path: p,
|
|
5911
|
+
alias: (it && typeof it === 'object') ? String(it.alias || '') : '',
|
|
5912
|
+
/* hidden 只认**真的 true**:老数据里没有这个字段(undefined),
|
|
5913
|
+
不能因为 `!it.hidden` 就把 undefined 当 false 用 —— 那样语义一样但读起来绕。
|
|
5914
|
+
用 === true 是为了「缺字段 = 未隐藏」这件事写在明面上。 */
|
|
5915
|
+
hidden: !!(it && typeof it === 'object' && it.hidden === true)
|
|
5916
|
+
});
|
|
4748
5917
|
});
|
|
4749
5918
|
return out;
|
|
4750
5919
|
}
|
|
4751
5920
|
function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} schedulePushPrefs(); }
|
|
4752
|
-
/*
|
|
5921
|
+
/* 某个路径在登记册里的那条(带 alias / hidden),没有登记过返回 null。
|
|
4753
5922
|
⚠️ 比路径前先归一(分隔符 + 尾斜杠 + 盘符大小写):用户手输的「/work/proj/」
|
|
4754
5923
|
与服务端登记的「\work\proj」是同一个空间,别名得认得出。 */
|
|
4755
5924
|
function customSpaceOf(p) {
|
|
@@ -4763,6 +5932,20 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4763
5932
|
if (it && it.alias) return it.alias;
|
|
4764
5933
|
return basename(p) || String(p || '');
|
|
4765
5934
|
}
|
|
5935
|
+
/* 这个空间被用户移除过吗(hidden)—— 移除 = 不出现在列表里,磁盘目录原样不动。 */
|
|
5936
|
+
function isHiddenSpace(p) {
|
|
5937
|
+
var it = customSpaceOf(p);
|
|
5938
|
+
return !!(it && it.hidden);
|
|
5939
|
+
}
|
|
5940
|
+
/* 给某个空间**登记一条**(没有就新建),返回那条登记。改名 / 移除都走它,
|
|
5941
|
+
保证「同一个目录只登记一次」。 */
|
|
5942
|
+
function ensureSpaceEntry(p) {
|
|
5943
|
+
var it = customSpaceOf(p);
|
|
5944
|
+
if (it) return it;
|
|
5945
|
+
it = { path: reqNormDir(p), alias: '', hidden: false };
|
|
5946
|
+
CUSTOM_SPACES.push(it);
|
|
5947
|
+
return it;
|
|
5948
|
+
}
|
|
4766
5949
|
/* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
|
|
4767
5950
|
⚠️ 去重要按**比较口径**(spaceKey)而不是原字符串:服务端给的是 Windows 原样
|
|
4768
5951
|
(D:\workspace\x),自定义空间是归一后的(D:/workspace/x)—— 同一个目录会
|
|
@@ -4775,6 +5958,8 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4775
5958
|
var seen = {}, out = [];
|
|
4776
5959
|
function add(p) {
|
|
4777
5960
|
if (!p) return;
|
|
5961
|
+
/* 被用户移除过的空间不进列表(hidden = 只从列表里拿掉,磁盘目录原样不动)。 */
|
|
5962
|
+
if (isHiddenSpace(p)) return;
|
|
4778
5963
|
var k = spaceKey(p);
|
|
4779
5964
|
if (seen[k]) return;
|
|
4780
5965
|
seen[k] = 1;
|
|
@@ -4790,7 +5975,33 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4790
5975
|
if (!isRealSpaceDir(s && s.workspace)) return;
|
|
4791
5976
|
add(s.workspace);
|
|
4792
5977
|
});
|
|
4793
|
-
|
|
5978
|
+
/* ⚠️ 再按**显示名**去重(2026-10-26,用户截图:「magic-stock 出现了两行」):
|
|
5979
|
+
路径不同但显示名相同,用户看到的是两个一模一样的条目,却指向两个不同的目录 ——
|
|
5980
|
+
「重复的不需要」。
|
|
5981
|
+
根因是**别名撞了真实目录名**:自定义空间 D:/workspace/chan-chart-py 起了别名
|
|
5982
|
+
「magic-stock」,而根目录下真有一个 magic-stock 目录 —— 路径去重抓不到它
|
|
5983
|
+
(spaceKey 不同),但显示名一模一样。
|
|
5984
|
+
留谁?留**显示名 == 目录真名**的那条(也就是真目录):别名是用户随手起的,
|
|
5985
|
+
真目录名才是客观事实;而且来源优先级也一致(ROOT_DIRS 在前)。
|
|
5986
|
+
两条都是别名(都是用户起的)时留先出现的那条。 */
|
|
5987
|
+
var byLabel = {};
|
|
5988
|
+
var kept = [];
|
|
5989
|
+
out.forEach(function (p) {
|
|
5990
|
+
var label = spaceLabel(p);
|
|
5991
|
+
var isRealName = (label === basename(p));
|
|
5992
|
+
var at = byLabel[label];
|
|
5993
|
+
if (at === undefined) {
|
|
5994
|
+
byLabel[label] = kept.length;
|
|
5995
|
+
kept.push(p);
|
|
5996
|
+
return;
|
|
5997
|
+
}
|
|
5998
|
+
/* 撞名了:已有那条是「真名」就保住它;已有那条是别名而这条是真名 → 换成本条。 */
|
|
5999
|
+
if (!isRealName) return;
|
|
6000
|
+
var prev = kept[at];
|
|
6001
|
+
if (spaceLabel(prev) === basename(prev)) return; // 已有那条也是真名 → 不换
|
|
6002
|
+
kept[at] = p;
|
|
6003
|
+
});
|
|
6004
|
+
return kept;
|
|
4794
6005
|
}
|
|
4795
6006
|
/* 会话里哪些是**顶层会话**(不是别人的子会话)。
|
|
4796
6007
|
团队模式会把子代理的会话挂到父会话下面去,它们的工作目录是
|
|
@@ -4913,12 +6124,30 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4913
6124
|
function cssEscape(v) { return String(v).replace(/["\\]/g, '\\$&'); }
|
|
4914
6125
|
|
|
4915
6126
|
function sidOf(s) { return s.session_id || s.run_id; }
|
|
4916
|
-
|
|
6127
|
+
/* ── 手动置顶认「会话」,不认「某一轮的 id」(2026-10-27 用户:
|
|
6128
|
+
「置顶的会话完成也是置顶啊 不要取消」)──
|
|
6129
|
+
一条会话跑着的时候可能只有 run_id —— session_id 是**收尾那一下**才由 core 回填的
|
|
6130
|
+
(见 internal/session 的 Finish)。用户这时钉住,PINNED 里存的就是 run_id;跑完
|
|
6131
|
+
sidOf 换成 session_id,只按字面比就再也对不上 —— 界面上就是「一完成,置顶自己取消了」。
|
|
6132
|
+
团队会话更狠:每轮换一个 session_id,只认一种写法同样会丢。
|
|
6133
|
+
所以判定不看字面,看**这一帧的会话分组**:renderSessions 在选「显示哪一条」之前先建
|
|
6134
|
+
PIN_ROOT(任意 id → 并查集根)与 PIN_NOW(被钉过的组的根),这里按组认人。
|
|
6135
|
+
⚠️ 与 isAutoPinned 无关:运行中临时置顶仍是**派生**的,跑完照旧归位(2026-10-22 定稿);
|
|
6136
|
+
这里保证的是**用户亲手钉的那条**不被状态 / id 变化顺手取消。 */
|
|
6137
|
+
var PIN_ROOT = {}; // 这一帧:任意 id(sid / run_id / session_id)→ 所属会话的并查集根
|
|
6138
|
+
var PIN_NOW = {}; // 这一帧:PINNED 命中的那些组的根
|
|
6139
|
+
function isPinned(s) {
|
|
6140
|
+
var id = sidOf(s);
|
|
6141
|
+
if (!id) return false;
|
|
6142
|
+
for (var i = 0; i < PINNED.length; i++) { if (PINNED[i] === id) return true; }
|
|
6143
|
+
var root = PIN_ROOT[id];
|
|
6144
|
+
return !!(root && PIN_NOW[root]);
|
|
6145
|
+
}
|
|
4917
6146
|
/* 运行中会话**自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
|
|
4918
6147
|
判据只看当前是不是在跑(`statusOf === 'run'`),**不写 PINNED、不落 prefs** ——
|
|
4919
6148
|
跑完下一次 renderSessions 自然归位,用户自己的手动置顶关系一点不动。
|
|
4920
6149
|
子会话在跑也算:子行默认折叠在父块里,只提子行的话首屏根本看不见,
|
|
4921
|
-
|
|
6150
|
+
所以把父块一起提到「置顶」段(父行本身不跑时,图标上不挂「临时置顶」角标,计数徽标会亮运行色)。
|
|
4922
6151
|
⚠️ 与手动置顶分开判:手动置顶仍只认 PINNED,自动的只在排序 / 分段时叠加。 */
|
|
4923
6152
|
function isAutoPinned(s) {
|
|
4924
6153
|
if (!s) return false;
|
|
@@ -4971,8 +6200,11 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
4971
6200
|
|| String(b.updated_at || b.started_at || '').localeCompare(String(a.updated_at || a.started_at || ''));
|
|
4972
6201
|
}
|
|
4973
6202
|
function togglePin(s) {
|
|
4974
|
-
var id = sidOf(s),
|
|
4975
|
-
|
|
6203
|
+
var id = sidOf(s), root = PIN_ROOT[id] || '', was = isPinned(s);
|
|
6204
|
+
/* 一次清掉这条会话的**所有**写法(这一轮的 run_id、上一轮的、session_id):
|
|
6205
|
+
只清字面的话,另一种写法还留在 PINNED 里 —— 刷新一下又变回「已置顶」。 */
|
|
6206
|
+
PINNED = PINNED.filter(function (p) { return p !== id && !(root && PIN_ROOT[p] === root); });
|
|
6207
|
+
if (!was) PINNED.unshift(id);
|
|
4976
6208
|
savePinned(); renderSessions(LAST_SESSIONS);
|
|
4977
6209
|
}
|
|
4978
6210
|
/* 长按会话行 → 浮动菜单:置顶/取消置顶、重命名、删除(对齐截图)。 */
|
|
@@ -5130,19 +6362,45 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5130
6362
|
排队标记**只做在图标里**,行尾不再挂角标
|
|
5131
6363
|
(2026-10-21 用户:「排队标志在会话左边的图标里设计 1 个等待样式即可」)。
|
|
5132
6364
|
团队标记同理:直接换图形,不再叠小圆角标。 */
|
|
5133
|
-
|
|
6365
|
+
/* AI 判定冲突的「AI」小标(排队图标右下角,2026-10-26 用户:「AI判断的任务需要
|
|
6366
|
+
串行排队的 显示 1 个特殊的标识」)。两条渲染路径共用:本地排队行(pendingRow)
|
|
6367
|
+
与服务端会话行(刷新后本地条目已交出去,只剩登记表那条)。 */
|
|
6368
|
+
/* 「AI 在调度」角标:与置顶图钉同款圆形角标,图标是四芒星(AI 判断的意象)。
|
|
6369
|
+
reason 来自服务端判定结论(auto.reason),拼进 title —— 长按看得到「为什么排队」。 */
|
|
6370
|
+
function aiConflictBadge(reason) {
|
|
6371
|
+
var ai = elSpan('srow__ico-ai');
|
|
6372
|
+
ai.appendChild(svgUse('#i-spark', 11));
|
|
6373
|
+
var why = String(reason || '').trim();
|
|
6374
|
+
ai.title = 'AI 正在调度:' + (why || '判定与运行中的任务冲突,串行等待');
|
|
6375
|
+
ai.setAttribute('aria-label', 'AI 正在调度,串行等待');
|
|
6376
|
+
return ai;
|
|
6377
|
+
}
|
|
6378
|
+
function sessionIcon(s, pinKind) {
|
|
5134
6379
|
var st = (FUP[sidOf(s)] || []).length ? 'queued' : statusOf(s);
|
|
5135
6380
|
var ico = elSpan('srow__ico srow__ico--' + st);
|
|
5136
6381
|
var glyph = (s && s.mode === 'team') ? '#i-team'
|
|
5137
6382
|
: (String(s && s.source) === 'mobile' ? '#i-phone' : '#i-laptop');
|
|
5138
6383
|
ico.appendChild(svgUse(glyph, 20));
|
|
6384
|
+
/* 这条排队是 AI 判了冲突(结论由 reconcileQueuedRows 从作业清单挂到 __jobAuto)。 */
|
|
6385
|
+
if (st === 'queued' && s && s.__jobAuto && s.__jobAuto.conflict) ico.appendChild(aiConflictBadge(s.__jobAuto.reason));
|
|
6386
|
+
/* 置顶标记:图标右下角一枚小图钉。
|
|
6387
|
+
manual = 用户手动置顶(PINNED);auto = 运行中自动临时置顶(跑完归位)。
|
|
6388
|
+
手动置顶必须也画(用户 2026-10:「置顶的更要有」)—— 之前只在自动置顶时画,
|
|
6389
|
+
于是被用户亲手钉住的那几条反倒没有任何标记。 */
|
|
6390
|
+
if (pinKind) {
|
|
6391
|
+
var pin = elSpan('srow__ico-pin' + (pinKind === 'manual' ? ' is-manual' : ''));
|
|
6392
|
+
pin.appendChild(svgUse('#i-pin', 12));
|
|
6393
|
+
pin.title = pinKind === 'manual'
|
|
6394
|
+
? '已置顶(长按这一行可取消)'
|
|
6395
|
+
: '运行中 · 自动临时置顶(跑完归位)';
|
|
6396
|
+
pin.setAttribute('aria-label', pinKind === 'manual' ? '已置顶' : '运行中自动临时置顶');
|
|
6397
|
+
ico.appendChild(pin);
|
|
6398
|
+
}
|
|
5139
6399
|
return ico;
|
|
5140
6400
|
}
|
|
5141
|
-
/* ──
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
var RUN_STEPS = {}; // sid → 已执行步数
|
|
5145
|
-
var RUN_CURSOR = {}; // sid → 已读到的事件 seq(null = 还没读过,下次全量)
|
|
6401
|
+
/* ── 运行中任务的**时长** ──
|
|
6402
|
+
时长 = now − started_at(「第 N 步」已去掉:用户定调无意义,不展示)。
|
|
6403
|
+
不整表重绘:只就地更新那一行的时间,避免闪烁/丢滚动位置。 */
|
|
5146
6404
|
var RUN_ROWS = {}; // sid → 该行的 DOM 引用
|
|
5147
6405
|
var RUN_TICK = null;
|
|
5148
6406
|
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
|
@@ -5153,41 +6411,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5153
6411
|
var m = Math.floor(sec / 60), ss = sec % 60;
|
|
5154
6412
|
return m >= 60 ? (Math.floor(m / 60) + ':' + pad2(m % 60) + ':' + pad2(ss)) : (m + ':' + pad2(ss));
|
|
5155
6413
|
}
|
|
5156
|
-
function runningSub(s
|
|
6414
|
+
function runningSub(s) {
|
|
5157
6415
|
var p = projectOf(s);
|
|
5158
|
-
return
|
|
5159
|
-
}
|
|
5160
|
-
/* 取「已跑到第几步」。**读过的部分不再重读**(2026-10-20):
|
|
5161
|
-
原来每 3s 对每条 running 会话拉一次全量 `/messages`,只为数 tool_use 的条数 ——
|
|
5162
|
-
长会话下等于让手机每 3 秒解析一遍几百 MB 的历史(列表页的主要卡顿来源)。
|
|
5163
|
-
现在记一个 seq 游标:首次全量(带 lean=1 让服务端裁掉 snapshot),之后只拉增量,
|
|
5164
|
-
步数在本地累加。游标超前(日志被重置/换会话)时服务端回 snapshotRequired,重置重来。 */
|
|
5165
|
-
function refreshProgress(sid) {
|
|
5166
|
-
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
5167
|
-
var after = RUN_CURSOR[sid];
|
|
5168
|
-
var url = BASE + '/desk/session/' + encodeURIComponent(sid) +
|
|
5169
|
-
(after == null ? '/messages?lean=1' : '/events?after=' + encodeURIComponent(after) + '&lean=1');
|
|
5170
|
-
fetch(url, { headers: hdr })
|
|
5171
|
-
.then(function (r) { return r.ok ? r.json() : null; })
|
|
5172
|
-
.then(function (d) {
|
|
5173
|
-
if (!d) return;
|
|
5174
|
-
if (d.snapshotRequired) { // 游标不可信 → 丢掉重来
|
|
5175
|
-
RUN_CURSOR[sid] = null; RUN_STEPS[sid] = 0;
|
|
5176
|
-
refreshProgress(sid);
|
|
5177
|
-
return;
|
|
5178
|
-
}
|
|
5179
|
-
var n = (after == null) ? 0 : (RUN_STEPS[sid] || 0);
|
|
5180
|
-
var maxSeq = 0;
|
|
5181
|
-
(d.events || []).forEach(function (e) {
|
|
5182
|
-
if ((e.kind || e.type) === 'tool_use') n++;
|
|
5183
|
-
if (e.seq > maxSeq) maxSeq = e.seq;
|
|
5184
|
-
});
|
|
5185
|
-
if (d.lastSeq != null && d.lastSeq > maxSeq) maxSeq = d.lastSeq;
|
|
5186
|
-
RUN_CURSOR[sid] = maxSeq || after || null;
|
|
5187
|
-
RUN_STEPS[sid] = n;
|
|
5188
|
-
var r = RUN_ROWS[sid];
|
|
5189
|
-
if (r && r.subEl) r.subEl.textContent = runningSub(r.s, n);
|
|
5190
|
-
}).catch(function () {});
|
|
6416
|
+
return '运行中' + (p ? ' · ' + p : '');
|
|
5191
6417
|
}
|
|
5192
6418
|
function tickRunning() {
|
|
5193
6419
|
var ids = Object.keys(RUN_ROWS);
|
|
@@ -5195,7 +6421,6 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5195
6421
|
ids.forEach(function (sid) {
|
|
5196
6422
|
var r = RUN_ROWS[sid];
|
|
5197
6423
|
if (r && r.timeEl) r.timeEl.textContent = elapsedText(r.s);
|
|
5198
|
-
refreshProgress(sid);
|
|
5199
6424
|
});
|
|
5200
6425
|
}
|
|
5201
6426
|
function ensureTicker() {
|
|
@@ -5247,14 +6472,18 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5247
6472
|
b.setAttribute('data-sid', sidOf(s)); // ensureTitle 就地改标题时要按 id 找这一行
|
|
5248
6473
|
var run = statusOf(s) === 'run';
|
|
5249
6474
|
var queued = statusOf(s) === 'queued';
|
|
5250
|
-
b.appendChild(sessionIcon(s));
|
|
6475
|
+
b.appendChild(sessionIcon(s, isPinned(s) ? 'manual' : (run ? 'auto' : '')));
|
|
5251
6476
|
var col = elSpan('srow__col');
|
|
5252
6477
|
col.appendChild(elSpan('srow__t', titleOf(s)));
|
|
5253
6478
|
var sid = sidOf(s);
|
|
5254
|
-
/* 副标题 = 引擎 · 模型;运行中换成「运行中 ·
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
6479
|
+
/* 副标题 = 引擎 · 模型;运行中换成「运行中 · 空间」,排队中说明还在等。 */
|
|
6480
|
+
var subEl = elSpan('srow__s', run ? runningSub(s)
|
|
6481
|
+
: queued ? ((s.__jobAuto && s.__jobAuto.conflict)
|
|
6482
|
+
? '自动 · 串行等待:' + shortReason(s.__jobAuto.reason || '与运行中的任务冲突', 30)
|
|
6483
|
+
: s.__jobState === 'running' ? '启动中 · 引擎已拉起,还没产出第一帧'
|
|
6484
|
+
: TASK_MODE === 'parallel' ? '排队中 · 并行模式下开跑不必等前面'
|
|
6485
|
+
: TASK_MODE === 'auto' ? '等待中 · 自动模式正在调度'
|
|
6486
|
+
: '等待中 · 同一空间有任务在跑,它结束后开始')
|
|
5258
6487
|
: [s.engine || '', qaModelText(s.model, s.engine)].filter(Boolean).join(' · '));
|
|
5259
6488
|
col.appendChild(subEl);
|
|
5260
6489
|
/* 排队与团队标记都不在这里做:两者都做在图标里(见 sessionIcon),行尾不再挂角标。 */
|
|
@@ -5293,18 +6522,11 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5293
6522
|
});
|
|
5294
6523
|
b.appendChild(chip);
|
|
5295
6524
|
}
|
|
5296
|
-
/*
|
|
5297
|
-
|
|
5298
|
-
(计数徽标会亮运行色),否则会误读成「父会话在跑」。 */
|
|
5299
|
-
if (run && !isPinned(s)) {
|
|
5300
|
-
var autoPin = elSpan('srow__auto');
|
|
5301
|
-
autoPin.title = '运行中 · 自动临时置顶(跑完自动归位)';
|
|
5302
|
-
autoPin.appendChild(svgUse('#i-pin', 14));
|
|
5303
|
-
b.appendChild(autoPin);
|
|
5304
|
-
}
|
|
6525
|
+
/* 行尾只放时间:运行中自动临时置顶的排序保留,但**不再画图钉**
|
|
6526
|
+
(用户定调:这枚标识不需要)。 */
|
|
5305
6527
|
var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
|
|
5306
6528
|
b.appendChild(timeEl);
|
|
5307
|
-
if (run)
|
|
6529
|
+
if (run) RUN_ROWS[sid] = { timeEl: timeEl, s: s };
|
|
5308
6530
|
var lp = false, timer = null;
|
|
5309
6531
|
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
|
|
5310
6532
|
b.addEventListener('pointerdown', function () {
|
|
@@ -5335,7 +6557,13 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5335
6557
|
找第一个有的,别让空间标题的引擎名因为排队行而消失。 */
|
|
5336
6558
|
var eng = '';
|
|
5337
6559
|
for (var i = 0; i < list.length; i++) { if (list[i] && list[i].engine) { eng = String(list[i].engine); break; } }
|
|
5338
|
-
|
|
6560
|
+
/* ⚠️ 除了本地提交的 pendingWait 条目,服务端**受理即登记**的 queued 占位行也要算 ——
|
|
6561
|
+
2026-10-26 实测:自动模式判冲突后那条会话是 state=queued 的登记行,它没有
|
|
6562
|
+
pendingWait 标记,waiting 算成 0 → 空间组按默认规则**折叠** → 排队行(连同刚加的
|
|
6563
|
+
AI 标)全被藏在折叠组里,用户以为「没排队」。有排队就默认展开,组头也能看到计数。 */
|
|
6564
|
+
var waiting = list.filter(function (x) {
|
|
6565
|
+
return x && (x.pendingWait || statusOf(x) === 'queued');
|
|
6566
|
+
}).length;
|
|
5339
6567
|
tog.appendChild(elSpan('wsrow__l', spaceLabel(key) + (eng ? ' · ' + eng : '')));
|
|
5340
6568
|
/* 折叠状态也要看得见「这个空间有任务在排队」—— 徽标不随展开/折叠消失。 */
|
|
5341
6569
|
if (waiting) tog.appendChild(elSpan('wsrow__badge', waiting + ' 等待中'));
|
|
@@ -5356,6 +6584,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5356
6584
|
function renderSessions(list) {
|
|
5357
6585
|
LAST_SESSIONS = list || [];
|
|
5358
6586
|
RUN_ROWS = {};
|
|
6587
|
+
/* 置顶的别名表按帧重建(见 isPinned 上方的说明):先清空,免得上一帧的分组残留,
|
|
6588
|
+
把一条已被删掉 / 换了 id 的会话仍判成「已置顶」。 */
|
|
6589
|
+
PIN_ROOT = {}; PIN_NOW = {};
|
|
5359
6590
|
/* 同一 session_id 可能有多轮 run,只留最新一条。 */
|
|
5360
6591
|
var seen = {}, rows = [];
|
|
5361
6592
|
LAST_SESSIONS.forEach(function (s) {
|
|
@@ -5408,6 +6639,19 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5408
6639
|
var b = KEY2SID[s.parent_run_id || ''] || '';
|
|
5409
6640
|
if (a && b && a !== b) ufUnion(a, b);
|
|
5410
6641
|
});
|
|
6642
|
+
/* 置顶别名(第一遍)**必须排在「选显示哪一条」之前**:rowRank 用 isPinned 给组内
|
|
6643
|
+
候选排档,这里还没建别名表的话,钉在 run_id 上的那条会按另一轮的 session_id 判成
|
|
6644
|
+
「没置顶」,组里就选了别人 —— 用户看到的是「钉着的那条不见了」。
|
|
6645
|
+
口径:并查集根相同的记录 = 同一条会话,所以这里把每条记录的各路 id 都指到根。 */
|
|
6646
|
+
PIN_ROOT = {}; PIN_NOW = {};
|
|
6647
|
+
rows.forEach(function (s) {
|
|
6648
|
+
var root = ufFind(sidOf(s));
|
|
6649
|
+
if (!root) return;
|
|
6650
|
+
[sidOf(s), s.run_id, s.session_id].forEach(function (id) {
|
|
6651
|
+
if (id) PIN_ROOT[String(id)] = root;
|
|
6652
|
+
});
|
|
6653
|
+
});
|
|
6654
|
+
PINNED.forEach(function (p) { var r = PIN_ROOT[p]; if (r) PIN_NOW[r] = 1; });
|
|
5411
6655
|
/* 每组只显示**一条**:人工置顶优先 → 活着(运行中 / 等审批)→ updated_at 新。
|
|
5412
6656
|
⚠️ 人工置顶排第一是为了「钉的是哪一轮就显示哪一轮」—— 否则折叠后用户会以为置顶掉了。 */
|
|
5413
6657
|
function rowRank(s) {
|
|
@@ -5445,6 +6689,17 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5445
6689
|
if (s.run_id) GROUP_ALIAS[s.run_id] = own;
|
|
5446
6690
|
if (s.session_id) GROUP_ALIAS[s.session_id] = own;
|
|
5447
6691
|
});
|
|
6692
|
+
/* 置顶写法归一(第二遍):PINNED 里可能还存着**跑之前**的 run_id(那时 session_id
|
|
6693
|
+
还没回填)。现在别名表在手,就地改写成这一组「显示那条」的 id 并去重 ——
|
|
6694
|
+
否则用户下次点「取消置顶」清的是新写法,旧 run_id 还留着,刷新一次又「活」回来。
|
|
6695
|
+
认不出的条目(那条会话这次不在列表里)原样保留:钉过就该一直算钉过。 */
|
|
6696
|
+
var pinNorm = [], pinChanged = false;
|
|
6697
|
+
PINNED.forEach(function (p) {
|
|
6698
|
+
var k = GROUP_ALIAS[p] || p;
|
|
6699
|
+
if (k !== p || pinNorm.indexOf(k) >= 0) pinChanged = true;
|
|
6700
|
+
if (pinNorm.indexOf(k) < 0) pinNorm.push(k);
|
|
6701
|
+
});
|
|
6702
|
+
if (pinChanged) { PINNED = pinNorm; savePinned(); }
|
|
5448
6703
|
CHILD_OF = {}; CHILD_IDS = {};
|
|
5449
6704
|
var top = [];
|
|
5450
6705
|
rows.forEach(function (s) {
|
|
@@ -5537,7 +6792,10 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5537
6792
|
真等的(见 gateAsk 的 markWaiting);但它们在磁盘上已经存在,关 App 也不丢。
|
|
5538
6793
|
PEND_ROWS = 需要**自己占一行**的排队条目(上面已算好:新任务,或那条会话暂时不在列表里)。 */
|
|
5539
6794
|
var pendingTasks = PEND_ROWS.filter(function (p) { return !p.explicit; });
|
|
5540
|
-
|
|
6795
|
+
/* 被用户移除过的空间,它的会话**回落到「任务」段**(2026-10-26):删除只是把空间
|
|
6796
|
+
从列表里拿掉,会话本身还在 —— 不能连带把会话也藏了(用户会以为任务丢了)。
|
|
6797
|
+
会话行的副标题仍写着它原来在哪个空间,认得出来。 */
|
|
6798
|
+
var tasks = rest.filter(function (s) { return !s.workspace || isHiddenSpace(s.workspace); });
|
|
5541
6799
|
if (pinned.length) {
|
|
5542
6800
|
frag.appendChild(secHead('置顶', pinned.length, false, 'pin'));
|
|
5543
6801
|
if (!SEC_COLLAPSED.pin) pinned.forEach(function (s) { frag.appendChild(sessionBlock(s)); });
|
|
@@ -5566,6 +6824,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5566
6824
|
var bySpace = {};
|
|
5567
6825
|
rest.forEach(function (s) {
|
|
5568
6826
|
var w = s.workspace; if (!w) return;
|
|
6827
|
+
/* 被用户移除过的空间不再有空间分组(2026-10-26):它的会话**不丢** ——
|
|
6828
|
+
上面「任务」段会把它们接过去(见 tasks 的过滤)。 */
|
|
6829
|
+
if (isHiddenSpace(w)) return;
|
|
5569
6830
|
var k = spaceKey(w);
|
|
5570
6831
|
(bySpace[k] = bySpace[k] || []).push(s);
|
|
5571
6832
|
});
|
|
@@ -5575,6 +6836,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5575
6836
|
用 PEND_ROWS 而不是 PEND_VIS:并进原会话行的追问不该再在空间组里插一行。 */
|
|
5576
6837
|
PEND_ROWS.forEach(function (p) {
|
|
5577
6838
|
if (!p.explicit || !p.ws) return;
|
|
6839
|
+
if (isHiddenSpace(p.ws)) return;
|
|
5578
6840
|
var k = spaceKey(p.ws);
|
|
5579
6841
|
(bySpace[k] = bySpace[k] || []).unshift(p);
|
|
5580
6842
|
});
|
|
@@ -5881,7 +7143,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5881
7143
|
组件没挂载时给「沿用默认 / 关闭」,不编造。 */
|
|
5882
7144
|
function paintRunSettings() {
|
|
5883
7145
|
var d = document.getElementById('rsDevice');
|
|
5884
|
-
if (d) d.textContent =
|
|
7146
|
+
if (d) d.textContent = envName();
|
|
5885
7147
|
var w = document.getElementById('rsWorkspace');
|
|
5886
7148
|
if (w) w.textContent = wsLabel();
|
|
5887
7149
|
var p = document.getElementById('rsPermission');
|
|
@@ -5975,6 +7237,47 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5975
7237
|
open 永远不来 → 会话行已经在跑,本地还挂着一条假的「等待中」。现在只要服务端作业
|
|
5976
7238
|
不再排队、且登记表里确实看得见它,就把本地行撤掉(见 jobLanded)。 */
|
|
5977
7239
|
var SERVER_ROW_MIN_AGE_MS = 1200; // 刚提交的别闪一下「等待中」再消失(空闲空间通常 1s 内就开跑)
|
|
7240
|
+
/* 服务端作业的 error 是**拼起来的诊断串**:先一行 stderr 原文,再一行 core 的 JSON。
|
|
7241
|
+
直接塞进副标题(40 字)会把最关键的那半句(「超时(10m0s)」)截掉 —— 实测
|
|
7242
|
+
「未能启动:magic-agent: 同一工作空间已有任务在跑,等待其结束(/ws)… {…」,
|
|
7243
|
+
用户还是不知道发生了什么。所以这里只取 JSON 里的 error/reason,没有才退回第一行。
|
|
7244
|
+
目标:一行说清「为什么失败 + 系统做了什么(等了多久)」。 */
|
|
7245
|
+
function jobHumanReason(raw) {
|
|
7246
|
+
var s = String(raw || '').trim();
|
|
7247
|
+
if (!s) return '服务端启动失败';
|
|
7248
|
+
/* 从后往前找 JSON 行 / 内嵌 {...}:core 的报错总在最后。 */
|
|
7249
|
+
var m = s.match(/\{[\s\S]*\}$/);
|
|
7250
|
+
if (m) {
|
|
7251
|
+
try {
|
|
7252
|
+
var j = JSON.parse(m[0]);
|
|
7253
|
+
var why = String((j && (j.error || j.reason)) || '').trim();
|
|
7254
|
+
if (why) return why;
|
|
7255
|
+
} catch (e) {}
|
|
7256
|
+
}
|
|
7257
|
+
return s.split('\n').map(function (x) { return x.trim(); }).filter(Boolean)[0] || '服务端启动失败';
|
|
7258
|
+
}
|
|
7259
|
+
/* 目录 → 正占着它的那条会话(2026-10-26)。由 loadSessions 从 --sessions 的
|
|
7260
|
+
workspace_locked / lock_holder 填(见那里的说明),只用来把排队行的副标题写具体。 */
|
|
7261
|
+
var LOCK_BY_WS = {};
|
|
7262
|
+
/* 占用者的人话标签:优先用设备形态(用户在手机上操作时最认这个),
|
|
7263
|
+
其次客户端名,都没有就退回 pid。 */
|
|
7264
|
+
function lockHolderLabel(h) {
|
|
7265
|
+
if (!h) return '';
|
|
7266
|
+
var where = h.source === 'mobile' ? '手机' : h.source === 'pc' ? 'PC' : '';
|
|
7267
|
+
if (!where && h.client) where = h.client === 'com.magic.test' ? '掌天瓶' : h.client;
|
|
7268
|
+
var who = where ? where + '上' : '';
|
|
7269
|
+
if (h.title) return who + '那条「' + shortReason(h.title, 18) + '」';
|
|
7270
|
+
if (who) return who + '一条任务';
|
|
7271
|
+
return h.pid ? '进程 ' + h.pid : '';
|
|
7272
|
+
}
|
|
7273
|
+
/* 已经在界面点过重试的服务端失败作业(job id → 1,2026-10-26)。
|
|
7274
|
+
服务端那条 failed 记录会一直留在 /desk/jobs 里(终态不删);不过滤的话,用户点按
|
|
7275
|
+
重试之后下一拍轮询又把它画回来,看起来像「重试没生效、还多了一条」。只在内存里记:
|
|
7276
|
+
刷新页面后重新显示是可以接受的(那说明它确实还失败着,用户也该再看到一次)。 */
|
|
7277
|
+
var RETRIED_JOBS = {};
|
|
7278
|
+
/* 失败作业在列表里最多留多久(2026-10-26)。见 syncServerJobs 里 failed 分支的说明:
|
|
7279
|
+
只显示近期失败,历史失败不占列表位置。 */
|
|
7280
|
+
var FAILED_ROW_TTL_MS = 30 * 60 * 1000;
|
|
5978
7281
|
function jobTimeMs(t) {
|
|
5979
7282
|
if (!t) return 0;
|
|
5980
7283
|
var d = new Date(String(t)); // 服务端给的是 RFC3339
|
|
@@ -5990,8 +7293,74 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
5990
7293
|
.map(function (j) { return String(j.id) + ':' + j.state + ':' + (j.position || 0); })
|
|
5991
7294
|
.sort().join('|');
|
|
5992
7295
|
}
|
|
7296
|
+
/* 并行模式:把**已经排在服务端队里**、却被当成串行受理的作业就地改成并行。
|
|
7297
|
+
⚠️ 2026-10-23(用户:「现在是并行模式怎么还在串行等待」,截图上是一条 state=queued 的会话行):
|
|
7298
|
+
这类作业是旧客户端 / 旧提交路径留下的 —— `fireQueued` 过去不传 serial(服务端 nil 即串行),
|
|
7299
|
+
或者用户在串行模式下排进去之后才切到并行。applyTaskModeToQueued 只在**拨开关的那一刻**
|
|
7300
|
+
推一次;已经切好模式、只是重开 App / 换设备的场景它根本不会跑,于是那些作业会一直在
|
|
7301
|
+
服务端排独占队,直到整条串行队消化完 —— 界面显示并行,行为还是串行。
|
|
7302
|
+
这里每次拿到作业清单就顺手对齐一次(同一 job 只推一次,防刷):
|
|
7303
|
+
· 只动 queued;running 的 updateSerial 服务端本来也会拒绝(updated=false);
|
|
7304
|
+
· 推送成功后置 MIRRORED,下一次 loadSessions 拿到 serial=false 就自然不再推。
|
|
7305
|
+
口径与 applyTaskModeToQueued 一致:任务模式是**环境级**设置,同环境排着的作业都跟着走。 */
|
|
7306
|
+
var MODE_MIRRORED = {};
|
|
7307
|
+
function mirrorModeToQueuedJobs(jobs) {
|
|
7308
|
+
/* 只认**明确并行**这一档:自动模式下 queued 作业的 serial 是服务端的判定结论,
|
|
7309
|
+
在这里改成 false 等于把「冲突、该排队」的结论抹掉。 */
|
|
7310
|
+
if (!SERVER_JOBS || !Array.isArray(jobs) || TASK_MODE !== 'parallel') return;
|
|
7311
|
+
var hdr = { 'Content-Type': 'application/json' };
|
|
7312
|
+
if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
|
|
7313
|
+
jobs.forEach(function (j) {
|
|
7314
|
+
if (!j || !j.id || j.state !== 'queued' || j.serial === false) return;
|
|
7315
|
+
if (MODE_MIRRORED[j.id]) return;
|
|
7316
|
+
MODE_MIRRORED[j.id] = 1;
|
|
7317
|
+
fetch(BASE + '/desk/jobs/' + encodeURIComponent(j.id) + '/update', { method: 'POST', headers: hdr,
|
|
7318
|
+
body: JSON.stringify({ serial: false }) })
|
|
7319
|
+
/* 服务端放行后调度器会立刻重排(jobs.go 的 updateSerial 末尾 q.signal()),
|
|
7320
|
+
这里拉一次清单让「排队中」那行当场变成运行中,不必等下一次 5s 轮询。 */
|
|
7321
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
7322
|
+
.then(function (out) {
|
|
7323
|
+
if (out && out.updated && typeof loadSessions === 'function') loadSessions(true);
|
|
7324
|
+
})
|
|
7325
|
+
.catch(function () { delete MODE_MIRRORED[j.id]; });
|
|
7326
|
+
});
|
|
7327
|
+
}
|
|
7328
|
+
/* 用**作业清单**校准「排队占位」那一行(见 loadSessions 的注释)。占位行是受理那一刻
|
|
7329
|
+
写进登记表的(run_id = request_id),它的 state 恒为 queued,**不会自己变**;
|
|
7330
|
+
作业真开跑了、甚至早早收尾了,这一行还挂着「排队中」——用户看到的就是「还在排队」。
|
|
7331
|
+
校准三档(只对 state=queued 的行,其余原样):
|
|
7332
|
+
· 作业已终结(done/failed/cancelled/expired)→ 丢掉:它的真实会话记录另有一行;
|
|
7333
|
+
· 作业 running → 保留,但标 __jobState=running,副标题改说「启动中」——
|
|
7334
|
+
这时 engine 进程已经起来了,只是还没登记/还没吐第一帧,说「排队」是错的;
|
|
7335
|
+
· 作业还 queued(或清单里认不出它)→ 原样保留。
|
|
7336
|
+
run_id 与作业的 request_id / job id 是同一套键(queuedSessionID),所以直接对得上。 */
|
|
7337
|
+
function reconcileQueuedRows(list, jobs) {
|
|
7338
|
+
if (!Array.isArray(list) || !Array.isArray(jobs) || !jobs.length) return list;
|
|
7339
|
+
/* 存**整个作业**而不只是状态:排队行要显示「为什么等」(auto.conflict/reason),
|
|
7340
|
+
刷新后本地条目已经交出去了,这些信息只能从作业清单这里挂到会话行上。 */
|
|
7341
|
+
var byId = {};
|
|
7342
|
+
jobs.forEach(function (j) {
|
|
7343
|
+
if (!j || !j.state) return;
|
|
7344
|
+
if (j.request_id) byId[String(j.request_id)] = j;
|
|
7345
|
+
if (j.id) byId[String(j.id)] = j;
|
|
7346
|
+
});
|
|
7347
|
+
var out = [];
|
|
7348
|
+
list.forEach(function (s) {
|
|
7349
|
+
if (!s) return;
|
|
7350
|
+
if (statusOf(s) !== 'queued') { out.push(s); return; }
|
|
7351
|
+
var jv = byId[String(sidOf(s) || '')];
|
|
7352
|
+
if (jv && jv.auto) s.__jobAuto = jv.auto; // 直接挂在行对象上,sessionRow/sessionIcon 读它
|
|
7353
|
+
var st = jv && jv.state;
|
|
7354
|
+
if (!st || st === 'queued') { out.push(s); return; }
|
|
7355
|
+
/* judging(自动判定中,2026-10-26)与 running 一样是「活着」:占位行不能删。 */
|
|
7356
|
+
if (st === 'running' || st === 'judging') { s.__jobState = 'running'; out.push(s); return; }
|
|
7357
|
+
/* 其余都是终态:这一行是陈迹(真实记录由 core 写过,或被删掉了)。 */
|
|
7358
|
+
});
|
|
7359
|
+
return out;
|
|
7360
|
+
}
|
|
5993
7361
|
function syncServerJobs(jobs, sessions) {
|
|
5994
7362
|
if (!SERVER_JOBS || !Array.isArray(jobs)) return;
|
|
7363
|
+
mirrorModeToQueuedJobs(jobs);
|
|
5995
7364
|
var now = Date.now();
|
|
5996
7365
|
var localOf = {};
|
|
5997
7366
|
PENDING.forEach(function (p) {
|
|
@@ -6026,6 +7395,12 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6026
7395
|
· 真的跑起来了 → 撤掉本地行(会话行接手显示);
|
|
6027
7396
|
· 没跑起来就终结(failed / expired / cancelled)→ 标成可重试的「未能启动」,
|
|
6028
7397
|
绝不静默消失(与 fireQueued 的判据同一口径)。 */
|
|
7398
|
+
/* 自动判定中(2026-10-26):≤2.5s 的中间态,**保留本地「等待中」行别动** ——
|
|
7399
|
+
按下面「非排队态」那套处理会把它当终态删掉/标失败,用户会看到行闪一下消失。 */
|
|
7400
|
+
if (j.state === 'judging') {
|
|
7401
|
+
if (j.auto) { lp.auto = j.auto; touched = true; }
|
|
7402
|
+
return;
|
|
7403
|
+
}
|
|
6029
7404
|
if (j.state !== 'queued') {
|
|
6030
7405
|
if (jobLanded(j, sessions)) { lp.gone = true; dead.push(lp.id); return; }
|
|
6031
7406
|
if (j.state === 'running') return; // 登记表还没落盘:下一拍再看
|
|
@@ -6045,6 +7420,41 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6045
7420
|
return;
|
|
6046
7421
|
}
|
|
6047
7422
|
if (lp.hide) { lp.hide = false; touched = true; }
|
|
7423
|
+
/* 判定结论跟着作业走:排队行的副标题要说明「为什么在排队」(自动 · 与谁冲突)。 */
|
|
7424
|
+
if (j.auto) {
|
|
7425
|
+
if (JSON.stringify(lp.auto || null) !== JSON.stringify(j.auto)) { lp.auto = j.auto; touched = true; }
|
|
7426
|
+
}
|
|
7427
|
+
return;
|
|
7428
|
+
}
|
|
7429
|
+
/* ⚠️ 2026-10-26(用户:「现在无法新建任务了 直接任务消失了」):
|
|
7430
|
+
**failed 必须留一行**。从前这里对非 queued 一律 return,界面上什么都不画 ——
|
|
7431
|
+
可服务端明明有这条作业、也写了真实原因(这次是「同一工作空间已有任务在跑,
|
|
7432
|
+
等待其结束…超时(10m0s)」)。用户看到的是「点了发送,任务凭空没了」,
|
|
7433
|
+
既不知道在排队、也不知道排了十分钟才失败,更没法重试。
|
|
7434
|
+
只有 cancelled / expired 才该安静消失:那是用户主动取消 / 排队过夜过期,
|
|
7435
|
+
本来就不该再占一个位置。
|
|
7436
|
+
RETRIED_JOBS:这条已经从界面点过重试了 —— 服务端那条 failed 作业还挂在
|
|
7437
|
+
/desk/jobs 里,不过滤的话下一拍又画回来(重试刚点的行会闪成两条)。
|
|
7438
|
+
FAILED_ROW_TTL_MS:只给**近期**的失败留位。作业文件名不是「跑完就删」的
|
|
7439
|
+
(实测这台机器上累计 31 条 failed,最老的隔了一天多),全画出来会把列表塞满
|
|
7440
|
+
历史垃圾,用户反而找不到自己刚发的那条。30 分钟内够覆盖「我发了、它没了」
|
|
7441
|
+
这个真实场景;更老的去「最近会话」里找(失败的 core 若真起来过会有记录)。 */
|
|
7442
|
+
if (j.state === 'failed' && !RETRIED_JOBS[j.id] && !jobLanded(j, sessions)
|
|
7443
|
+
&& (now - jobTimeMs(j.updated_at || j.created_at) < FAILED_ROW_TTL_MS)) {
|
|
7444
|
+
rows.push({
|
|
7445
|
+
id: 'srv-' + j.id + '-failed', fromJob: true, job: j.id, requestId: j.request_id || '',
|
|
7446
|
+
ws: wsKey(j.explicit_workspace ? j.workspace : ''),
|
|
7447
|
+
w: j.explicit_workspace ? j.workspace : '',
|
|
7448
|
+
explicit: !!j.explicit_workspace, pendingWait: true, hide: false, owner: 'host',
|
|
7449
|
+
session: j.session || '', prompt: j.prompt || '', engine: j.engine || '',
|
|
7450
|
+
model: j.model || '', team: j.team || null, hosted: !!j.hosted,
|
|
7451
|
+
/* 重试要原样还原用户的这些选择,否则第二次跑会用默认权限 / 档位。 */
|
|
7452
|
+
permission: j.permission || '', reasoning: j.reasoning || '',
|
|
7453
|
+
attachments: (j.attachments || []).slice(),
|
|
7454
|
+
atts: [], pos: 0, at: jobTimeMs(j.created_at), auto: j.auto || null,
|
|
7455
|
+
/* stalled:pendingRow 据此画红字 + 给「点按重试」(与本地下发失败同一套画法)。 */
|
|
7456
|
+
stalled: true, stalledReason: jobHumanReason(j.error)
|
|
7457
|
+
});
|
|
6048
7458
|
return;
|
|
6049
7459
|
}
|
|
6050
7460
|
if (j.state !== 'queued') return;
|
|
@@ -6063,7 +7473,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6063
7473
|
explicit: !!j.explicit_workspace, pendingWait: true, hide: false, owner: 'server',
|
|
6064
7474
|
session: j.session || '', prompt: j.prompt || '', engine: j.engine || '',
|
|
6065
7475
|
model: j.model || '', team: j.team || null, hosted: !!j.hosted,
|
|
6066
|
-
pos: j.position || 0, at: jobTimeMs(j.created_at)
|
|
7476
|
+
pos: j.position || 0, at: jobTimeMs(j.created_at), auto: j.auto || null
|
|
6067
7477
|
});
|
|
6068
7478
|
});
|
|
6069
7479
|
rows.sort(function (a, b) { return (a.at || 0) - (b.at || 0); }); // FIFO,与队列顺序一致
|
|
@@ -6199,8 +7609,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6199
7609
|
var pend = pendingOf(p.prompt);
|
|
6200
7610
|
var rid = (pend && pend.id) || ('q' + Date.now() + '-' + Math.floor(Math.random() * 100000));
|
|
6201
7611
|
p.request_id = rid;
|
|
6202
|
-
/*
|
|
6203
|
-
|
|
7612
|
+
/* 模式统一由 applyTaskModeToPayload 翻成 mode/serial(自动模式还要带判定引擎)。
|
|
7613
|
+
调用方已经显式指定 mode/serial 的(内部重发路径)不覆盖。 */
|
|
7614
|
+
if (p.mode === undefined && p.serial === undefined) applyTaskModeToPayload(p);
|
|
6204
7615
|
var hs = h || {};
|
|
6205
7616
|
var onOpen = hs.onOpen, onEvent = hs.onEvent, onEnd = hs.onEnd, onErr = hs.onError;
|
|
6206
7617
|
if (pend && !pend.gone) {
|
|
@@ -6212,6 +7623,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6212
7623
|
if (ev && ev.state === 'queued' && pend && !pend.gone) {
|
|
6213
7624
|
if (ev.job) pend.job = ev.job;
|
|
6214
7625
|
pend.pos = ev.position || 0;
|
|
7626
|
+
/* 自动模式:判定结论随 queued 一起回来(服务端 handleAsk 的 auto 字段,只增)。
|
|
7627
|
+
有它就写进本地行,副标题才能说「与谁冲突 / 判无冲突」,而不是干巴巴「等待中」。 */
|
|
7628
|
+
if (ev.auto) pend.auto = ev.auto;
|
|
6215
7629
|
if (pend.hide) { pend.hide = false; renderSessions(LAST_SESSIONS); }
|
|
6216
7630
|
if (typeof pend.queuedCb === 'function') { try { pend.queuedCb(true); } catch (e) {} }
|
|
6217
7631
|
}
|
|
@@ -6541,6 +7955,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6541
7955
|
本地串行门(!SERVER_JOBS)不用管:waitForSlot 每一拍都看 TASK_MODE,最多 3s 自己生效。 */
|
|
6542
7956
|
function applyTaskModeToQueued(v) {
|
|
6543
7957
|
if (!SERVER_JOBS) return;
|
|
7958
|
+
/* 切到「自动」不回溯已排队的作业(用户 2026-10-26:「不需要排队 60 秒后重新判定」):
|
|
7959
|
+
它们保留判定当时的结论,只有这之后的新提交按自动模式走。 */
|
|
7960
|
+
if (v === 'auto') return;
|
|
6544
7961
|
var serial = (v === 'serial');
|
|
6545
7962
|
PENDING.forEach(function (p) {
|
|
6546
7963
|
if (!p || p.gone || p.cancelled) return;
|
|
@@ -6642,7 +8059,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6642
8059
|
失败则保留并标「未能启动」,下次连上由 restoreQueue 再试(request_id 不变 → 幂等)。 */
|
|
6643
8060
|
function submitQueuedJob(p) {
|
|
6644
8061
|
if (!connected()) return Promise.resolve(false);
|
|
6645
|
-
var body = { engine: p.engine, prompt: p.prompt, request_id: p.id
|
|
8062
|
+
var body = applyTaskModeToPayload({ engine: p.engine, prompt: p.prompt, request_id: p.id });
|
|
6646
8063
|
if (p.model) body.model = p.model;
|
|
6647
8064
|
if (p.w) body.workspace = p.w;
|
|
6648
8065
|
if (p.session) body.session = p.session;
|
|
@@ -6680,13 +8097,26 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6680
8097
|
}
|
|
6681
8098
|
function enqueueDispatch(p) {
|
|
6682
8099
|
var ws = wsKey(p.w);
|
|
6683
|
-
var q = serialQ(ws);
|
|
6684
8100
|
/* 重新尝试(用户点「未能启动」那条):先把红字连原因一起摘掉,回到正常的排队态。 */
|
|
6685
8101
|
if (p.stalled) { p.stalled = false; p.stalledReason = ''; renderSessions(LAST_SESSIONS); }
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
8102
|
+
/* ⚠️ 2026-10-23(用户:「现在是并行模式怎么还在串行等待」):
|
|
8103
|
+
旧实现不管哪种模式都把这一条挂到 `q.tail` 上 —— 并行模式下 waitForSlot 当场放行,
|
|
8104
|
+
但 q.tail 的串行**链**还在:上一条要等 onOpen(HTTP 往返)才 resolve,甚至要再等
|
|
8105
|
+
fireQueued 的验收(最长 LAND_TIMEOUT_MS),于是后一条明明能并行,却仍在串行排队。
|
|
8106
|
+
现在按模式分开:
|
|
8107
|
+
· 并行:不碰 q.tail(空间级排队只在串行模式下有意义),所有**不同会话**的任务同时开跑;
|
|
8108
|
+
· 串行:照旧进空间 FIFO,队首才能检查(见 waitForSlot 的注释)。
|
|
8109
|
+
⚠️ 两种模式共有的**会话级**闸门必须留:同一条会话同时只能跑一轮,
|
|
8110
|
+
否则两个进程会同时往同一份会话日志里写,正文与思考区交错,是最难查的一类脏数据。 */
|
|
8111
|
+
var gate;
|
|
8112
|
+
if (TASK_MODE === 'serial') {
|
|
8113
|
+
var q = serialQ(ws);
|
|
8114
|
+
gate = q.tail.then(function () { return waitForSlot(ws, q); });
|
|
8115
|
+
q.tail = gate.catch(function () { return false; }); // 出错也不能让后面永久挂起
|
|
8116
|
+
} else {
|
|
8117
|
+
gate = Promise.resolve(true);
|
|
8118
|
+
}
|
|
8119
|
+
return gate
|
|
6690
8120
|
.then(function () { return waitSessionIdle(p.session); })
|
|
6691
8121
|
.then(function () {
|
|
6692
8122
|
/* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
|
|
@@ -6696,14 +8126,16 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6696
8126
|
if (p.owner !== 'host') return false;
|
|
6697
8127
|
p.dispatching = true; // 已交给 fireQueued:此后不许组件再接管(防双发)
|
|
6698
8128
|
return fireQueued(p);
|
|
6699
|
-
})
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
8129
|
+
})
|
|
8130
|
+
/* ⚠️ 故意**不**把 fireQueued 挂回 q.tail(旧写法 `q.tail = mine…`):那是并行仍串行的
|
|
8131
|
+
根因 —— 后一条要等前一条「真正开跑并被登记表看见」才轮到。串行模式的排队已经在
|
|
8132
|
+
waitForSlot 的 held 占位里表达,这里只负责发。 */
|
|
8133
|
+
.then(function (sent) {
|
|
8134
|
+
if (!sent) return; // 没落盘:留在队列里(stalled),不删
|
|
8135
|
+
dropPending(p.id);
|
|
8136
|
+
/* 条目刚删掉、会话行还没被 5s 轮询拉到 —— 立刻刷一次,避免列表出现空档。 */
|
|
8137
|
+
if (typeof loadSessions === 'function') loadSessions(true);
|
|
8138
|
+
}, function () { /* 没发出去:保留,下次连上重试 */ });
|
|
6707
8139
|
}
|
|
6708
8140
|
/* 真正下发一条排队任务:只发、不收画面(事件进会话日志,点开会话可回放)。
|
|
6709
8141
|
⚠️ **收到 open 不等于任务跑起来了** —— open 只说明连上了 `/desk/ask`,core 可能
|
|
@@ -6724,6 +8156,14 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6724
8156
|
② 列表侧 syncServerJobs 靠它把本地「等待中」和 /desk/jobs 里的同一作业认成
|
|
6725
8157
|
一行 —— 不带的话,恢复队列重发时本地行 + 服务端快照行会各画一条。 */
|
|
6726
8158
|
if (p.id) payload.request_id = p.id;
|
|
8159
|
+
/* ⚠️ 2026-10-23(用户:「现在是并行模式怎么还在串行等待」,截图上是一条 state=queued 的会话行):
|
|
8160
|
+
服务端 `askRequest.Serial` 的语义是 **nil = 串行**(jobs.go: `serial := true` 只有当
|
|
8161
|
+
`req.Serial != nil` 时才被覆盖)。这条重发路径原先一个字节都不传 → 服务端按串行处理 →
|
|
8162
|
+
同一 workspace 里前一条在跑,这一条就一直 queued,而客户端显示的还是「并行模式」。
|
|
8163
|
+
这是「客户端以为并行、服务端仍在串行」的唯一一处漏网:gateAsk 走 /desk/ask 有
|
|
8164
|
+
wrapTransportForJobs 补 serial,restoreQueue 走 submitQueuedJob 也补了,只有这里漏。
|
|
8165
|
+
并行模式显式传 false(服务端仍会套全局并发上限,见 jobs.go 的 parallelHeadroom)。 */
|
|
8166
|
+
applyTaskModeToPayload(payload);
|
|
6727
8167
|
if (p.model) payload.model = p.model;
|
|
6728
8168
|
if (p.w) payload.workspace = p.w;
|
|
6729
8169
|
/* 带上原会话 id 才是「追问」;不带就是新起一段会话(见 addPending 里 session 的注释)。 */
|
|
@@ -6738,7 +8178,22 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6738
8178
|
if (p.hosted) payload.hosted = true;
|
|
6739
8179
|
if (p.attachments && p.attachments.length) payload.attachments = p.attachments;
|
|
6740
8180
|
return new Promise(function (resolve) {
|
|
6741
|
-
var done = false, handle = null, t0 = Date.now();
|
|
8181
|
+
var done = false, handle = null, t0 = Date.now(), armed = false;
|
|
8182
|
+
/* ── 不在会话页里:发出去就够了,验收交给登记表 ──
|
|
8183
|
+
⚠️ 2026-10-23(「现在是并行模式怎么还在串行等待」):宿主下发本来只「发、不收画面」,
|
|
8184
|
+
但这里紧跟 onOpen 就 cancel —— 用户在列表页 / 已经离开会话页时,open 帧永远到不了,
|
|
8185
|
+
验收函数一次都不会跑,条目挂着「等待中」直到 20s 超时判「未能启动」。
|
|
8186
|
+
更糟的是:异步的收口让列表上出现「一条在跑、另一条还在等」,并行模式下看起来就是串行。
|
|
8187
|
+
现在:open 到了就先起验收(arm),再掐掉这条没人看的长连接;掐过之后只按登记表判
|
|
8188
|
+
(见 byRegistryOnly),确认不到由 LAND_TIMEOUT_MS 收口。 */
|
|
8189
|
+
function arm() {
|
|
8190
|
+
if (armed) return;
|
|
8191
|
+
armed = true;
|
|
8192
|
+
try { if (handle && handle.cancel) handle.cancel(); } catch (e) {}
|
|
8193
|
+
seenInRegistry(0);
|
|
8194
|
+
}
|
|
8195
|
+
/* 记录起点:pollOnce 的对账(reconcileQueued)靠它认「t0 之后新出现的记录」。 */
|
|
8196
|
+
p.t0 = t0;
|
|
6742
8197
|
/* why = 失败原因(人话)。以前判失败只留一个布尔,用户只能看到「未能启动」四个字,
|
|
6743
8198
|
无从判断是路由配错、core 崩了还是隧道断了(2026-10-21:「未能启动 为什么」)。 */
|
|
6744
8199
|
var why = '';
|
|
@@ -6762,17 +8217,13 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6762
8217
|
优先取里面的 error/reason 当人话;解析不了就退回最后一行原文。
|
|
6763
8218
|
比「退出码 2」对用户有用得多。 */
|
|
6764
8219
|
function endReason(code, ev) {
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
if (j && (j.error || j.reason)) return String(j.error || j.reason);
|
|
6773
|
-
} catch (e) {}
|
|
6774
|
-
}
|
|
6775
|
-
return t;
|
|
8220
|
+
/* 与作业快照那条路径共用同一个提炼函数(见 jobHumanReason)——
|
|
8221
|
+
两条路各写一份的话,同一次失败在「本地下发」和「服务端快照」两种呈现下
|
|
8222
|
+
会给出措辞不同的原因,用户会以为踩了两个不同的坑。 */
|
|
8223
|
+
var s = String((ev && ev.stderr) || '').trim();
|
|
8224
|
+
if (s) {
|
|
8225
|
+
var why = jobHumanReason(s);
|
|
8226
|
+
if (why && why !== '服务端启动失败') return why;
|
|
6776
8227
|
}
|
|
6777
8228
|
return code ? 'core 退出码 ' + code : '';
|
|
6778
8229
|
}
|
|
@@ -6785,10 +8236,14 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6785
8236
|
/* 只增不减:onError 之后再来的 onEnd(null) 不能把宽限期压回 0,否则刚许的 4s 白给。 */
|
|
6786
8237
|
if (typeof graceMs === 'number' && graceMs > grace) grace = graceMs;
|
|
6787
8238
|
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
8239
|
+
/* 兜底验收:arm() 之后掐了流,seenAt 就再也不会更新 —— 不能再拿「流收尾了 + 宽限已过」
|
|
8240
|
+
当失败判据(那是「确认这条路走完了」,不是「它没起来」)。改为:掐过流 / 曾经离开了
|
|
8241
|
+
会话页(LEAVE_CONV_AT 晚于 t0)时,只按会话登记表判,确认不到由 LAND_TIMEOUT_MS 收口。 */
|
|
8242
|
+
var byRegistryOnly = armed || LEAVE_CONV_AT > t0;
|
|
6788
8243
|
function again() {
|
|
6789
8244
|
if (done) return;
|
|
6790
8245
|
var now = Date.now();
|
|
6791
|
-
if (seenAt && now - seenAt >= grace) { fin(false); return; }
|
|
8246
|
+
if (!byRegistryOnly && seenAt && now - seenAt >= grace) { fin(false); return; }
|
|
6792
8247
|
if (now - t0 >= LAND_TIMEOUT_MS) {
|
|
6793
8248
|
why = why || '20 秒内没有出现在会话登记表(core 没起来或请求没到服务端)';
|
|
6794
8249
|
fin(false);
|
|
@@ -6800,9 +8255,12 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6800
8255
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
6801
8256
|
.then(function (list) {
|
|
6802
8257
|
if (done) return;
|
|
8258
|
+
/* ⚠️ 新任务只认**自己的** t0:并行模式下同一空间可能有好几条同时发出,
|
|
8259
|
+
「任意一条在跑」不等于「这一条起来了」—— 认早了它的记录还没出现就被删行,
|
|
8260
|
+
用户点开会话看到的是空白。容差 2s 在 startedAfter 里。 */
|
|
6803
8261
|
var seen = Array.isArray(list) && list.some(function (s) {
|
|
6804
8262
|
if (wsOf(s) !== wsKey(p.w)) return false;
|
|
6805
|
-
if (p.session) return String(sidOf(s) || '') === String(p.session);
|
|
8263
|
+
if (p.session) return String(sidOf(s) || '') === String(p.session) || startedAfter(s, t0);
|
|
6806
8264
|
return startedAfter(s, t0);
|
|
6807
8265
|
});
|
|
6808
8266
|
if (seen) fin(true); else again();
|
|
@@ -6811,7 +8269,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6811
8269
|
}
|
|
6812
8270
|
try {
|
|
6813
8271
|
handle = transport.ask(payload, {
|
|
6814
|
-
|
|
8272
|
+
/* open 到了就交验收(arm:先起验收,再掐掉这条没人看的长连接)。
|
|
8273
|
+
不管这一条是不是在会话页里发的 —— 判据只看登记表,不看流还在不在。 */
|
|
8274
|
+
onOpen: function () { arm(); },
|
|
6815
8275
|
/* 事件里一旦带出 session_id,说明 core 已经跑起来并登记了 —— 不必再查表。 */
|
|
6816
8276
|
onEvent: function (ev) { if (ev && ev.session_id) fin(true); },
|
|
6817
8277
|
/* code 非空 = 这一轮以失败收尾;若登记表里也没有它,就是没跑起来。
|
|
@@ -6843,6 +8303,35 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6843
8303
|
});
|
|
6844
8304
|
}
|
|
6845
8305
|
|
|
8306
|
+
/* ── 宿主下发的对账(2026-10-23「并行模式还在串行等待」的第二个口子)──
|
|
8307
|
+
宿主这条(fireQueued)只想「发、不看画面」,验收靠 open 帧 —— 但那帧要等服务端
|
|
8308
|
+
真把 SSE 建起来。用户在列表页时,或流被环境层立刻掐掉时,open 可能**永远不来**:
|
|
8309
|
+
条目就卡在「等待中」,要等 20s 超时才收口;那 20s 里列表看着就像还在串行排队。
|
|
8310
|
+
这里不修任何时序细节,只做**对账**:每一拍拿到登记表,凡是已下发、还没记账的条目,
|
|
8311
|
+
只要该空间/该会话里出现了 t0 之后开始、或此刻还在跑的记录,就当场认它「开跑了」。 */
|
|
8312
|
+
function reconcileQueued(list) {
|
|
8313
|
+
var hit = false;
|
|
8314
|
+
PENDING.forEach(function (p) {
|
|
8315
|
+
if (!p || p.gone || p.owner !== 'host' || !p.dispatching || p.claimed || !p.t0) return;
|
|
8316
|
+
var ws = wsKey(p.w);
|
|
8317
|
+
var seen = list.some(function (s) {
|
|
8318
|
+
if (p.session) {
|
|
8319
|
+
var sid = String(sidOf(s) || '');
|
|
8320
|
+
return sid === String(p.session) && (serialBusy(s) || startedAfter(s, p.t0));
|
|
8321
|
+
}
|
|
8322
|
+
return wsOf(s) === ws && (serialBusy(s) || startedAfter(s, p.t0));
|
|
8323
|
+
});
|
|
8324
|
+
if (!seen) return;
|
|
8325
|
+
p.claimed = true;
|
|
8326
|
+
hit = true;
|
|
8327
|
+
/* 与 fireQueued 的成功收口同一动作:删行 + 立刻刷一次列表(避免 5s 轮询前的空档)。
|
|
8328
|
+
fireQueued 自己的验收随后也会认租它(服务端已开跑 → 登记表一定有记录),
|
|
8329
|
+
p.gone 之后那边的 dropPending / stalled 都是空操作。 */
|
|
8330
|
+
dropPending(p.id);
|
|
8331
|
+
});
|
|
8332
|
+
if (hit && typeof loadSessions === 'function') loadSessions(true);
|
|
8333
|
+
}
|
|
8334
|
+
|
|
6846
8335
|
/* 等待中任务的列表行(不可点开,纯状态展示)。
|
|
6847
8336
|
带 session 的是**历史会话的追问**(串行门对追问一视同仁,见 gateAsk):
|
|
6848
8337
|
副标题说清楚「追问」二字,否则用户会以为自己在原会话里排队,列表上却凭空多出
|
|
@@ -6853,6 +8342,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6853
8342
|
var ico = elSpan('srow__ico srow__ico--queued');
|
|
6854
8343
|
/* 团队任务排队时也用双人图形,别先闪一下电脑再换(与 sessionIcon 同一规则)。 */
|
|
6855
8344
|
ico.appendChild(svgUse(p.team ? '#i-team' : '#i-laptop', 20));
|
|
8345
|
+
/* AI 判定「与运行中的任务冲突,必须串行」才挂标;并行模式下没轮到(等槽位)、
|
|
8346
|
+
自动判定降级为并行、stalled 重试行都不挂 —— 那个标只代表「AI 判了冲突」。 */
|
|
8347
|
+
if (p.auto && p.auto.conflict && !p.stalled) ico.appendChild(aiConflictBadge(p.auto.reason));
|
|
6856
8348
|
b.appendChild(ico);
|
|
6857
8349
|
var col = elSpan('srow__col');
|
|
6858
8350
|
col.appendChild(elSpan('srow__t', String(p.prompt || '新任务').slice(0, 40)));
|
|
@@ -6862,8 +8354,27 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6862
8354
|
后面拼上**真实原因**(end 帧 stderr / HTTP 正文 / 超时),否则用户只知道失败、
|
|
6863
8355
|
不知道是路由配错还是服务没起(2026-10-21:「未能启动 为什么」)。 */
|
|
6864
8356
|
var reason = p.stalled ? String(p.stalledReason || '').trim() : '';
|
|
8357
|
+
/* ⚠️ 2026-10-23(用户:「现在是并行模式怎么还在串行等待」):串行模式下这条文案是对的;
|
|
8358
|
+
并行模式还排在队里,**不是**串行门在挡,而是三者之一(见下面三支)——
|
|
8359
|
+
写成「上一个完成后自动开始」等于把真实原因藏起来,用户就会以为模式开关没生效。 */
|
|
8360
|
+
/* 自动模式的排队行要说清「为什么等」:判冲突时把冲突对象/文件直接写出来,
|
|
8361
|
+
否则用户只会看到一条「等待中」,以为模式开关又没生效(2026-10-24 那次报障的观感)。 */
|
|
8362
|
+
var auto = p.auto || null;
|
|
8363
|
+
/* 这个目录正被谁占着(2026-10-26):「同一空间有任务在跑」只说了一半 ——
|
|
8364
|
+
用户没法判断该等、该去停哪条、还是该换个空间发。core 已经把占用者身份
|
|
8365
|
+
随 --sessions 给了(workspace_locked / lock_holder),这里直接用;
|
|
8366
|
+
老 core 没有这两个字段 → LOCK_BY_WS 为空 → 文案自动退回原来那句。 */
|
|
8367
|
+
var holder = LOCK_BY_WS[p.ws] || null;
|
|
8368
|
+
var waitWho = holder ? lockHolderLabel(holder) : '';
|
|
6865
8369
|
var text = p.stalled ? '未能启动' + (reason ? ':' + shortReason(reason, 40) : '') + ' · 点按重试'
|
|
6866
|
-
:
|
|
8370
|
+
: p.session ? '追问排队中 · 同一会话上一轮结束后自动发送'
|
|
8371
|
+
: (auto && auto.conflict) ? '自动 · 串行等待:' + shortReason(auto.reason || '与运行中的任务冲突', 34)
|
|
8372
|
+
: (auto && auto.by === 'model') ? '等待中 · 自动判定无冲突,正在开跑'
|
|
8373
|
+
: (auto && auto.by === 'undecided') ? '等待中 · 自动判定未完成,按并行放行'
|
|
8374
|
+
: TASK_MODE === 'auto' ? '等待中 · 自动模式正在调度' + (waitWho ? '(' + waitWho + '占着这个目录)' : '')
|
|
8375
|
+
: TASK_MODE !== 'serial' ? '等待中 · 正在开跑(并行模式:不必等前面的任务)'
|
|
8376
|
+
: waitWho ? '等待中 · 等 ' + waitWho + '结束'
|
|
8377
|
+
: '等待中 · 同一空间有任务在跑,它结束后自动开始';
|
|
6867
8378
|
sub.appendChild(elSpan('srow__stext', text));
|
|
6868
8379
|
col.appendChild(sub);
|
|
6869
8380
|
b.appendChild(col);
|
|
@@ -6874,6 +8385,24 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
6874
8385
|
b.onclick = function () {
|
|
6875
8386
|
if (b.dataset.busy === '1') return;
|
|
6876
8387
|
b.dataset.busy = '1';
|
|
8388
|
+
/* 服务端那条失败作业(fromJob)点重试后要记住它,否则下一拍轮询又画回来。 */
|
|
8389
|
+
if (p.fromJob && p.job) RETRIED_JOBS[p.job] = 1;
|
|
8390
|
+
/* id 要换一个新的:服务端按 request_id 幂等 —— 沿用旧 id 会直接拿回那条
|
|
8391
|
+
failed 作业(submitAsk 命中 byReq 就返回旧的),用户点了重试却毫无动静。
|
|
8392
|
+
所以这里先摘掉旧条目、用新 id 重建一条再下发。 */
|
|
8393
|
+
if (p.fromJob) {
|
|
8394
|
+
var retry = {
|
|
8395
|
+
ws: p.ws, w: p.w, explicit: p.explicit, session: p.session,
|
|
8396
|
+
prompt: p.prompt, engine: p.engine, model: p.model,
|
|
8397
|
+
permission: p.permission || '', reasoning: p.reasoning || '',
|
|
8398
|
+
team: p.team || null, hosted: !!p.hosted,
|
|
8399
|
+
attachments: (p.attachments || []).slice(), atts: (p.atts || []).slice(),
|
|
8400
|
+
owner: 'host'
|
|
8401
|
+
};
|
|
8402
|
+
dropPending(p.id);
|
|
8403
|
+
enqueueDispatch(addPending(retry.ws, retry, false));
|
|
8404
|
+
return;
|
|
8405
|
+
}
|
|
6877
8406
|
enqueueDispatch(p);
|
|
6878
8407
|
};
|
|
6879
8408
|
}
|
|
@@ -7188,6 +8717,217 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7188
8717
|
toast('当前没有可用的系统通知通道,已改用应用内提示');
|
|
7189
8718
|
return Promise.resolve('toast');
|
|
7190
8719
|
}
|
|
8720
|
+
/* ══════════ 飞书推送(我的 → 飞书推送)══════════
|
|
8721
|
+
2026-10-26 用户:「另增加飞书绑定支持登录后推送通知」。
|
|
8722
|
+
**为什么要有它**:手机端系统通知(上面那一整套)只在页面开着、5 秒轮询在跑、
|
|
8723
|
+
还在同一网络里才响。锁屏 / 切后台 / 人不在 Wi-Fi 上 = 什么都收不到。
|
|
8724
|
+
飞书推送把**桌面端**变成推送源:任务完成 / 待确认时,桌面端用飞书机器人
|
|
8725
|
+
往用户本人推一张卡片,人在飞书里就能看见。
|
|
8726
|
+
|
|
8727
|
+
**绑定 = 两步**(对齐「登录」):
|
|
8728
|
+
① 点「获取绑定码」→ POST /desk/feishu/code 拿 6 位码(10 分钟有效);
|
|
8729
|
+
② 在飞书里把码发给我们发给你的机器人,再从机器人收到的卡片里把
|
|
8730
|
+
「我的 open_id」填进来 → POST /desk/feishu/bind。
|
|
8731
|
+
为什么不直接让手机自己调飞书接口拿 open_id:远程页(/m/)里没有飞书登录态,
|
|
8732
|
+
拿不到用户身份;而**桌面端**手里有应用凭据(app_id/app_secret),
|
|
8733
|
+
open_id 只要来路可信(绑定码只发给了用户自己)就够定位「推给谁」。
|
|
8734
|
+
绑定码只认发起者的 10 分钟窗口、错码不退让、用过即废 —— 别人猜不到也抢不走。
|
|
8735
|
+
|
|
8736
|
+
状态一律以 GET /desk/feishu 为准(服务端宣布什么就显示什么),
|
|
8737
|
+
本页只缓存最后一次结果给上一页那行「飞书推送」用。 */
|
|
8738
|
+
var FEISHU_CACHE = null;
|
|
8739
|
+
function feishuApi(method, action, body) {
|
|
8740
|
+
if (!BASE && !TOKEN) return Promise.reject(new Error('还没连接桌面端'));
|
|
8741
|
+
var hdr = { 'Content-Type': 'application/json' };
|
|
8742
|
+
if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
|
|
8743
|
+
var opt = { method: method, headers: hdr };
|
|
8744
|
+
if (body != null) opt.body = JSON.stringify(body);
|
|
8745
|
+
return fetch(BASE + '/desk/feishu' + (action ? '/' + action : ''), opt).then(function (r) {
|
|
8746
|
+
return r.json().catch(function () { return {}; }).then(function (j) {
|
|
8747
|
+
if (!r.ok) {
|
|
8748
|
+
/* 老桌面端没有这条路由:404 的 body 不是我们的 {error} 形状,给一句能落地的提示。 */
|
|
8749
|
+
if (r.status === 404) throw new Error('桌面端版本过旧,没有飞书推送接口(升级 magic-agent 后重启服务)');
|
|
8750
|
+
throw new Error((j && j.error) || ('HTTP ' + r.status));
|
|
8751
|
+
}
|
|
8752
|
+
return j;
|
|
8753
|
+
});
|
|
8754
|
+
});
|
|
8755
|
+
}
|
|
8756
|
+
/* 「我的」那行「飞书推送」的值:未绑定 / 已绑定 · 已开启 / 已绑定 · 已关闭。 */
|
|
8757
|
+
function feishuLabel(st) {
|
|
8758
|
+
if (!st) return '—';
|
|
8759
|
+
if (!st.bound) return st.mode === 'webhook' ? '未绑定' : '未绑定 · 未配置通道';
|
|
8760
|
+
return st.enabled ? '已开启' : '已关闭';
|
|
8761
|
+
}
|
|
8762
|
+
function paintFeishuSummary(st) {
|
|
8763
|
+
var el = document.getElementById('feishuSummary');
|
|
8764
|
+
if (el) el.textContent = feishuLabel(st);
|
|
8765
|
+
}
|
|
8766
|
+
function refreshFeishuSummary() {
|
|
8767
|
+
if (FEISHU_CACHE) paintFeishuSummary(FEISHU_CACHE);
|
|
8768
|
+
if (!BASE && !TOKEN) return;
|
|
8769
|
+
feishuApi('GET').then(function (st) {
|
|
8770
|
+
FEISHU_CACHE = st;
|
|
8771
|
+
paintFeishuSummary(st);
|
|
8772
|
+
}).catch(function () { /* 拉不到就保持原样,别把「—」当成未绑定 */ });
|
|
8773
|
+
}
|
|
8774
|
+
function feishuSub(body, text, opts) {
|
|
8775
|
+
var d = elSpan('pick__d', text);
|
|
8776
|
+
d.style.display = 'block'; d.style.margin = '0 0 8px'; d.style.textAlign = 'left';
|
|
8777
|
+
d.style.lineHeight = '1.55';
|
|
8778
|
+
if (opts && opts.error) d.style.color = 'var(--error-text)';
|
|
8779
|
+
body.appendChild(d);
|
|
8780
|
+
return d;
|
|
8781
|
+
}
|
|
8782
|
+
function feishuBtn(body, label, onClick, opts) {
|
|
8783
|
+
var b = hEl('button', 'btn' + ((opts && opts.filled) ? ' btn--filled' : ''), label);
|
|
8784
|
+
b.type = 'button';
|
|
8785
|
+
b.style.marginTop = '6px';
|
|
8786
|
+
/* 抽屉里的按钮是**整行**的(.btn 默认撑满);这里塞在列表流里,会被
|
|
8787
|
+
`.setrow{text-align:left}` 这类继承带成左对齐,读起来像一行正文。
|
|
8788
|
+
显式居中 + 左右留一点边,让它一眼是「可以点的按钮」。 */
|
|
8789
|
+
if (opts && opts.danger) { b.style.color = 'var(--error-text)'; }
|
|
8790
|
+
if (opts && opts.disabled) { b.disabled = true; b.style.opacity = '.5'; }
|
|
8791
|
+
b.onclick = function () { if (!b.disabled) onClick(b); };
|
|
8792
|
+
body.appendChild(b);
|
|
8793
|
+
return b;
|
|
8794
|
+
}
|
|
8795
|
+
/* 本页的全部状态都从 GET /desk/feishu 来,每次动作完重画一次。 */
|
|
8796
|
+
function paintFeishu(notice, isError) {
|
|
8797
|
+
var body = document.getElementById('feishuBody');
|
|
8798
|
+
if (!body) return;
|
|
8799
|
+
body.innerHTML = '';
|
|
8800
|
+
if (notice) feishuSub(body, notice, { error: isError });
|
|
8801
|
+
if (!BASE && !TOKEN) {
|
|
8802
|
+
body.appendChild(hEl('div', 'history__hint', '还没有连接桌面端'));
|
|
8803
|
+
return;
|
|
8804
|
+
}
|
|
8805
|
+
body.appendChild(hEl('div', 'history__hint', '正在读取飞书配置…'));
|
|
8806
|
+
feishuApi('GET').then(function (st) {
|
|
8807
|
+
FEISHU_CACHE = st;
|
|
8808
|
+
paintFeishuSummary(st);
|
|
8809
|
+
body.innerHTML = '';
|
|
8810
|
+
if (st.error) feishuSub(body, st.error, { error: true });
|
|
8811
|
+
|
|
8812
|
+
/* 2026-10-26 用户定调:「改为直接绑定会话 id 即可」。
|
|
8813
|
+
所以这一页只留一条路:填一个 oc_ 开头的会话 ID,推给那个群 / 那个单聊。
|
|
8814
|
+
绑定码还在(服务端要它防「随便谁都能绑」),但**用户不用管** ——
|
|
8815
|
+
点确定时先自动取一个码,再把码和会话 ID 一起提交。 */
|
|
8816
|
+
body.appendChild(hEl('div', 'history__group', '推送会话'));
|
|
8817
|
+
var bd = hEl('div', 'setcard');
|
|
8818
|
+
var brow = hEl('div', 'setrow');
|
|
8819
|
+
brow.appendChild(elSpan('setrow__l', '会话 ID'));
|
|
8820
|
+
/* bound_to 是服务端**打过码**的会话 id(只留末 6 位)。 */
|
|
8821
|
+
brow.appendChild(elSpan('setrow__v', st.bound
|
|
8822
|
+
? ('已绑定 · ' + (st.bound_to || ('••' + (st.open_id_tail || ''))))
|
|
8823
|
+
: '未绑定'));
|
|
8824
|
+
bd.appendChild(brow);
|
|
8825
|
+
if (st.bound && st.enabled != null) {
|
|
8826
|
+
var trow = hEl('div', 'setrow');
|
|
8827
|
+
trow.appendChild(elSpan('setrow__l', '推送开关'));
|
|
8828
|
+
var tg = hEl('button', 'tgl' + (st.enabled ? ' is-on' : ''), '');
|
|
8829
|
+
tg.type = 'button'; tg.setAttribute('aria-label', '飞书推送开关');
|
|
8830
|
+
tg.onclick = function () {
|
|
8831
|
+
feishuApi('POST', 'enable', { enabled: !st.enabled })
|
|
8832
|
+
.then(function () { paintFeishu(st.enabled ? '已关闭飞书推送' : '已开启飞书推送'); })
|
|
8833
|
+
.catch(function (e) { paintFeishu(e.message, true); });
|
|
8834
|
+
};
|
|
8835
|
+
trow.appendChild(tg);
|
|
8836
|
+
bd.appendChild(trow);
|
|
8837
|
+
}
|
|
8838
|
+
body.appendChild(bd);
|
|
8839
|
+
|
|
8840
|
+
if (!st.bound) {
|
|
8841
|
+
feishuSub(body, '填飞书里的会话 ID(oc_ 开头)就绑好了 —— 任务跑完 / 等你确认时会推到这个会话。取法:在飞书里打开那个群或和机器人的单聊,复制链接,末尾那串 oc_… 就是。');
|
|
8842
|
+
} else {
|
|
8843
|
+
feishuSub(body, '任务跑完 / 等你确认时会推一条卡片到这个会话。');
|
|
8844
|
+
}
|
|
8845
|
+
|
|
8846
|
+
feishuBtn(body, st.bound ? '更换会话 ID' : '填会话 ID', function () {
|
|
8847
|
+
openTextInputSheet({
|
|
8848
|
+
title: '绑定推送会话',
|
|
8849
|
+
value: '',
|
|
8850
|
+
placeholder: 'oc_a90ab20986ffccf22b95939a1a8ce2a4',
|
|
8851
|
+
emptyHint: '把 oc_ 开头的那串粘进来'
|
|
8852
|
+
}, function (to) {
|
|
8853
|
+
/* 绑定码对用户不可见:先取一个,再和会话 ID 一起提交。
|
|
8854
|
+
失败时把服务端原话显示出来(认不出 oc_ / 凭据没配 / 发不出去,都会说清楚)。 */
|
|
8855
|
+
feishuApi('POST', 'code').then(function (r) {
|
|
8856
|
+
return feishuApi('POST', 'bind', { code: r.code, to: to });
|
|
8857
|
+
}).then(function (r) {
|
|
8858
|
+
FEISHU_CODE = '';
|
|
8859
|
+
if (r.notice) paintFeishu('已绑定。' + r.notice, true);
|
|
8860
|
+
else if (r.push && r.push.ok) paintFeishu('已绑定,测试卡片已经发到那个会话');
|
|
8861
|
+
else paintFeishu('已绑定,但测试卡片没发出去:' + ((r.push && r.push.reason) || '未知原因'), true);
|
|
8862
|
+
}).catch(function (e) { paintFeishu(e.message, true); });
|
|
8863
|
+
});
|
|
8864
|
+
}, { filled: !st.bound });
|
|
8865
|
+
|
|
8866
|
+
/* 应用凭据:推给会话(oc_)用的是应用机器人身份,所以这一栏是**必需**的。 */
|
|
8867
|
+
body.appendChild(hEl('div', 'history__group', '飞书应用'));
|
|
8868
|
+
var cred = hEl('div', 'setcard');
|
|
8869
|
+
var row = hEl('div', 'setrow');
|
|
8870
|
+
row.appendChild(elSpan('setrow__l', '应用凭据'));
|
|
8871
|
+
row.appendChild(elSpan('setrow__v', st.configured ? ('已配置 · ' + (st.source || '')) : '未配置'));
|
|
8872
|
+
cred.appendChild(row);
|
|
8873
|
+
body.appendChild(cred);
|
|
8874
|
+
if (!st.configured) {
|
|
8875
|
+
feishuSub(body, '推给会话需要应用身份:填一次自建应用的 App ID / App Secret(只在桌面端保存,不会随页面下发)。');
|
|
8876
|
+
feishuBtn(body, '填写应用凭据', function () {
|
|
8877
|
+
openTextInputSheet({
|
|
8878
|
+
title: '飞书应用凭据',
|
|
8879
|
+
value: st.app_id || '',
|
|
8880
|
+
placeholder: 'App ID(cli_ 开头)',
|
|
8881
|
+
emptyHint: '请填 App ID',
|
|
8882
|
+
secondary: { label: 'App Secret', placeholder: 'app_secret' }
|
|
8883
|
+
}, function (id, sec) {
|
|
8884
|
+
feishuApi('POST', 'config', { app_id: id, app_secret: sec })
|
|
8885
|
+
.then(function () { paintFeishu('应用凭据已保存'); })
|
|
8886
|
+
.catch(function (e) { paintFeishu(e.message, true); });
|
|
8887
|
+
});
|
|
8888
|
+
});
|
|
8889
|
+
}
|
|
8890
|
+
|
|
8891
|
+
if (st.bound) {
|
|
8892
|
+
feishuBtn(body, '发一条测试消息', function (b) {
|
|
8893
|
+
b.disabled = true;
|
|
8894
|
+
feishuApi('POST', 'test', { url: connUrlNow() }).then(function () {
|
|
8895
|
+
paintFeishu('测试卡片已发出,去飞书看看');
|
|
8896
|
+
}).catch(function (e) { paintFeishu('发送失败:' + e.message, true); });
|
|
8897
|
+
}, { disabled: !st.configured });
|
|
8898
|
+
feishuBtn(body, '解绑', function () {
|
|
8899
|
+
feishuApi('POST', 'unbind').then(function () { paintFeishu('已解绑,不会再推送到飞书'); })
|
|
8900
|
+
.catch(function (e) { paintFeishu(e.message, true); });
|
|
8901
|
+
}, { danger: true });
|
|
8902
|
+
}
|
|
8903
|
+
|
|
8904
|
+
/* ③ 最近的推送结果:用户报「没收到」时,这里能当场看到是哪一步失败。 */
|
|
8905
|
+
var recent = Array.isArray(st.recent) ? st.recent.slice(-5).reverse() : [];
|
|
8906
|
+
if (recent.length) {
|
|
8907
|
+
body.appendChild(hEl('div', 'history__group', '最近的推送'));
|
|
8908
|
+
var rc = hEl('div', 'setcard');
|
|
8909
|
+
recent.forEach(function (r) {
|
|
8910
|
+
var rr = hEl('div', 'setrow');
|
|
8911
|
+
var col = hEl('span', 'setrow__l', (r.title || r.event || ''));
|
|
8912
|
+
col.style.display = 'flex'; col.style.flexDirection = 'column'; col.style.gap = '2px';
|
|
8913
|
+
/* 失败原因**一行写全**(不截断):用户来这一页就是为了看「为什么没收到」,
|
|
8914
|
+
截掉后半截等于没说。 */
|
|
8915
|
+
if (!r.ok && r.error) col.appendChild(hEl('span', '', '失败:' + r.error));
|
|
8916
|
+
rr.appendChild(col);
|
|
8917
|
+
var when = '';
|
|
8918
|
+
try { when = new Date(r.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch (e) {}
|
|
8919
|
+
rr.appendChild(elSpan('setrow__v', (r.ok ? '已发送 · ' : '') + when));
|
|
8920
|
+
rc.appendChild(rr);
|
|
8921
|
+
});
|
|
8922
|
+
body.appendChild(rc);
|
|
8923
|
+
}
|
|
8924
|
+
}).catch(function (e) {
|
|
8925
|
+
body.innerHTML = '';
|
|
8926
|
+
feishuSub(body, e.message || String(e), { error: true });
|
|
8927
|
+
});
|
|
8928
|
+
}
|
|
8929
|
+
var FEISHU_CODE = '';
|
|
8930
|
+
|
|
7191
8931
|
function openNotifySettings() {
|
|
7192
8932
|
openHostSheet('消息通知', function (body) {
|
|
7193
8933
|
var st = notifyPermState();
|
|
@@ -7202,12 +8942,36 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7202
8942
|
tip.style.display = 'block'; tip.style.margin = '2px 0 6px';
|
|
7203
8943
|
body.appendChild(tip);
|
|
7204
8944
|
}
|
|
7205
|
-
/* ②
|
|
8945
|
+
/* ② 推送去向(2026-10-26):系统通知只在本页开着时才会响,后台 / 锁屏收不到;
|
|
8946
|
+
飞书推送由桌面端发,人不在 App 里也收得到。两个通道并列给出来,各自的开关在
|
|
8947
|
+
自己的页面里(飞书 → 我的 → 飞书推送)。 */
|
|
8948
|
+
var fe = FEISHU_CACHE;
|
|
8949
|
+
var feTip = elSpan('pick__d', fe
|
|
8950
|
+
? ((fe.bound ? (fe.enabled ? '已开启' : '已绑定 · 已关闭') : '未绑定')
|
|
8951
|
+
+ (fe.configured ? '' : ' · 缺应用凭据')
|
|
8952
|
+
+ ' (人不在 App 里也能收到任务通知)')
|
|
8953
|
+
: '由桌面端推送到你的飞书(人不在 App 里也能收到)');
|
|
8954
|
+
feTip.style.display = 'block'; feTip.style.margin = '0 0 6px';
|
|
8955
|
+
body.appendChild(feTip);
|
|
8956
|
+
hRow(body, '飞书推送', '去管理 ›', function () { closeHostSheet(); showFeishuPage(); });
|
|
8957
|
+
/* ③ 通知内容开关:用户来这个页面的主要目的,放前面。
|
|
7206
8958
|
⚠️ 「定时任务通知」开关撤掉(2026-10-25 用户定调):定时任务功能本身不存在
|
|
7207
8959
|
(tab 已删、core 无调度 API,notify('sched') 0 处调用),开关是假承诺。
|
|
7208
8960
|
NOTIFY.sched 数据字段保留 —— 真做了定时任务时开关挂回来即可。 */
|
|
7209
8961
|
nToggle(body, '任务结果通知', NOTIFY.result, function (v) { NOTIFY.result = v; saveNotify(); });
|
|
7210
8962
|
nToggle(body, '任务待确认通知', NOTIFY.confirm, function (v) { NOTIFY.confirm = v; saveNotify(); });
|
|
8963
|
+
/* ④ 页面来源自证(2026-10-26 加)。
|
|
8964
|
+
「桌面改了页面、手机还是老样子」排查过两次:手机可能加载的是 **APK 内置的
|
|
8965
|
+
ui.html**(file://,打包时的旧副本),而不是桌面的 /m/。这种情况在桌面上
|
|
8966
|
+
怎么改都不会生效 —— 必须重打 APK。以前没有任何地方能看出这一点,
|
|
8967
|
+
只能靠猜。这里把来源直接写在通知设置页底部,一眼可辨:
|
|
8968
|
+
file: → App 内置页(旧);http(s): → 桌面远程页(改了立刻生效)。 */
|
|
8969
|
+
var srcLine = elSpan('pick__d', String(location.protocol) === 'file:'
|
|
8970
|
+
? '界面来源:App 内置页(随安装包固化,改页面需重打 APK)'
|
|
8971
|
+
: '界面来源:桌面远程页 ' + String(location.host || '') + '(改页面即时生效)');
|
|
8972
|
+
srcLine.style.display = 'block';
|
|
8973
|
+
srcLine.style.margin = '10px 0 2px';
|
|
8974
|
+
body.appendChild(srcLine);
|
|
7211
8975
|
/* 「发一条测试通知」「通道诊断」两个入口 2026-10-25 用户定调撤掉(通道已通,
|
|
7212
8976
|
正式 UI 不需要调试工具)。底层 notifyTest()/notifyDiagnose() 保留 ——
|
|
7213
8977
|
通道再出问题时把它们挂回任意入口就能当场自证。 */
|
|
@@ -7215,25 +8979,135 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7215
8979
|
}
|
|
7216
8980
|
|
|
7217
8981
|
/* ── 轮询会话状态跳变 → 发通知(不重绘列表,避免打扰浏览)── */
|
|
7218
|
-
var PREV_STATE = {},
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
8982
|
+
var PREV_STATE = {}, FIRST_POLL = true; // 定时器统一由 POLLER 管(2026-10-26 合并轮询)
|
|
8983
|
+
/* 通知轮询**必须**在「连上桌面」这件事上启动,而不是「手动点连接」时才启动。
|
|
8984
|
+
漏了这一点 = 通知功能看起来配好了、真机上永远不响(用户 2026-10-26 报障:
|
|
8985
|
+
「会话结果通知没对接吗 没收到系统通知」)。
|
|
8986
|
+
⚠️ 为什么以前会漏:startPoll() 原先只在 connect() 里调,而 connect() 只在
|
|
8987
|
+
「用户手填地址/扫码/点最近连接」时走;App 首屏启动走的是另一条路 ——
|
|
8988
|
+
直接给 BASE/TOKEN 赋值(注入的 / 恢复的上次环境 / 最近用过的),
|
|
8989
|
+
那三处**从不调 startPoll**。于是最常见的路径(App 启动即连上)轮询根本没跑,
|
|
8990
|
+
notify('result') 一次都不会触发。
|
|
8991
|
+
所以这里改成一个幂等的「连接已就绪」入口:谁把 BASE/TOKEN 落定,谁就调它。 */
|
|
8992
|
+
/* ⚠️ startPoll **不清基线**(幂等,只负责把消费者登记上):清基线交给 notifyFromList 里的
|
|
8993
|
+
端点比对,否则「resume 列表 → 打开会话 → 再 resume」几次调用就会把快照清三四遍,
|
|
8994
|
+
弹出一串假「任务完成」。
|
|
8995
|
+
⚠️ 2026-10-26 合并轮询:它**不再自挂定时器**,改为把通知登记成 tickPoller 的一个消费者。
|
|
8996
|
+
通知的 wants() 恒为真 —— 列表页不可见时也要判跳变,这是后台通知的唯一来源。 */
|
|
8997
|
+
function startPoll() {
|
|
8998
|
+
pollConsumers.notify = {
|
|
8999
|
+
wants: function () { return true; },
|
|
9000
|
+
jobs: function () { return false; }, // 通知不看作业队列
|
|
9001
|
+
run: function (list) { notifyFromList(list); },
|
|
9002
|
+
};
|
|
9003
|
+
startPoller();
|
|
9004
|
+
}
|
|
9005
|
+
function stopPoll() {
|
|
9006
|
+
delete pollConsumers.notify;
|
|
9007
|
+
if (POLLER) { clearInterval(POLLER); POLLER = null; }
|
|
9008
|
+
POLL_EP = null; resetPollBaseline();
|
|
9009
|
+
}
|
|
9010
|
+
/* 连接落定后统一入口:启动通知轮询。
|
|
9011
|
+
幂等(重复登记同一个消费者,后写覆盖前写;tickPoller 也只有一个),所以多处调用安全。
|
|
9012
|
+
切环境时先 stopPoll 再由整页重载后的新页面重新起 —— 见 connect() 的 switchEnv 分支。 */
|
|
9013
|
+
function ensureNotifyPoll() {
|
|
7222
9014
|
if (!BASE && !TOKEN) return;
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
9015
|
+
startPoll();
|
|
9016
|
+
}
|
|
9017
|
+
/* 会话状态缓存**跨轮询保留、只存**;页面生命周期 / 连接落定 / 最近用过的环境变了才重置。
|
|
9018
|
+
以前这两件事一起挂在 startPoll 上(每次 ensureNotifyPoll → PREV_STATE={} + FIRST_POLL=true),
|
|
9019
|
+
而 ensureNotifyPoll 会在连接落定 / 恢复上次环境 / 列表页重新出现时被调用 —— 于是快照在
|
|
9020
|
+
「清空 → 记下 running → 又清空」之间来回抖:轮询恢复后 FIRST_POLL 那一轮把所有会话重新
|
|
9021
|
+
当成「首次见到」,下一轮 round 状态一变就弹「任务完成」,一屏会话能连弹好几条。
|
|
9022
|
+
现在只有端点真的变了、或轮询被停过(disconnect)才清基线。 */
|
|
9023
|
+
var POLL_EP = null;
|
|
9024
|
+
function pollEndpoint() { return String(BASE || '') + '|' + String(TOKEN || ''); }
|
|
9025
|
+
function resetPollBaseline() { PREV_STATE = {}; FIRST_POLL = true; }
|
|
9026
|
+
/* notifyFromList 只吃**已经拿到的**清单,判状态跳变并弹通知 / 推飞书。
|
|
9027
|
+
拆出来的原因(2026-10-26 合并轮询):列表轮询与通知轮询原先各自拉一次 /desk/sessions,
|
|
9028
|
+
现在同一次 tick 的清单要**同时**喂给这两个消费者 —— 取数只有一份(pollSessionsShared),
|
|
9029
|
+
判跳变留在这里,两边都不必再为了拿数据而多发一个请求。
|
|
9030
|
+
list 为 null(拉失败 / 旧服务端回非数组)时什么都不做,也不清基线。 */
|
|
9031
|
+
function notifyFromList(list) {
|
|
9032
|
+
if (!Array.isArray(list)) return;
|
|
9033
|
+
var ep = pollEndpoint();
|
|
9034
|
+
if (ep !== POLL_EP) { POLL_EP = ep; resetPollBaseline(); }
|
|
9035
|
+
/* 这一份就是**最新**登记表:顺手把宿主下发后卡在「等待中」的条目对一次账
|
|
9036
|
+
(见 reconcileQueued)—— 不为此多打一发请求。 */
|
|
9037
|
+
try { reconcileQueued(list); } catch (e) {}
|
|
9038
|
+
list.forEach(function (s) {
|
|
9039
|
+
/* 通知按**会话级** status 判(同一条会话各轮一个记录,只看 state 会拿旧轮误报)。 */
|
|
9040
|
+
var id = sidOf(s), st = String((s.status || s.state) || ''), prev = PREV_STATE[id];
|
|
9041
|
+
if (!FIRST_POLL && prev !== undefined && prev !== st) {
|
|
9042
|
+
/* 通知只认「我们在跟踪时变完成的」:一条会话在本页里既没见过的 done(prev 为 undefined)
|
|
9043
|
+
是历史遗留,不弹;running → done 才弹。 */
|
|
9044
|
+
if (st === 'done' && prev === 'running') notify('result', '任务完成', titleOf(s));
|
|
9045
|
+
else if (st === 'waiting_approval') notify('confirm', '任务待确认', titleOf(s));
|
|
9046
|
+
/* 同一次跳变**顺带**推一条到飞书(2026-10-26):系统通知只在 App 开着时响,
|
|
9047
|
+
飞书那条覆盖「人在别的页面 / 锁屏」。按 ask_id 交给桌面端去重 ——
|
|
9048
|
+
多台手机、多个 tab 各自轮询时,同一轮只会推一次(见 feishu.go push)。 */
|
|
9049
|
+
if ((st === 'done' && prev === 'running') || st === 'waiting_approval') {
|
|
9050
|
+
var kind = st === 'waiting_approval' ? 'confirm' : 'result';
|
|
9051
|
+
if (NOTIFY[kind] !== false) pushFeishu(kind, s);
|
|
9052
|
+
}
|
|
7232
9053
|
}
|
|
7233
9054
|
PREV_STATE[id] = st;
|
|
7234
9055
|
});
|
|
7235
9056
|
FIRST_POLL = false;
|
|
7236
|
-
|
|
9057
|
+
}
|
|
9058
|
+
/* pollOnce 一次 tick:拉清单 → 交给 notifyFromList 判跳变。
|
|
9059
|
+
⚠️ 它**只服务通知**(列表页不可见时也要跑,通知是后台能力)。列表页可见时,
|
|
9060
|
+
清单由 startListPoll 那一拍统一取(见 pollSessionsShared)—— 两条 tick 会在
|
|
9061
|
+
同一秒各拉一次,所以下面 tickOnce 加了一把「同一拍复用」的短门:
|
|
9062
|
+
500ms 内已经拉过的清单直接复用,不再发请求。 */
|
|
9063
|
+
var LAST_FETCH = { at: 0, list: null };
|
|
9064
|
+
function pollOnce() {
|
|
9065
|
+
if (!BASE && !TOKEN) return;
|
|
9066
|
+
/* 同一拍复用:列表轮询刚拉过(<500ms)就用它那份,别为通知再发一个请求。
|
|
9067
|
+
这个窗口比两次 tick 的间隔(5s)小两个数量级,不会掩盖真实的状态变化。 */
|
|
9068
|
+
if (LAST_FETCH.list && Date.now() - LAST_FETCH.at < 500) {
|
|
9069
|
+
notifyFromList(LAST_FETCH.list);
|
|
9070
|
+
return;
|
|
9071
|
+
}
|
|
9072
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
9073
|
+
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
9074
|
+
.then(function (r) { return r.ok ? r.json() : []; })
|
|
9075
|
+
.then(function (list) {
|
|
9076
|
+
if (Array.isArray(list)) { LAST_FETCH = { at: Date.now(), list: list }; }
|
|
9077
|
+
notifyFromList(list);
|
|
9078
|
+
})
|
|
9079
|
+
.catch(function () { notifyWarningOnce(); });
|
|
9080
|
+
}
|
|
9081
|
+
/* 过了这个点(首次在飞书里绑上 / 上次成功之后)的跳变才推:避免刚打开 App 就把
|
|
9082
|
+
几小时前就跑完的会话一股脑推到飞书(那时用户已经不在手机上看了)。
|
|
9083
|
+
⚠️ 这是**客户端侧的礼貌过滤**,不是正确性保证;正确性靠桌面端的幂等键。 */
|
|
9084
|
+
var FEISHU_NOTIFY_AFTER = 0;
|
|
9085
|
+
function pushFeishu(event, s) {
|
|
9086
|
+
if (!BASE && !TOKEN) return;
|
|
9087
|
+
/* 飞书开关没开时桌面端会回 {disabled:true},200 + 无副作用;这里不必先查状态,
|
|
9088
|
+
省一次往返(状态可能是几分钟前的缓存)。 */
|
|
9089
|
+
var askId = s.status_ask_id || s.ask_id || s.request_id || '';
|
|
9090
|
+
if (!askId) return; // 没有这一轮的身份就没法去重 —— 宁可不推,也不刷屏
|
|
9091
|
+
var at = s.updated_at ? Date.parse(s.updated_at) : NaN;
|
|
9092
|
+
if (isFinite(at) && FEISHU_NOTIFY_AFTER && at < FEISHU_NOTIFY_AFTER) return;
|
|
9093
|
+
var title = titleOf(s);
|
|
9094
|
+
feishuApi('POST', 'push', {
|
|
9095
|
+
event: event, ask_id: askId, session_id: sidOf(s) || '',
|
|
9096
|
+
title: title === sidOf(s) ? '' : title,
|
|
9097
|
+
/* 手机最清楚自己现在是怎么连上的(局域网 / 隧道 / 域名):卡片里的
|
|
9098
|
+
「打开」按钮就用它拼,服务端有公网地址时优先用自己的。 */
|
|
9099
|
+
url: connUrlNow()
|
|
9100
|
+
}).catch(function (e) {
|
|
9101
|
+
/* 推送失败不打扰用户(系统通知已经弹了);只记一笔,排查时看控制台。 */
|
|
9102
|
+
if (window.console && console.warn) console.warn('[feishu] 推送失败:', e && e.message ? e.message : e);
|
|
9103
|
+
});
|
|
9104
|
+
}
|
|
9105
|
+
/* 轮询失败警告只弹一次(传输层断了以前直接吞掉,用户只看到「一直没通知」)。 */
|
|
9106
|
+
var POLL_WARNED = false;
|
|
9107
|
+
function notifyWarningOnce() {
|
|
9108
|
+
if (POLL_WARNED) return;
|
|
9109
|
+
POLL_WARNED = true;
|
|
9110
|
+
if (window.console && console.warn) console.warn('[notify] /desk/sessions 拉取失败,状态通知将暂停');
|
|
7237
9111
|
}
|
|
7238
9112
|
|
|
7239
9113
|
/* 思考模式选择器:写**当前这条会话**的档位(默认最高)。 */
|
|
@@ -7272,32 +9146,37 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7272
9146
|
hRow(body, '默认(不属于任何空间)', null, function () { WS = ''; saveWs(); applyWs(); closeHostSheet(); paintRunSettings(); }, false, !WS);
|
|
7273
9147
|
keys.forEach(function (w) {
|
|
7274
9148
|
var it = customSpaceOf(w);
|
|
7275
|
-
var isCustom = !!it;
|
|
7276
9149
|
/* 打勾要比**空间键**而不是原字符串:WS 可能是用户手输的「D:/work/proj/」,
|
|
7277
9150
|
列表里这一行却是服务端登记的「D:\work\proj」—— 字面不等,勾就打不上,
|
|
7278
9151
|
用户会以为「点了没生效」(2026-10-21 报「无法选择默认」是同一类毛病)。 */
|
|
7279
|
-
hRow(body, spaceLabel(w),
|
|
7280
|
-
/*
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
9152
|
+
hRow(body, spaceLabel(w), it && it.alias ? '自定义' : null, function () { WS = w; saveWs(); applyWs(); closeHostSheet(); paintRunSettings(); }, false, !!WS && spaceKey(WS) === spaceKey(w),
|
|
9153
|
+
/* ⚠️ 每行都带「✎」「✕」(2026-10-26,用户:「空间都可以修改和删除」):
|
|
9154
|
+
以前只有「自定义空间」那一类有按钮,根目录下真有的 magic-* 目录、
|
|
9155
|
+
会话跑出来的空间既改不了名也删不掉 —— 用户看到列表里有一项却动不了它。
|
|
9156
|
+
现在三类来源统一登记到「空间登记册」(见 normSpaces),动作对所有行一致。
|
|
9157
|
+
行本身仍是「选中这个空间」(动作是行内的 span,不是按钮 —— 行是 <button>,
|
|
9158
|
+
里面再塞按钮 HTML 不允许)。 */
|
|
9159
|
+
[
|
|
9160
|
+
{ label: '✎', title: '重命名', onClick: function () { openSpaceAliasSheet(w); } },
|
|
9161
|
+
{ label: '✕', title: '删除空间', onClick: function () { openSpaceDeleteSheet(w); } }
|
|
9162
|
+
]);
|
|
7287
9163
|
});
|
|
7288
9164
|
hRow(body, '自定义路径…', '+', openCustomPathSheet);
|
|
7289
9165
|
});
|
|
7290
9166
|
}
|
|
7291
|
-
/*
|
|
7292
|
-
留空 = 回到「最后一级文件夹名」(见 spaceLabel)。
|
|
7293
|
-
|
|
9167
|
+
/* 空间改名:给目录起一个**显示名**,之后各处显示它而不是路径。
|
|
9168
|
+
留空 = 回到「最后一级文件夹名」(见 spaceLabel)。
|
|
9169
|
+
⚠️ 传进来的是**路径**而不是登记项:根目录目录与会话空间原先不在册子里,
|
|
9170
|
+
改名时现登记一条(ensureSpaceEntry)—— 同一个目录只登记一次。 */
|
|
9171
|
+
function openSpaceAliasSheet(path) {
|
|
9172
|
+
var it = ensureSpaceEntry(path);
|
|
7294
9173
|
openTextInputSheet({
|
|
7295
9174
|
title: '空间别名',
|
|
7296
|
-
value:
|
|
9175
|
+
value: it.alias || '',
|
|
7297
9176
|
placeholder: '留空 = 用文件夹名',
|
|
7298
9177
|
allowEmpty: true
|
|
7299
9178
|
}, function (alias) {
|
|
7300
|
-
|
|
9179
|
+
it.alias = String(alias || '').trim();
|
|
7301
9180
|
saveSpaces();
|
|
7302
9181
|
/* 别名一改,所有显示它的地方都要跟着变:运行设置页 / 空间选择器 / 任务页分组头。 */
|
|
7303
9182
|
paintRunSettings();
|
|
@@ -7305,12 +9184,17 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7305
9184
|
openWorkspacePicker(); // 回到选择器,让用户当场看见新名字
|
|
7306
9185
|
});
|
|
7307
9186
|
}
|
|
7308
|
-
/*
|
|
9187
|
+
/* 删除空间:二次确认后把它从空间列表里移除(**只删登记,不删目录** —— 磁盘上的文件
|
|
7309
9188
|
原样不动,用户的代码/文档不能因为一个 UI 操作消失)。当前生效的 WS 正是它时
|
|
7310
|
-
回落「默认」,并当场把对话组件的 workspace 也清掉(否则下一轮还会带着已删空间发)。
|
|
7311
|
-
|
|
9189
|
+
回落「默认」,并当场把对话组件的 workspace 也清掉(否则下一轮还会带着已删空间发)。
|
|
9190
|
+
⚠️ 移除的实现是**打 hidden 标记**而不是从册子里删条目:
|
|
9191
|
+
册子里的 alias 得留着(用户下次把这个目录加回来,别名还在),
|
|
9192
|
+
而且根目录目录 / 会话空间本来就没有「条目」可删 —— 它们是磁盘与会话里客观存在的,
|
|
9193
|
+
只能「不再作为空间出现」。 */
|
|
9194
|
+
function openSpaceDeleteSheet(path) {
|
|
9195
|
+
var label = spaceLabel(path);
|
|
7312
9196
|
openHostSheet('删除空间', function (body) {
|
|
7313
|
-
var tip = hEl('div', 'pick__d', '从空间列表移除「' +
|
|
9197
|
+
var tip = hEl('div', 'pick__d', '从空间列表移除「' + label + '」?目录里的文件不会被删除。');
|
|
7314
9198
|
tip.style.textAlign = 'left';
|
|
7315
9199
|
tip.style.lineHeight = '1.6';
|
|
7316
9200
|
body.appendChild(tip);
|
|
@@ -7322,10 +9206,10 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7322
9206
|
del.style.color = 'var(--error-surface)';
|
|
7323
9207
|
body.appendChild(del);
|
|
7324
9208
|
del.onclick = function () {
|
|
7325
|
-
var
|
|
7326
|
-
|
|
9209
|
+
var it = ensureSpaceEntry(path);
|
|
9210
|
+
it.hidden = true;
|
|
7327
9211
|
saveSpaces();
|
|
7328
|
-
if (spaceKey(WS) ===
|
|
9212
|
+
if (spaceKey(WS) === spaceKey(path)) { WS = ''; saveWs(); applyWs(); }
|
|
7329
9213
|
closeHostSheet();
|
|
7330
9214
|
paintRunSettings();
|
|
7331
9215
|
renderSessions(LAST_SESSIONS);
|
|
@@ -7391,10 +9275,13 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7391
9275
|
}
|
|
7392
9276
|
function paintSuggestPref() {
|
|
7393
9277
|
if (!document.getElementById('suggestEngine')) return;
|
|
7394
|
-
var main = SUGGEST.engine
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
9278
|
+
var main = SUGGEST.engine ? SUGGEST.engine : '不开启';
|
|
9279
|
+
paintEnginePair(document.getElementById('suggestEngine'), main,
|
|
9280
|
+
fallbackLine(SUGGEST.fbEngine, SUGGEST.fbModel),
|
|
9281
|
+
SUGGEST.engine ? modelInlineText(SUGGEST.model, SUGGEST.engine) : '');
|
|
9282
|
+
var mEl = document.getElementById('suggestModel');
|
|
9283
|
+
/* 没开启建议引擎时这一格留空:写「—」只是多一个占位符号(2026-10-26 简约口径)。 */
|
|
9284
|
+
if (mEl) mEl.textContent = SUGGEST.engine ? (SUGGEST.model ? qaModelText(SUGGEST.model, SUGGEST.engine) : '跟随引擎') : '';
|
|
7398
9285
|
}
|
|
7399
9286
|
function applySuggestToLive() {
|
|
7400
9287
|
if (conv && typeof conv.setSuggestEngine === 'function') {
|
|
@@ -7429,10 +9316,17 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7429
9316
|
function paintRunPref() {
|
|
7430
9317
|
if (!document.getElementById('defaultEngine')) return;
|
|
7431
9318
|
/* 不再有「自动 / 引擎默认」这两档可选项:没选模型就只显示引擎名,空值写成「未设置」。 */
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
paintEnginePair(document.getElementById('defaultEngine'), main,
|
|
9319
|
+
/* 引擎格只放引擎名(主一行、备一行);具体模型在下一行「默认模型」里。 */
|
|
9320
|
+
var main = RUN_PREF.engine ? RUN_PREF.engine : '自动(首个可用)';
|
|
9321
|
+
/* 模型与引擎**同一行**:主行画成「引擎 · 模型」;没选模型只画引擎名。 */
|
|
9322
|
+
paintEnginePair(document.getElementById('defaultEngine'), main,
|
|
9323
|
+
fallbackLine(RUN_PREF.fbEngine, RUN_PREF.fbModel), modelInlineText(RUN_PREF.model, RUN_PREF.engine));
|
|
9324
|
+
var mEl = document.getElementById('defaultModel');
|
|
9325
|
+
if (mEl) mEl.textContent = RUN_PREF.model ? qaModelText(RUN_PREF.model, RUN_PREF.engine) : '跟随引擎';
|
|
9326
|
+
}
|
|
9327
|
+
/* 同一格里「引擎 · 模型」里的模型那半截;没选模型 → 空串(不写「跟随引擎」占位)。 */
|
|
9328
|
+
function modelInlineText(model, engine) {
|
|
9329
|
+
return model ? qaModelText(model, engine) : '';
|
|
7436
9330
|
}
|
|
7437
9331
|
/* 备用那一行(第二行,小一档):没配就空串 = 不画。
|
|
7438
9332
|
⚠️ 不要和主引擎挤在同一行 —— `setrow__v` 是单行省略,长组合会被截成
|
|
@@ -7441,10 +9335,18 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7441
9335
|
if (!engine) return '';
|
|
7442
9336
|
return '备 ' + engine + (model ? ' · ' + qaModelText(model, engine) : '');
|
|
7443
9337
|
}
|
|
7444
|
-
|
|
9338
|
+
/* modelText 非空时主行画成「引擎 · 模型」(同一行)。只有「我的」那两行传它;
|
|
9339
|
+
团队配置里仍只画引擎名(那边模型写在下面一行的规则里),所以做成可选参数。 */
|
|
9340
|
+
function paintEnginePair(el, mainText, fbText, modelText) {
|
|
7445
9341
|
if (!el) return;
|
|
7446
9342
|
el.innerHTML = '';
|
|
7447
|
-
|
|
9343
|
+
var mainEl = hEl('span', 'setrow__pair-main');
|
|
9344
|
+
mainEl.appendChild(document.createTextNode(mainText));
|
|
9345
|
+
if (modelText) {
|
|
9346
|
+
mainEl.appendChild(hEl('span', 'engmodel__mid', '·'));
|
|
9347
|
+
mainEl.appendChild(hEl('span', 'engmodel__model', modelText));
|
|
9348
|
+
}
|
|
9349
|
+
el.appendChild(mainEl);
|
|
7448
9350
|
if (fbText) el.appendChild(hEl('span', 'setrow__pair-fb', fbText));
|
|
7449
9351
|
}
|
|
7450
9352
|
|
|
@@ -7879,12 +9781,33 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7879
9781
|
}
|
|
7880
9782
|
/* 进选择器的引擎:探测成功、**已登录**且**能列出模型**。没登录 / 取不到清单的
|
|
7881
9783
|
(如 trae、登出后的 codebuddy-ai)不出现 —— 「登录了几个账号就显示几个」
|
|
7882
|
-
(2026-10-25 用户要求)。
|
|
9784
|
+
(2026-10-25 用户要求)。
|
|
9785
|
+
⚠️「取不到清单」分两种(core --engines 的 models_source):
|
|
9786
|
+
none = 引擎本身不提供模型选择(arkclaw 按 claw_id 绑模型、codebuddy-gateway
|
|
9787
|
+
用网关进程的模型)→ 不是故障,保留(右列显示「引擎默认」);
|
|
9788
|
+
failed = 有来源但探测失败(CLI 非 0 退出)→ 隐藏。
|
|
9789
|
+
老 core 没该字段时按 ErrNoModelSource 的固定文案前缀兜底。 */
|
|
9790
|
+
function modelsSourceOf(en) {
|
|
9791
|
+
return en.models_source || en.modelsSource || '';
|
|
9792
|
+
}
|
|
9793
|
+
/* 「没有模型可选」的原因,翻成人话(core 的 models_note 是英文诊断串)。
|
|
9794
|
+
arkclaw / MagicAI 的模型绑在网关的 claw_id 上,codebuddy-gateway 的绑在网关进程上 ——
|
|
9795
|
+
都不是"选不了模型就不可用"。 */
|
|
9796
|
+
function noModelChoiceHint(en) {
|
|
9797
|
+
var note = String(en.models_note || en.modelsNote || '');
|
|
9798
|
+
if (/claw_id/i.test(note)) return '模型由网关的 claw_id 决定';
|
|
9799
|
+
if (/gateway/i.test(note)) return '模型由网关进程决定';
|
|
9800
|
+
return '不指定模型,由引擎自己挑';
|
|
9801
|
+
}
|
|
7883
9802
|
function pickableEngines(list) {
|
|
7884
9803
|
return (list || []).filter(function (en) {
|
|
7885
9804
|
if (!en || !en.ok) return false;
|
|
7886
9805
|
if (en.logged_in === false) return false;
|
|
7887
|
-
|
|
9806
|
+
var src = modelsSourceOf(en);
|
|
9807
|
+
if (src === 'none') return true;
|
|
9808
|
+
if (src === 'failed') return false;
|
|
9809
|
+
if (/^no dynamic model source/.test(en.models_note || en.modelsNote || '')) return true;
|
|
9810
|
+
return (en.models || []).length > 0 || !(en.models_note || en.modelsNote);
|
|
7888
9811
|
});
|
|
7889
9812
|
}
|
|
7890
9813
|
/* 引擎行副标题:有模型报数量,否则「引擎默认」;多账号引擎前面带上登录的账号昵称
|
|
@@ -7964,7 +9887,13 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
7964
9887
|
var stale = enginesStaleHint();
|
|
7965
9888
|
if (stale) body.appendChild(stale);
|
|
7966
9889
|
var avail = pickableEngines(list);
|
|
7967
|
-
|
|
9890
|
+
/* 先按引擎名排;cfg.sort 只做「置顶」(返回 -1/1 才生效,0 保持字母序)。
|
|
9891
|
+
2026-10-26 用户:引擎显示乱序,要按字母顺序 —— 探测结果按安装顺序返回,不排就是乱的。 */
|
|
9892
|
+
avail.sort(function (a, b) {
|
|
9893
|
+
var r = cfg.sort ? cfg.sort(a, b) : 0;
|
|
9894
|
+
if (r) return r;
|
|
9895
|
+
return String(a.engine).localeCompare(String(b.engine), undefined, { sensitivity: 'base' });
|
|
9896
|
+
});
|
|
7968
9897
|
if (!avail.length) {
|
|
7969
9898
|
var d = hEl('div', 'history__hint',
|
|
7970
9899
|
'没有可用引擎:先在这台机器上装一个(终端跑 magic-agent --engines 会给出安装命令)。');
|
|
@@ -8094,7 +10023,6 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8094
10023
|
var offLabel = o.offLabel || '自动';
|
|
8095
10024
|
var offDesc = o.offDesc || '首个可用引擎';
|
|
8096
10025
|
var modelDefaultDesc = o.modelDefaultDesc || '不指定模型,由引擎自己挑';
|
|
8097
|
-
|
|
8098
10026
|
var wrap = hEl('div', 'pick__cols');
|
|
8099
10027
|
var left = hEl('div', 'pick__col');
|
|
8100
10028
|
var right = hEl('div', 'pick__col pick__col--r');
|
|
@@ -8149,7 +10077,8 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8149
10077
|
var d = hEl('button', 'pick__citem' + (prefs.engine === cur.engine && !prefs.model ? ' is-on' : ''));
|
|
8150
10078
|
d.type = 'button';
|
|
8151
10079
|
d.appendChild(hEl('span', 'pick__cl', '引擎默认'));
|
|
8152
|
-
d.appendChild(hEl('span', 'pick__cd',
|
|
10080
|
+
d.appendChild(hEl('span', 'pick__cd',
|
|
10081
|
+
modelsSourceOf(cur) === 'none' ? noModelChoiceHint(cur) : modelDefaultDesc));
|
|
8153
10082
|
d.onclick = function () { pick(cur.engine, ''); };
|
|
8154
10083
|
right.appendChild(d);
|
|
8155
10084
|
}
|
|
@@ -8232,68 +10161,383 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8232
10161
|
链路(见 internal/client/restart.go):POST /desk/restart → 桌面端**先立标志、
|
|
8233
10162
|
先答 200**(重复点击答 already_pending,不会再起第二个助手),1 秒后由**进程内
|
|
8234
10163
|
定时器**拉起一个脱离终端的 `magic-client restart` 助手停旧实例、按状态文件里的
|
|
8235
|
-
|
|
10164
|
+
参数拉起新实例。
|
|
8236
10165
|
|
|
8237
|
-
⚠️
|
|
10166
|
+
⚠️ 会话是**这个实例**起的子进程:重启会中断它们(状态变「中断」,重开会话可继续)。
|
|
10167
|
+
**不影响**同机别的客户端实例 —— 每台实例只停自己起的会话,判据是那条会话自己的
|
|
10168
|
+
pid 还在不在(见 internal/session/session.go 的 gone 判定),不是「客户端重启了没」。
|
|
10169
|
+
所以这里只说「当前连接的这套服务」,不要把「桌面端所有会话」都说进去(2026-10-26
|
|
10170
|
+
用户指出:桌面端还开着另一个客户端时,这句提示与事实不符)。
|
|
8238
10171
|
⚠️ 判断「好了没」:旧实例认账时 /desk/health 会带 restarting=true(它马上要下线,
|
|
8239
|
-
可当成「看到下线」),再探到**不带** restarting 的 200 才算新实例起来了。
|
|
10172
|
+
可当成「看到下线」),再探到**不带** restarting 的 200 才算新实例起来了。
|
|
10173
|
+
⚠️ 地址 / 令牌可能变(换网、换端口、令牌重发):见 followRestartMoved。
|
|
10174
|
+
⚠️⚠️ 只见「200 回来了」还不够(2026-10-27 用户:「点了手机端重启 好像没效果」):
|
|
10175
|
+
重启换的是**进程**,会话 / 任务 / 设置全在盘上,界面重启前后长得一模一样 ——
|
|
10176
|
+
探活一回来就弹「桌面端已重启」,用户看到列表没变,只会判「没效果」。
|
|
10177
|
+
所以必须拿**换代的硬证据**:health 里的 `pid` / `startedAt`(2026-10-27 起服务端才给)。
|
|
10178
|
+
没给(老插件)就如实说「已完成重启请求」,不要暗示用户去看什么变化。 */
|
|
8240
10179
|
function restartClient() {
|
|
8241
10180
|
if (!TOKEN && !BASE) { toast('还没连接桌面'); return; }
|
|
8242
|
-
if (!window.confirm('
|
|
10181
|
+
if (!window.confirm('重启这套桌面端服务?\n\n只会中断**它自己起的**会话(状态变「中断」,重新打开会话可继续);\n同机其它客户端实例不受影响。')) return;
|
|
8243
10182
|
var btn = document.getElementById('btnRestartClient');
|
|
8244
10183
|
var val = document.getElementById('restartClientState');
|
|
8245
10184
|
if (btn) btn.disabled = true;
|
|
8246
10185
|
if (val) val.textContent = '重启中…';
|
|
8247
|
-
|
|
10186
|
+
/* 重启前的身份指纹:用来证明「换了一代」。拿不到不影响重启,只是没法给出硬证据。
|
|
10187
|
+
⚠️ 必须在 POST **之前**读 —— 重启很快(3~7 秒),POST 之后旧实例可能已经没了。 */
|
|
10188
|
+
var before = { pid: null, startedAt: null };
|
|
10189
|
+
var beforeReady = Promise.resolve();
|
|
10190
|
+
try {
|
|
10191
|
+
beforeReady = fetch(apiOrigin() + '/desk/health', {
|
|
10192
|
+
headers: apiToken() ? { 'X-Magic-Token': apiToken() } : {}, cache: 'no-store'
|
|
10193
|
+
})
|
|
10194
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
10195
|
+
.then(function (d) {
|
|
10196
|
+
if (!d) return;
|
|
10197
|
+
if (typeof d.pid === 'number') before.pid = d.pid;
|
|
10198
|
+
if (d.startedAt) before.startedAt = d.startedAt;
|
|
10199
|
+
})
|
|
10200
|
+
.catch(function () { /* 读不到就当没有指纹,不影响重启本身 */ });
|
|
10201
|
+
} catch (e) { /* 极老的 WebView 没有 Promise 兜底:直接跳过指纹 */ }
|
|
10202
|
+
/* 重启会比对令牌 —— 必须带「这个地址此刻认的那把」:同源页的令牌可能只注入在
|
|
10203
|
+
BASE 为空那一侧(见 apiToken),这里先让两条来源都试一下。 */
|
|
10204
|
+
var hdr = apiToken() ? { 'X-Magic-Token': apiToken() } : {};
|
|
10205
|
+
if (TOKEN && injectedToken && TOKEN !== injectedToken && BASE === '') hdr['X-Magic-Token'] = TOKEN;
|
|
8248
10206
|
var done = function (label, msg) {
|
|
8249
10207
|
if (val) val.textContent = label;
|
|
8250
10208
|
if (btn) btn.disabled = false;
|
|
8251
10209
|
if (msg) toast(msg);
|
|
8252
10210
|
};
|
|
8253
|
-
|
|
10211
|
+
beforeReady
|
|
10212
|
+
.then(function () { return fetch(apiOrigin() + '/desk/restart', { method: 'POST', headers: hdr }); })
|
|
8254
10213
|
.then(function (r) {
|
|
8255
10214
|
if (r.ok) return null;
|
|
10215
|
+
/* 404/405 = 桌面端版本过旧,没有这条路由;其余非 2xx = 服务端**明确拒绝**
|
|
10216
|
+
(典型 409:这套实例没有状态记录,压根不支持自重启)。
|
|
10217
|
+
两种情况都**没有**发出任何重启 —— 必须与「发出去了但连不上」分开说,
|
|
10218
|
+
否则用户会对着「重启中…」白等,也不知道该去桌面敲哪条命令。 */
|
|
8256
10219
|
return r.json().catch(function () { return {}; }).then(function (d) {
|
|
8257
|
-
|
|
10220
|
+
var e = new Error((d && d.error) || ('HTTP ' + r.status));
|
|
10221
|
+
e.refused = true; // 被拒绝:没发出、不必等探活
|
|
10222
|
+
e.status = r.status;
|
|
10223
|
+
throw e;
|
|
8258
10224
|
});
|
|
8259
10225
|
})
|
|
8260
|
-
.then(function () { return waitRestartBack(); })
|
|
8261
|
-
.then(function () {
|
|
8262
|
-
|
|
10226
|
+
.then(function () { return waitRestartBack(before); })
|
|
10227
|
+
.then(function (back) {
|
|
10228
|
+
back = back || {};
|
|
10229
|
+
/* 新地址 + 新令牌(见 followRestartMoved):连上了再换入口,
|
|
10230
|
+
换完整页重载 —— 会话轮询、通知、排队都绑定在 BASE/TOKEN 上,就地改会分叉。 */
|
|
10231
|
+
if (back.follow) {
|
|
10232
|
+
followRestartMoved(back.url, back.token || '', { btn: btn, val: val });
|
|
10233
|
+
return;
|
|
10234
|
+
}
|
|
10235
|
+
/* 「已重启」这句话必须有证据才说(2026-10-27 用户「好像没效果」的根因):
|
|
10236
|
+
进程换了、会话与任务却一个不少(全在盘上),界面看起来毫无变化。
|
|
10237
|
+
拿 pid / startedAt 对比,把变化摆出来;拿不到就说清楚「没有可核对的证据」。 */
|
|
10238
|
+
if (back.changed) {
|
|
10239
|
+
var what = back.pidChanged && back.pidBefore
|
|
10240
|
+
? '(pid ' + back.pidBefore + ' → ' + back.pidAfter + ')'
|
|
10241
|
+
: '';
|
|
10242
|
+
done('已重启', '桌面端已重启' + what + ';会话与任务都在盘上,不受影响');
|
|
10243
|
+
} else if (back.pidAfter === null && back.pidBefore === null) {
|
|
10244
|
+
/* 老插件没有 pid 字段:不编造「已换新」的证据。 */
|
|
10245
|
+
done('重启', '重启请求已完成;这个桌面端版本不提供 pid,无法核对是否换代,可看桌面日志确认');
|
|
10246
|
+
} else {
|
|
10247
|
+
done('重启', '重启请求已完成,但没检测到换代:pid 仍是 ' + (back.pidAfter || '?') + ',可能重启没生效');
|
|
10248
|
+
}
|
|
8263
10249
|
loadSessions(true); // 新实例的会话表(状态已按重启后的实况归一过)
|
|
8264
10250
|
})
|
|
8265
10251
|
.catch(function (e) {
|
|
8266
|
-
|
|
10252
|
+
var msg = (e && e.message) ? e.message : String(e);
|
|
10253
|
+
/* 服务端明确拒绝(409 等):按钮如实回到「重启」,并把拒绝理由原样给出来。
|
|
10254
|
+
那句话本身就是「该怎么自救」,不要再包装成「重启已发出」——
|
|
10255
|
+
过去的文案把这类拒绝渲染成「已发出但连不上」,用户会一直等一个永不会来的重启。 */
|
|
10256
|
+
if (e && e.refused) {
|
|
10257
|
+
done('重启', msg);
|
|
10258
|
+
console.warn('[restart] 服务端拒绝:', msg);
|
|
10259
|
+
return;
|
|
10260
|
+
}
|
|
10261
|
+
/* 探活等不到新实例,但旧实例死前交代过「新实例在哪」:旧地址已经报废,
|
|
10262
|
+
这不是失败,是地址变了(见 restartHandoff / followRestartMoved)。 */
|
|
10263
|
+
if (e && e.handoff && e.handoff.urls && e.handoff.urls.length) {
|
|
10264
|
+
followRestartMoved(e.handoff.urls[0], e.handoff.token || '', { btn: btn, val: val });
|
|
10265
|
+
return;
|
|
10266
|
+
}
|
|
10267
|
+
/* 旧地址连不上、也没拿到交接:住址多半换了,但也可能只是重启失败。
|
|
10268
|
+
别让用户对着「连不上」干等 —— 给一句能照着做的话(2026-10-25)。 */
|
|
10269
|
+
if (e && e.moved) msg += ';旧地址可能已换(IP / 端口变了),请确认电脑当前的地址,或在环境里重新扫码';
|
|
10270
|
+
else msg = '重启已发出,但暂时连不上桌面端:' + msg + '(可能是还没起来、或地址变了;稍后会自动重试)';
|
|
10271
|
+
done('重启', msg);
|
|
10272
|
+
console.warn('[restart]', msg, e && e.stack); // 排障留痕(控制台)
|
|
10273
|
+
|
|
8267
10274
|
});
|
|
8268
10275
|
}
|
|
8269
10276
|
/* 等桌面端**真的换了一代**:旧实例认账(health.restarting=true)或连不上都算「看到
|
|
8270
10277
|
下线」,再看到 200(不带 restarting 标志)才算新实例起来。60 秒等不到就当失败 ——
|
|
8271
10278
|
实测正常 3-7 秒;但客户端与 watchdog 抢着拉起时见过 30 秒以上(两个实例抢端口,
|
|
8272
|
-
输的那个要等 ready 超时),别让按钮永远停在「重启中…」。
|
|
8273
|
-
|
|
8274
|
-
|
|
8275
|
-
|
|
8276
|
-
|
|
10279
|
+
输的那个要等 ready 超时),别让按钮永远停在「重启中…」。
|
|
10280
|
+
探活时顺手把旧实例交代的「新实例地址 + 令牌」(health.restartHandoff)拿走:
|
|
10281
|
+
旧实例马上要死,交接只在这个窗口里给。
|
|
10282
|
+
|
|
10283
|
+
before:调用前抓的身份指纹 {pid, startedAt}(可以为空 = 老插件没有这两个字段)。
|
|
10284
|
+
返回 {url, token, follow, changed, pidBefore, pidAfter}:
|
|
10285
|
+
`changed` = 拿 pid / startedAt 证实**换了一代**(2026-10-27 新增,治「好像没效果」)。 */
|
|
10286
|
+
function waitRestartBack(before) {
|
|
10287
|
+
var hdr = apiToken() ? { 'X-Magic-Token': apiToken() } : {};
|
|
10288
|
+
var launchedAt = Date.now();
|
|
10289
|
+
var deadline = launchedAt + 60000;
|
|
10290
|
+
var sawDown = false, moved = false, settled = false;
|
|
10291
|
+
var handoff = null, lostAt = 0, triedAt = 0;
|
|
10292
|
+
before = before || {};
|
|
10293
|
+
var pidBefore = (typeof before.pid === 'number') ? before.pid : null;
|
|
10294
|
+
var startBefore = before.startedAt || null;
|
|
10295
|
+
/* 换代判据:pid 变了,或启动时刻变了。两个都拿不到 = 无法判断(不编造结论)。 */
|
|
10296
|
+
var isNewGen = function (d) {
|
|
10297
|
+
if (!d) return false;
|
|
10298
|
+
if (pidBefore !== null && typeof d.pid === 'number') return d.pid !== pidBefore;
|
|
10299
|
+
if (startBefore && d.startedAt) return d.startedAt !== startBefore;
|
|
10300
|
+
return false;
|
|
10301
|
+
};
|
|
10302
|
+
/* 交接里的候选入口一拿到就去试:新实例可能开在**另一个域名/端口**上,只等旧地址
|
|
10303
|
+
恢复等于白等 60 秒(用户就看着「重启中…」发呆)。公网域名在 urls 第一位。 */
|
|
10304
|
+
var probeHandoff = function (resolve) {
|
|
10305
|
+
if (settled || !handoff) return;
|
|
10306
|
+
var now = Date.now();
|
|
10307
|
+
if (now - triedAt < 900) return; // 别每 400ms 一轮就砸一遍:1 秒一试足够
|
|
10308
|
+
triedAt = now;
|
|
10309
|
+
var urls = (handoff.urls || []).slice(0, 3), tok = handoff.token || '';
|
|
10310
|
+
var h2 = tok ? { 'X-Magic-Token': tok } : {};
|
|
10311
|
+
/* 每个候选都要有超时:隧道半死时 fetch 可能挂很久,它会拖住整条探活链,
|
|
10312
|
+
让界面一直停在「重启中…」(2026-10-25 真机复现)。 */
|
|
10313
|
+
var once = function (u) {
|
|
10314
|
+
return new Promise(function (res) {
|
|
10315
|
+
var done = false;
|
|
10316
|
+
var t = setTimeout(function () { if (!done) { done = true; res(null); } }, 2500);
|
|
10317
|
+
fetch(u + '/desk/health', { headers: h2, cache: 'no-store' })
|
|
10318
|
+
.then(function (r) { return r.ok ? r.json().catch(function () { return {}; }) : null; })
|
|
10319
|
+
.then(function (d) { if (done) return; done = true; clearTimeout(t); res(d); })
|
|
10320
|
+
.catch(function () { if (done) return; done = true; clearTimeout(t); res(null); });
|
|
10321
|
+
});
|
|
10322
|
+
};
|
|
10323
|
+
var again = function (i) {
|
|
10324
|
+
if (settled || i >= urls.length) return;
|
|
10325
|
+
var u = String(urls[i] || '').replace(/\/+$/, '');
|
|
10326
|
+
if (!u) return again(i + 1);
|
|
10327
|
+
once(u).then(function (d) {
|
|
10328
|
+
if (settled) return;
|
|
10329
|
+
/* 200 就算数(老插件的 health 可能没有 ok 字段)。令牌不对会是 401 → null。 */
|
|
10330
|
+
if (d) { settled = true; return resolve({ url: u, token: tok, follow: true, handoff: true }); }
|
|
10331
|
+
again(i + 1);
|
|
10332
|
+
});
|
|
10333
|
+
};
|
|
10334
|
+
again(0);
|
|
10335
|
+
};
|
|
8277
10336
|
return new Promise(function (resolve, reject) {
|
|
8278
10337
|
(function tick() {
|
|
8279
10338
|
if (settled) return;
|
|
8280
|
-
fetch(
|
|
10339
|
+
fetch(apiOrigin() + '/desk/health', { headers: hdr, cache: 'no-store' })
|
|
8281
10340
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
8282
10341
|
.then(function (d) {
|
|
8283
|
-
if (!d) {
|
|
10342
|
+
if (!d) {
|
|
10343
|
+
/* 连不上:旧实例真的走了。但别把「重启前就连不上」也算成「看到下线」——
|
|
10344
|
+
那样第一次探活就判新一代,等于什么都没确认。 */
|
|
10345
|
+
sawDown = true;
|
|
10346
|
+
if (Date.now() - launchedAt > 2000) moved = true;
|
|
10347
|
+
return;
|
|
10348
|
+
}
|
|
10349
|
+
/* 交接要**先**收下:旧实例在 restarting 期间就会带上新入口,别因为提前 return 漏掉。 */
|
|
10350
|
+
if (d.restartHandoff && d.restartHandoff.urls && d.restartHandoff.urls.length) handoff = d.restartHandoff;
|
|
8284
10351
|
if (d.restarting) { sawDown = true; return; } // 旧实例认账(restarting 标志):定时器到点就下线
|
|
8285
|
-
if (sawDown) {
|
|
10352
|
+
if (sawDown) {
|
|
10353
|
+
/* 不含标志的 200 = 新实例(地址没变)。2026-10-27 起再核对 pid / startedAt,
|
|
10354
|
+
把「换代」从推测变成证据;老插件没有这两个字段 → changed 为 false,如实说。 */
|
|
10355
|
+
settled = true;
|
|
10356
|
+
var pa = (typeof d.pid === 'number') ? d.pid : null;
|
|
10357
|
+
return resolve({
|
|
10358
|
+
url: BASE, token: apiToken(), follow: false,
|
|
10359
|
+
changed: isNewGen(d),
|
|
10360
|
+
pidBefore: pidBefore, pidAfter: pa,
|
|
10361
|
+
pidChanged: (pidBefore !== null && pa !== null && pa !== pidBefore)
|
|
10362
|
+
});
|
|
10363
|
+
}
|
|
10364
|
+
/* 没看到下线、但这一代**已经换了**(重启太快,中间的空窗被我们错过了):
|
|
10365
|
+
同样算成功 —— 只要 pid 变了,就是新进程在应答。 */
|
|
10366
|
+
if (isNewGen(d)) {
|
|
10367
|
+
settled = true;
|
|
10368
|
+
var pb = (typeof d.pid === 'number') ? d.pid : null;
|
|
10369
|
+
return resolve({
|
|
10370
|
+
url: BASE, token: apiToken(), follow: false,
|
|
10371
|
+
changed: true, pidBefore: pidBefore, pidAfter: pb,
|
|
10372
|
+
pidChanged: (pidBefore !== null && pb !== null && pb !== pidBefore)
|
|
10373
|
+
});
|
|
10374
|
+
}
|
|
10375
|
+
})
|
|
10376
|
+
.catch(function () {
|
|
10377
|
+
if (!lostAt) lostAt = Date.now();
|
|
10378
|
+
sawDown = true;
|
|
10379
|
+
if (Date.now() - launchedAt > 2000) moved = true;
|
|
8286
10380
|
})
|
|
8287
|
-
.catch(function () { sawDown = true; })
|
|
8288
10381
|
.then(function () {
|
|
8289
10382
|
if (settled) return; // 已确认恢复:不再空转探活
|
|
8290
|
-
|
|
10383
|
+
probeHandoff(resolve); // 旧地址还没回来?试试它交代的新入口
|
|
10384
|
+
if (Date.now() > deadline) {
|
|
10385
|
+
settled = true;
|
|
10386
|
+
/* 仍然连不上 + 旧实例死前交代过新入口 = 地址真的换了(旧地址 401 / 连不上都算)。 */
|
|
10387
|
+
if (handoff) { var e1 = new Error('旧地址已不可用'); e1.handoff = handoff; return reject(e1); }
|
|
10388
|
+
var e2 = new Error('等桌面端恢复超时');
|
|
10389
|
+
e2.moved = moved;
|
|
10390
|
+
return reject(e2);
|
|
10391
|
+
}
|
|
8291
10392
|
setTimeout(tick, 400);
|
|
8292
10393
|
});
|
|
8293
10394
|
})();
|
|
8294
10395
|
});
|
|
8295
10396
|
}
|
|
8296
10397
|
|
|
10398
|
+
/* ── 重启 / 升级后地址或令牌变了:把「原环境」的连接地址与令牌换成新实例的 ──
|
|
10399
|
+
2026-10-25 用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」。
|
|
10400
|
+
|
|
10401
|
+
为什么旧地址会失效:手机存的是「地址 + 令牌」。换 Wi-Fi / 热点 → 网卡 IP 变了;
|
|
10402
|
+
端口撞了 → 换端口;状态文件丢了 → 令牌重发。旧实例死前把「新实例的手机入口 +
|
|
10403
|
+
令牌」挂在自己的 /desk/health.restartHandoff 上(见 restartHandoff),这里取走。
|
|
10404
|
+
|
|
10405
|
+
口径(三条都是硬约束):
|
|
10406
|
+
· 只改**这一条**环境:url / token / id 换成新的,eid 与名字不动 —— eid 是环境的
|
|
10407
|
+
稳定身份(见 ensureEnvId),排队任务、配置槽、空间都还挂在这条名下;
|
|
10408
|
+
· **一律以新连上的那台实例自己回报的入口为准**:交接给的地址也来自旧实例的网卡
|
|
10409
|
+
枚举(退出 Wi-Fi 时它可能还是旧的),所以连上之后优先用 health.lanUrls /
|
|
10410
|
+
publicUrl;永远不在没验证连通之前改地址,改错了就再也连不回来;
|
|
10411
|
+
· 换完整页重载:会话轮询、通知、排队、会话组件都绑定在 BASE / TOKEN 上,
|
|
10412
|
+
就地改会把内存里的旧连接状态带到新入口上(同 switchEnv 的道理)。 */
|
|
10413
|
+
function normOrigin(u) {
|
|
10414
|
+
try { return new URL(String(u || '')).origin; } catch (e) { return ''; }
|
|
10415
|
+
}
|
|
10416
|
+
function sameOriginUrl(a, list) {
|
|
10417
|
+
var na = normOrigin(a);
|
|
10418
|
+
if (!na) return false;
|
|
10419
|
+
for (var i = 0; i < (list || []).length; i++) if (normOrigin(list[i]) === na) return true;
|
|
10420
|
+
return false;
|
|
10421
|
+
}
|
|
10422
|
+
/* 「原环境」那一条记录:同一台桌面可能有多条入口(公司本地 / 公司远程,共享 eid);
|
|
10423
|
+
当前要是同源页那种临时记录(壳注入的地址 + 令牌),这条原环境才找得到 ——
|
|
10424
|
+
认不出来就按旧地址 + 旧令牌找。 */
|
|
10425
|
+
function keepSameConn(oldUrl) {
|
|
10426
|
+
if (oldUrl) {
|
|
10427
|
+
var cur = activeConn();
|
|
10428
|
+
if (cur && cur.eid) {
|
|
10429
|
+
var list = loadConns();
|
|
10430
|
+
for (var i = 0; i < list.length; i++) if (list[i] && list[i].eid === cur.eid) return list[i];
|
|
10431
|
+
}
|
|
10432
|
+
}
|
|
10433
|
+
return findConn(connIdOf({ url: oldUrl, token: apiToken() }));
|
|
10434
|
+
}
|
|
10435
|
+
/* 把一个原点换到另一个原点,保留路径与查询参数(http://a:9100 → http://b:9200)。 */
|
|
10436
|
+
function swapOrigin(from, to) {
|
|
10437
|
+
var a, b;
|
|
10438
|
+
try { a = new URL(String(from || '')); b = new URL(String(to || '')); } catch (e) { return ''; }
|
|
10439
|
+
/* 相对路径的 URL 没有 origin(base = 当前页):这种入口换不了,交给同源分支。 */
|
|
10440
|
+
if (!a.origin || a.origin === 'null' || !b.origin || b.origin === 'null') return '';
|
|
10441
|
+
/* 纯 origin 的入口别留尾斜杠:存成 `https://x/` 的话,各处 `apiOrigin() + '/desk/…'`
|
|
10442
|
+
会拼出 `https://x//desk/…` —— 隧道/WAF 上可能被规范化成 301,白多一跳。 */
|
|
10443
|
+
if (a.pathname === '/' && !a.search && !a.hash) return b.origin;
|
|
10444
|
+
return b.origin + a.pathname + a.search + a.hash;
|
|
10445
|
+
}
|
|
10446
|
+
function followRestartMoved(url, token, ui) {
|
|
10447
|
+
var val = ui && ui.val;
|
|
10448
|
+
var fail = function (msg) {
|
|
10449
|
+
if (val) val.textContent = '重启';
|
|
10450
|
+
if (ui && ui.btn) ui.btn.disabled = false;
|
|
10451
|
+
toast('桌面端已重启,但没能自动更新连接:' + msg + '。请在环境里重新扫码(新地址:' + (url || '未知') + ')');
|
|
10452
|
+
};
|
|
10453
|
+
var probe = function (u, tok) {
|
|
10454
|
+
var h = tok ? { 'X-Magic-Token': tok } : {};
|
|
10455
|
+
return fetch(u + '/desk/health', { headers: h, cache: 'no-store' })
|
|
10456
|
+
.then(function (r) { return r.ok ? r.json().catch(function () { return {}; }) : null; })
|
|
10457
|
+
.catch(function () { return null; });
|
|
10458
|
+
};
|
|
10459
|
+
probe(url, token).then(function (d) {
|
|
10460
|
+
if (!d) return { base: '', health: null }; // 交接给的地址也连不上:交给下面报失败
|
|
10461
|
+
/* 新实例自己枚举的入口(它眼里此刻的网卡)+ 公网隧道:优先于交接值。 */
|
|
10462
|
+
var live = [];
|
|
10463
|
+
if (d.publicUrl) live.push(d.publicUrl);
|
|
10464
|
+
if (d.lanUrls && d.lanUrls.length) live = live.concat(d.lanUrls);
|
|
10465
|
+
live = live.filter(function (u) { return u && normOrigin(u) !== normOrigin(url); });
|
|
10466
|
+
if (!live.length) return { base: url, health: d };
|
|
10467
|
+
return Promise.all(live.map(function (u) {
|
|
10468
|
+
return probe(u, token).then(function (dd) { return dd ? u : ''; });
|
|
10469
|
+
})).then(function (hits) {
|
|
10470
|
+
for (var i = 0; i < hits.length; i++) if (hits[i]) return { base: hits[i], health: d };
|
|
10471
|
+
return { base: url, health: d };
|
|
10472
|
+
});
|
|
10473
|
+
}).then(function (r) {
|
|
10474
|
+
if (!r.base) { fail('新地址也连不上'); return; }
|
|
10475
|
+
var newOrigin = normOrigin(r.base);
|
|
10476
|
+
var oldOrigin = normOrigin(apiOrigin());
|
|
10477
|
+
if (!newOrigin) { fail('新地址无法解析'); return; }
|
|
10478
|
+
/* 同源页(web 壳 / 远程界面,BASE 为空):入口由页面本身决定,换不了 ——
|
|
10479
|
+
如果新入口刚验证过可用、且页面就打开在它上面,说明本页已经在新地址上,什么也不用改。
|
|
10480
|
+
⚠️ 这里只比 r.base:旧入口(就是本页地址)一定和 location.origin 相同,
|
|
10481
|
+
拿它比会把「地址真变了」误判成「没变」。 */
|
|
10482
|
+
if (!BASE && newOrigin === normOrigin(location.origin)) {
|
|
10483
|
+
if (val) val.textContent = '重启';
|
|
10484
|
+
if (ui && ui.btn) ui.btn.disabled = false;
|
|
10485
|
+
loadSessions(true);
|
|
10486
|
+
return;
|
|
10487
|
+
}
|
|
10488
|
+
if (newOrigin === oldOrigin && String(token || '') === String(apiToken())) {
|
|
10489
|
+
/* 地址与令牌都没变:不用改任何记录,刷新一下列表即可。 */
|
|
10490
|
+
if (val) val.textContent = '重启';
|
|
10491
|
+
if (ui && ui.btn) ui.btn.disabled = false;
|
|
10492
|
+
loadSessions(true);
|
|
10493
|
+
return;
|
|
10494
|
+
}
|
|
10495
|
+
if (val) val.textContent = '地址变了,正在重连…';
|
|
10496
|
+
var list = loadConns(), changed = 0;
|
|
10497
|
+
list.forEach(function (c) {
|
|
10498
|
+
if (!c || !c.url) return;
|
|
10499
|
+
/* 只换**与原入口同一个原点**的记录(当前这条 + 它的重复条目)。
|
|
10500
|
+
⚠️ 不按 eid 把同环境的所有入口一起换:同一台桌面的「公司本地 127.0.0.1」与
|
|
10501
|
+
「公司远程 域名」共享 eid,但把回环入口也换成域名/局域网地址会把它弄坏 ——
|
|
10502
|
+
那个入口在本机仍然有效(2026-10-25)。 */
|
|
10503
|
+
if (normOrigin(c.url) !== oldOrigin) return;
|
|
10504
|
+
var nu = swapOrigin(c.url, newOrigin);
|
|
10505
|
+
if (!nu) return;
|
|
10506
|
+
if (nu === c.url && String(c.token || '') === String(token || '')) return;
|
|
10507
|
+
c.url = nu;
|
|
10508
|
+
c.token = token || '';
|
|
10509
|
+
c.id = connIdOf(c);
|
|
10510
|
+
c.host = connHost(nu);
|
|
10511
|
+
c.at = Date.now();
|
|
10512
|
+
changed++;
|
|
10513
|
+
});
|
|
10514
|
+
saveConns(list);
|
|
10515
|
+
var now = findConn(connIdOf({ url: r.base, token: token })) ||
|
|
10516
|
+
findConn(connIdOf({ url: swapOrigin(connUrlNow(), newOrigin), token: token }));
|
|
10517
|
+
if (!now) { fail('没能把新地址落成这条环境'); return; }
|
|
10518
|
+
BASE = toConnValue(now.url); // 同源页仍留空串(与 /desk/ 同源,见 apiOrigin)
|
|
10519
|
+
TOKEN = now.token;
|
|
10520
|
+
ACTIVE_ID = now.id;
|
|
10521
|
+
try { localStorage.setItem(ACTIVE_ENV_KEY, now.url); } catch (e) {}
|
|
10522
|
+
saveEnvScope(envScopeIdOf(now)); // 现场仍写在原环境(eid 没变)名下
|
|
10523
|
+
/* 同源页(壳 / 远程界面)的令牌是**注入**进来的,首屏会认注入值。留下「接下来该用哪个
|
|
10524
|
+
令牌」,首屏恢复时优先用它 —— 否则重载后又拿旧令牌去连,等于白换。 */
|
|
10525
|
+
try {
|
|
10526
|
+
sessionStorage.setItem('magic.remote.toknext.v1', JSON.stringify({ url: now.url, token: now.token }));
|
|
10527
|
+
} catch (e) {}
|
|
10528
|
+
toast('桌面端已重启,已把「' + (connLabel(now) || connHost(now.url)) + '」的地址与令牌刷新为 ' + now.host);
|
|
10529
|
+
/* 通知轮询(tests/check-notify.mjs 钉着:每个给 BASE 赋值的分支都要起)。
|
|
10530
|
+
现状:900ms 后整页重载,新页面首屏自己会起轮询;这里起一把是**兜底** ——
|
|
10531
|
+
重载被壳拦住 / 失败时不至于连通知都停着(startPoll 幂等,不会起两套)。 */
|
|
10532
|
+
ensureNotifyPoll();
|
|
10533
|
+
setTimeout(function () {
|
|
10534
|
+
try { location.reload(); } catch (e) { location.href = location.href; }
|
|
10535
|
+
}, 900); // 稍等一下再刷:让 toast 先被看见
|
|
10536
|
+
}).catch(function (e) {
|
|
10537
|
+
fail((e && e.message) ? e.message : String(e));
|
|
10538
|
+
});
|
|
10539
|
+
}
|
|
10540
|
+
|
|
8297
10541
|
/* ══════════ 接线 ══════════ */
|
|
8298
10542
|
document.getElementById('btnConnect').onclick = function () {
|
|
8299
10543
|
var target = parseTarget(addrEl.value);
|
|
@@ -8366,6 +10610,9 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8366
10610
|
别名另开一个口子(getSpaceAlias),免得需求页去拆对象。 */
|
|
8367
10611
|
getCustomSpaces: function () { return CUSTOM_SPACES.map(function (s) { return s.path; }); },
|
|
8368
10612
|
getSpaceAlias: function (dir) { var it = customSpaceOf(dir); return it ? (it.alias || '') : ''; },
|
|
10613
|
+
/* 被用户移除过的空间(2026-10-26):需求页据此把这些空间整块滤掉 ——
|
|
10614
|
+
与任务页「空间」分段同一口径(否则用户删了空间,需求页还留着它)。 */
|
|
10615
|
+
isHiddenSpace: isHiddenSpace,
|
|
8369
10616
|
getRunPref: function () { return RUN_PREF; },
|
|
8370
10617
|
isRealSpaceDir: isRealSpaceDir,
|
|
8371
10618
|
topLevelSessions: topLevelSessions,
|
|
@@ -8440,6 +10687,33 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8440
10687
|
getToken: function () { return TOKEN; }
|
|
8441
10688
|
});
|
|
8442
10689
|
}
|
|
10690
|
+
/* 应用页(apps.js):与脚本页同一套注入方式 —— getter 传 BASE/TOKEN(切环境后会变),
|
|
10691
|
+
抽屉借宿主的,「在内置浏览器打开」走 openBrowser(相对地址 = 本服务入口)。 */
|
|
10692
|
+
if (window.MagicApps && window.MagicApps.mount) {
|
|
10693
|
+
window.MagicApps.mount({
|
|
10694
|
+
rootEl: appsEl,
|
|
10695
|
+
detailEl: appDetailEl,
|
|
10696
|
+
hEl: hEl,
|
|
10697
|
+
elSpan: elSpan,
|
|
10698
|
+
svgUse: svgUse,
|
|
10699
|
+
toast: toast,
|
|
10700
|
+
hideAll: hideAll,
|
|
10701
|
+
setActive: setActive,
|
|
10702
|
+
showRoot: function () {
|
|
10703
|
+
hideAll();
|
|
10704
|
+
appsEl.hidden = false;
|
|
10705
|
+
tabbarEl.hidden = true;
|
|
10706
|
+
},
|
|
10707
|
+
showDetail: showAppDetail,
|
|
10708
|
+
backToDiscover: showDiscover,
|
|
10709
|
+
openSheet: openHostSheet,
|
|
10710
|
+
closeSheet: closeHostSheet,
|
|
10711
|
+
/* 应用工作台用专用入口(见 openAppInBrowser):刷新 / 分享指回入口,而不是最后的 "/"。 */
|
|
10712
|
+
openInBrowser: function (u) { openAppInBrowser(u); },
|
|
10713
|
+
getBase: function () { return BASE; },
|
|
10714
|
+
getToken: function () { return TOKEN; }
|
|
10715
|
+
});
|
|
10716
|
+
}
|
|
8443
10717
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
8444
10718
|
b.onclick = function () {
|
|
8445
10719
|
var t = b.getAttribute('data-tab');
|
|
@@ -8455,7 +10729,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8455
10729
|
on('btnFabNew', function () { newConversation(); });
|
|
8456
10730
|
on('btnMineBack', function () { showHistory(); });
|
|
8457
10731
|
on('dscBrowser', function () { openBrowser(HOME_URL); });
|
|
8458
|
-
on('dscApps', function () {
|
|
10732
|
+
on('dscApps', function () { showApps(); });
|
|
8459
10733
|
on('dscPlugins', function () { showPlugins(); });
|
|
8460
10734
|
on('dscScripts', function () { showScripts(); });
|
|
8461
10735
|
on('dscKnowledge', function () { showKnowledge(); });
|
|
@@ -8471,6 +10745,19 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8471
10745
|
on('brHome', brHome);
|
|
8472
10746
|
on('brRefresh', brReload);
|
|
8473
10747
|
on('brShare', brShare);
|
|
10748
|
+
/* 手动切换横屏:切过一次就不再自动判断(brLandByUser)。 */
|
|
10749
|
+
on('brLand', function () { brSetLandscape(!brLand, true); });
|
|
10750
|
+
if (brLandHint) {
|
|
10751
|
+
var off = document.getElementById('brLandOff');
|
|
10752
|
+
if (off) off.onclick = function () { brSetLandscape(false, true); };
|
|
10753
|
+
}
|
|
10754
|
+
/* 转屏 / 改窗口:设备横过来就撤回软件旋转(否则双重旋转),竖回去再自动判断。 */
|
|
10755
|
+
window.addEventListener('resize', function () {
|
|
10756
|
+
if (brEl.hidden) return;
|
|
10757
|
+
if (brLand && window.innerWidth > window.innerHeight) { brSetLandscape(false); return; }
|
|
10758
|
+
if (brLand) brSetLandscape(true); // 舞台尺寸变了,重算铺满
|
|
10759
|
+
else brScheduleLandscape();
|
|
10760
|
+
});
|
|
8474
10761
|
on('brStar', brToggleStar);
|
|
8475
10762
|
on('brRelay', brToggleRelay);
|
|
8476
10763
|
/* 截图按钮:需求页「页面完成情况」流程专用(reqshot.js 发指令、收图)。
|
|
@@ -8480,6 +10767,11 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8480
10767
|
});
|
|
8481
10768
|
brPaintRelay();
|
|
8482
10769
|
on('btnNotify', openNotifySettings);
|
|
10770
|
+
/* 飞书推送(2026-10-26):「我的 → 账户 → 飞书推送」进子页面;返回回「我的」。 */
|
|
10771
|
+
on('btnFeishuPage', showFeishuPage);
|
|
10772
|
+
on('btnFeishuBack', showMine);
|
|
10773
|
+
/* 「我的 → 账户 → 当前桌面」那一行右侧的「切换」:复用任务页标题的环境菜单(同一份清单)。 */
|
|
10774
|
+
on('btnMineSwitchEnv', openEnvMenu);
|
|
8483
10775
|
on('btnDefaultEngine', openDefaultEnginePicker);
|
|
8484
10776
|
on('btnSuggestEngine', openSuggestEnginePicker);
|
|
8485
10777
|
/* 团队模式配置(「我的」→ 子页面):计划 + 7 个角色,行由 paintTeamRoles 动态渲染,
|
|
@@ -8617,6 +10909,15 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8617
10909
|
· 其余情况:显示启动页,由用户粘贴/扫码连接。 */
|
|
8618
10910
|
var injectedToken = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
|
|
8619
10911
|
var injectedBase = (window.MAGIC_BASE || '').replace(/\/+$/, '');
|
|
10912
|
+
TOKEN_INJECTED = injectedToken; // 见 apiToken:同源页的 BASE 为空时仍要带令牌
|
|
10913
|
+
/* 重启 / 升级后跟着新实例换过令牌(见 followRestartMoved):同源页的令牌是注入的,
|
|
10914
|
+
这一轮恢复要用新令牌,并顺手撤掉这个一次性标记。 */
|
|
10915
|
+
var TOKEN_OVERRIDE = null;
|
|
10916
|
+
try {
|
|
10917
|
+
var ov = JSON.parse(sessionStorage.getItem('magic.remote.toknext.v1') || 'null');
|
|
10918
|
+
if (ov && ov.token) TOKEN_OVERRIDE = ov;
|
|
10919
|
+
sessionStorage.removeItem('magic.remote.toknext.v1');
|
|
10920
|
+
} catch (e) { TOKEN_OVERRIDE = null; }
|
|
8620
10921
|
try {
|
|
8621
10922
|
/* 上次停在哪个环境(见 switchEnv):切过环境后整页重载,必须回到**那台**,
|
|
8622
10923
|
否则壳注入的地址会把人拽回本机 —— 用户会看到「切了又跳回来」。 */
|
|
@@ -8631,14 +10932,27 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8631
10932
|
var sc = saved[i];
|
|
8632
10933
|
if (!sc || sc.url !== lastUrl) continue;
|
|
8633
10934
|
/* 同一地址(壳注入的这台)令牌以注入为准:App 每次启动会换令牌,存档里的旧令牌已失效。 */
|
|
8634
|
-
|
|
8635
|
-
|
|
10935
|
+
/* ⚠️ 例外:这份存档里的令牌与注入的不一样时,可能是「重启 / 升级后跟着新实例换过的
|
|
10936
|
+
新令牌」(见 followRestartMoved)—— 能连上就用存档的,连不上再退回注入的,
|
|
10937
|
+
否则一重载就被拽回已失效的旧令牌。 */
|
|
10938
|
+
var tok = (lastUrl === injUrl && injectedToken && String(injectedToken) !== String(sc.token || ''))
|
|
10939
|
+
? (sc.token || injectedToken) : ((lastUrl === injUrl && injectedToken)
|
|
10940
|
+
? injectedToken : (sc.token || injectedToken));
|
|
8636
10941
|
if (!tok) break;
|
|
8637
10942
|
BASE = sc.url; TOKEN = tok;
|
|
8638
10943
|
sc = ensureEnvId(sc) || sc; // 老记录补 eid,并把按地址分区的旧队列/配置槽迁过来
|
|
8639
10944
|
ACTIVE_ID = sc.id;
|
|
10945
|
+
/* 换过令牌后 id 会按 url|token 重算:存档是按旧令牌存的,这时 ACTIVE_ID 对不上。
|
|
10946
|
+
统一从「这一次真正要连的」连接里取(没变的记录 id 相同,行为不变)。 */
|
|
10947
|
+
/* 换过令牌的那一轮(见 followRestartMoved):先撤掉注入的旧令牌,再按 eid / 新令牌认这条,
|
|
10948
|
+
否则 ACTIVE_ID 会停在按 url|旧令牌 算出来的那行上。 */
|
|
10949
|
+
if (TOKEN_OVERRIDE) TOKEN = '';
|
|
10950
|
+
var bound = keepSameConn(TOKEN_OVERRIDE ? TOKEN_OVERRIDE.url : '')
|
|
10951
|
+
|| findConn(connIdOf({ url: BASE, token: TOKEN }));
|
|
10952
|
+
if (bound) { BASE = bound.url; TOKEN = bound.token; ACTIVE_ID = bound.id; }
|
|
8640
10953
|
showHistory(sc.host || connHost(sc.url));
|
|
8641
10954
|
restoreQueue();
|
|
10955
|
+
ensureNotifyPoll(); // 通知轮询(见 ensureNotifyPoll:这条路径原先从不启动)
|
|
8642
10956
|
resumed = true;
|
|
8643
10957
|
break;
|
|
8644
10958
|
}
|
|
@@ -8652,6 +10966,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8652
10966
|
ensureEnvId(activeConn()); // 种出来的这条也补 eid;seedoff 没落盘时按老键回退
|
|
8653
10967
|
showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
|
|
8654
10968
|
restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
|
|
10969
|
+
ensureNotifyPoll(); // 通知轮询(见 ensureNotifyPoll:这条路径原先从不启动)
|
|
8655
10970
|
} else if (!resumed) {
|
|
8656
10971
|
/* 没有注入、也没有可恢复的上次环境:有存过的环境就直接连最近用过的那条;
|
|
8657
10972
|
一条都没有才进任务页空态 —— 不再用独立启动页挡在前面
|
|
@@ -8665,6 +10980,7 @@ html[data-theme="dark"] .tabfab__b{
|
|
|
8665
10980
|
try { localStorage.setItem(ACTIVE_ENV_KEY, pick.url); } catch (e) {}
|
|
8666
10981
|
showHistory(pick.host || connHost(pick.url));
|
|
8667
10982
|
restoreQueue();
|
|
10983
|
+
ensureNotifyPoll(); // 通知轮询(见 ensureNotifyPoll:这条路径原先从不启动)
|
|
8668
10984
|
} else {
|
|
8669
10985
|
showHistory(null);
|
|
8670
10986
|
}
|