magic-agent 1.0.49 → 1.0.109
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 +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2443 -178
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -41,6 +41,12 @@
|
|
|
41
41
|
--ink:#F9FAFB; --ink-soft:#E5E7EB; --muted:#CBD5E1; --faint:#94A3B8;
|
|
42
42
|
--border:rgba(255,255,255,.10); --subtle:#374151; --raised:#1F2937;
|
|
43
43
|
--primary-action:#93C5FD; --on-primary-action:#111827; /* primary = BrandBlueDark / onPrimary */
|
|
44
|
+
/* 底栏「+」凹槽的两件套,**必须按主题分别给**(`--fab-well` 槽底,`--fab-notch` 接触影满强度色):
|
|
45
|
+
槽底不能用 `--canvas` —— 那是「页面底色」,暗色下 #111827 比栏面 #1F2937 暗 14 阶,
|
|
46
|
+
整圈会读成一道黑箍(比浮起来更假)。槽底该是**栏面稍暗一档**的同一个色系:
|
|
47
|
+
暗色 #1F2937 → #1B2432(同色相压 4 阶),浅色 #FFFFFF → #EEF2F7(同色相压 8 阶,必须够,
|
|
48
|
+
因为 248/250/252 与 255 只差 7 阶,直接用 canvas 等于没画)。 */
|
|
49
|
+
--fab-well:#1B2432; --fab-notch:rgba(0,0,0,.22);
|
|
44
50
|
--app-shadow:rgba(0,0,0,.45);
|
|
45
51
|
|
|
46
52
|
/* 用户气泡 = surface/foreground:ZCode 的用户消息是**中性表面**不是品牌色气泡 */
|
|
@@ -60,11 +66,19 @@
|
|
|
60
66
|
--tint-queued:rgba(139,92,246,.18); --queued:#8B5CF6;
|
|
61
67
|
|
|
62
68
|
/* ── 掌天瓶绿液(可选令牌,shared/conversation.css 用 `var(--q-bottle*, 兜底)` 取)──
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
瓶是**玉**不是玻璃(见 conversation.js 的形制说明),所以颜色分两组:
|
|
70
|
+
· 玉胎 `--q-bottle-jade-hi / jade / jade-lo`:整只瓶的底色,**空处也是绿的**(薄玉)。
|
|
71
|
+
这三个是 rgba,浅底上必须更浓才不发白 —— 所以下面两套主题各给一份。
|
|
72
|
+
· 灵液 `--q-bottle-hi / / lo / deep`:液位以下的浓翠。
|
|
73
|
+
· `--q-bottle-line` 是轮廓(比玉胎深一档的绿)、`--q-bottle-rim` 是边光/液面亮线/气泡
|
|
74
|
+
(必须比 hi 更白,否则整只瓶会「发光过度」)、`--q-bottle-cork` 是木塞。
|
|
75
|
+
移动端的绿跟 --running 同族(#22C55E),兜底那份偏薄荷(#2ECB80),深色底上会发灰。 */
|
|
76
|
+
--q-bottle-hi:#7DF3A8; --q-bottle:#22C55E; --q-bottle-lo:#0F7B3C; --q-bottle-deep:#06371F;
|
|
77
|
+
--q-bottle-line:#43A06E; --q-bottle-rim:#D9FBE8; --q-bottle-cork:#8A7659;
|
|
78
|
+
--q-bottle-glow:rgba(34,197,94,.30);
|
|
79
|
+
--q-bottle-jade-hi:rgba(120,232,175,.44);
|
|
80
|
+
--q-bottle-jade:rgba(52,192,124,.46);
|
|
81
|
+
--q-bottle-jade-lo:rgba(8,96,60,.58);
|
|
68
82
|
|
|
69
83
|
--r-card:16px; --r:12px; --r-full:999px;
|
|
70
84
|
--font:-apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans CJK SC","Segoe UI",Roboto,system-ui,sans-serif;
|
|
@@ -83,6 +97,7 @@
|
|
|
83
97
|
--ink:#111827; --ink-soft:#1F2937; --muted:#64748B; --faint:#94A3B8;
|
|
84
98
|
--border:#E2E8F0; --subtle:#F1F5F9; --raised:#FFFFFF;
|
|
85
99
|
--primary-action:#2563EB; --on-primary-action:#FFFFFF;
|
|
100
|
+
--fab-well:#EEF2F7; --fab-notch:rgba(15,23,42,.22);
|
|
86
101
|
--app-shadow:rgba(15,23,42,.12);
|
|
87
102
|
--bubble:#FFFFFF; --bubble-text:#111827;
|
|
88
103
|
--code:#F1F5F9; --status-neutral:#E2E8F0; --status-neutral-text:#475569;
|
|
@@ -93,6 +108,13 @@
|
|
|
93
108
|
--accent:#2563EB; --warning:#D97706; --success:#16A34A;
|
|
94
109
|
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
95
110
|
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
111
|
+
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
112
|
+
/* 浅底上玉胎要更浓:`.42` 那档实测会被冲成白瓷,读不出「翠」。浓到 .66 又像实心塑料。 */
|
|
113
|
+
--q-bottle-jade-hi:rgba(146,231,184,.62);
|
|
114
|
+
--q-bottle-jade:rgba(96,205,152,.60);
|
|
115
|
+
--q-bottle-jade-lo:rgba(40,152,104,.66);
|
|
116
|
+
--q-bottle-line:#37955F; --q-bottle-rim:#FFFFFF;
|
|
117
|
+
--q-bottle-glow:rgba(45,200,120,.20);
|
|
96
118
|
}
|
|
97
119
|
}
|
|
98
120
|
/* 手动主题:在「我的」页显式选了才生效(属性选择器优先级高于上面的 :root / media) */
|
|
@@ -101,6 +123,7 @@ html[data-theme="light"]{color-scheme:light;
|
|
|
101
123
|
--ink:#111827; --ink-soft:#1F2937; --muted:#64748B; --faint:#94A3B8;
|
|
102
124
|
--border:#E2E8F0; --subtle:#F1F5F9; --raised:#FFFFFF;
|
|
103
125
|
--primary-action:#2563EB; --on-primary-action:#FFFFFF;
|
|
126
|
+
--fab-well:#EEF2F7; --fab-notch:rgba(15,23,42,.22);
|
|
104
127
|
--app-shadow:rgba(15,23,42,.12);
|
|
105
128
|
--bubble:#FFFFFF; --bubble-text:#111827;
|
|
106
129
|
--code:#F1F5F9; --status-neutral:#E2E8F0; --status-neutral-text:#475569;
|
|
@@ -111,12 +134,19 @@ html[data-theme="light"]{color-scheme:light;
|
|
|
111
134
|
--accent:#2563EB; --warning:#D97706; --success:#16A34A;
|
|
112
135
|
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
113
136
|
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
137
|
+
/* 浅底上玉胎要更浓(同上一条注释) */
|
|
138
|
+
--q-bottle-jade-hi:rgba(146,231,184,.62);
|
|
139
|
+
--q-bottle-jade:rgba(96,205,152,.60);
|
|
140
|
+
--q-bottle-jade-lo:rgba(40,152,104,.66);
|
|
141
|
+
--q-bottle-line:#37955F; --q-bottle-rim:#FFFFFF;
|
|
142
|
+
--q-bottle-glow:rgba(45,200,120,.20);
|
|
114
143
|
}
|
|
115
144
|
html[data-theme="dark"]{color-scheme:dark;
|
|
116
145
|
--canvas:#111827;
|
|
117
146
|
--ink:#F9FAFB; --ink-soft:#E5E7EB; --muted:#CBD5E1; --faint:#94A3B8;
|
|
118
147
|
--border:rgba(255,255,255,.10); --subtle:#374151; --raised:#1F2937;
|
|
119
148
|
--primary-action:#93C5FD; --on-primary-action:#111827;
|
|
149
|
+
--fab-well:#1B2432; --fab-notch:rgba(0,0,0,.22);
|
|
120
150
|
--app-shadow:rgba(0,0,0,.45);
|
|
121
151
|
--bubble:#1F2937; --bubble-text:#F9FAFB;
|
|
122
152
|
--code:#0F172A; --status-neutral:#374151; --status-neutral-text:#CBD5E1;
|
|
@@ -138,7 +168,18 @@ html[data-theme="dark"]{color-scheme:dark;
|
|
|
138
168
|
/* ⚠️ 锁死视口高度而不是 min-height:否则滚动条落到 document 上,对话区不滚。 */
|
|
139
169
|
html,body{margin:0;height:100%;overflow:hidden}
|
|
140
170
|
body{background:var(--canvas);color:var(--ink);font:16px/1.5 var(--font);
|
|
141
|
-
overscroll-behavior:none;display:flex;flex-direction:column}
|
|
171
|
+
overscroll-behavior:none;display:flex;flex-direction:column;position:relative}
|
|
172
|
+
/* 不显示滚动条(2026-10-22 用户:「不需要显示滚动条」)。
|
|
173
|
+
手机上没有常驻滚动条(系统只在滑动时浮出指示、停手即隐);但本页也会进桌面那扇手机尺寸的
|
|
174
|
+
小窗(magic-test 悬浮球面板,Chromium),macOS 接了鼠标或设成「始终显示滚动条」时,
|
|
175
|
+
对话区 / 列表右缘各多出一根带轨道描边的 15px 竖条。这里与真机对齐:一律隐藏,
|
|
176
|
+
滚动能力不丢(滚轮 / 触控板 / 触摸照常)。
|
|
177
|
+
两套写法都写:scrollbar-width 管标准属性(Chromium 里它优先于 webkit 伪元素),
|
|
178
|
+
::-webkit-scrollbar 兜不认标准属性的旧内核 / WebView。
|
|
179
|
+
⚠️ !important 不能省:共享皮肤里有元素级的 `scrollbar-width:thin`(提问卡停靠位 .qa-askdock),
|
|
180
|
+
通配符的优先级压不过它,不加就会剩一根细条。 */
|
|
181
|
+
*{scrollbar-width:none!important}
|
|
182
|
+
*::-webkit-scrollbar{display:none!important;width:0!important;height:0!important}
|
|
142
183
|
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
|
|
143
184
|
svg{display:block}
|
|
144
185
|
textarea{font:inherit}
|
|
@@ -150,6 +191,11 @@ textarea{font:inherit}
|
|
|
150
191
|
padding:calc(env(safe-area-inset-top) + 20px) 20px calc(20px + env(safe-area-inset-bottom))}
|
|
151
192
|
/* headlineSmall ≈ 24sp */
|
|
152
193
|
.launcher__title{font-size:24px;font-weight:400;line-height:32px;margin:0;color:var(--ink)}
|
|
194
|
+
/* 从「切换环境 → 添加连接」进来时的返回键:已连着桌面就别把用户困在启动页 */
|
|
195
|
+
.launcher__back{position:absolute;top:calc(env(safe-area-inset-top) + 10px);left:12px;
|
|
196
|
+
display:flex;align-items:center;gap:2px;height:40px;padding:0 10px;border-radius:var(--r-full);
|
|
197
|
+
color:var(--primary-action);font-size:15px;font-weight:500}
|
|
198
|
+
.launcher__back:active{background:var(--subtle)}
|
|
153
199
|
/* 说明 bodyMedium(onSurfaceVariant) */
|
|
154
200
|
.launcher__desc{margin:8px 0 0;font-size:14px;line-height:20px;color:var(--muted)}
|
|
155
201
|
/* OutlinedTextField:1dp 描边 · 4dp 圆角(Material3 默认) */
|
|
@@ -167,15 +213,19 @@ textarea{font:inherit}
|
|
|
167
213
|
.btn--filled{margin-top:12px;background:var(--primary-action);color:var(--on-primary-action)}
|
|
168
214
|
.btn--outlined{margin-top:8px;border:1px solid var(--border);color:var(--primary-action)}
|
|
169
215
|
.btn:active{opacity:.9}
|
|
170
|
-
/*
|
|
216
|
+
/* 已保存的连接(2026-10-22:由「最近连接」升级而来,可保存多台桌面并逐个删除) */
|
|
171
217
|
.recent{margin-top:20px}
|
|
172
218
|
.recent__head{display:flex;align-items:center}
|
|
173
219
|
.recent__title{flex:1;font-size:14px;font-weight:600;color:var(--ink)}
|
|
174
220
|
.recent__clear{color:var(--primary-action);font-size:13px;font-weight:600;padding:8px 12px}
|
|
175
|
-
.
|
|
221
|
+
.recent__row{display:flex;align-items:stretch;gap:8px;margin-top:8px}
|
|
222
|
+
.recent__item{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;text-align:left;
|
|
176
223
|
border:1px solid var(--border);border-radius:20px;padding:10px 16px}
|
|
177
224
|
.recent__t{font-size:14px;line-height:20px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
178
225
|
.recent__s{font-size:12px;line-height:16px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
226
|
+
.recent__del{flex:0 0 auto;width:44px;border-radius:20px;border:1px solid var(--border);
|
|
227
|
+
display:flex;align-items:center;justify-content:center;color:var(--muted)}
|
|
228
|
+
.recent__del:active{background:var(--subtle);color:var(--error-text)}
|
|
179
229
|
|
|
180
230
|
/* ══════════ 会话页 RemoteControlWebViewScreen + TopBar(:517-644)══════════ */
|
|
181
231
|
.session{flex:1;min-height:0;display:flex;flex-direction:column}
|
|
@@ -203,13 +253,28 @@ textarea{font:inherit}
|
|
|
203
253
|
padding:calc(env(safe-area-inset-top) + 10px) 16px 8px}
|
|
204
254
|
.history__title{flex:1;min-width:0;font-size:22px;font-weight:700;color:var(--ink);
|
|
205
255
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
256
|
+
/* 环境切换(2026-10-22 用户:「移动端要支持环境切换 可以有多个连接」):
|
|
257
|
+
任务页标题本身就是切换入口 —— 点标题弹出已保存的环境,选中即切过去。
|
|
258
|
+
标题装的是**当前连接**的名字(扫码时带的 name > 桌面 health 报的 app > host)。 */
|
|
259
|
+
.history__title--btn{display:flex;align-items:center;gap:6px;text-align:left}
|
|
260
|
+
.history__title--btn>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
261
|
+
.history__title-chev{flex:0 0 auto;color:var(--muted);transition:transform .15s}
|
|
262
|
+
.history__title--btn.is-open .history__title-chev{transform:rotate(180deg)}
|
|
206
263
|
.history__disconnect{color:var(--primary-action);font-size:14px;font-weight:500;padding:8px 10px}
|
|
264
|
+
.history__disconnect:disabled{color:var(--muted);opacity:.6}
|
|
207
265
|
.history__list{flex:1;min-height:0;overflow-y:auto;padding:4px 10px calc(96px + env(safe-area-inset-bottom))}
|
|
208
266
|
.history__group{padding:12px 8px 6px;font-size:12px;font-weight:700;color:var(--muted)}
|
|
209
267
|
.history__hint{padding:22px 12px;color:var(--muted);font-size:14px;text-align:center}
|
|
210
268
|
.srow{display:flex;align-items:center;gap:12px;width:100%;padding:12px 8px;border-radius:14px;text-align:left}
|
|
211
269
|
.srow:active{background:var(--subtle)}
|
|
212
270
|
.srow--pending{cursor:default}
|
|
271
|
+
/* 排队任务没能启动(core 起不来 / 被拒 / 迟迟没落盘):不再呼吸,改成可点重试。
|
|
272
|
+
2026-10-21 用户:「团队模式会话没存档吗 刷新就没了」—— 从前这种情况条目会被静默删掉,
|
|
273
|
+
任务凭空消失;现在留在列表里,点一下就重新下发。 */
|
|
274
|
+
.srow--stalled{cursor:pointer}
|
|
275
|
+
.srow--stalled .srow__ico{background:var(--tint-wait);color:var(--warning)}
|
|
276
|
+
.srow--stalled .srow__ico--queued::after{display:none}
|
|
277
|
+
.srow--stalled .srow__s{color:var(--warning)}
|
|
213
278
|
.srow__ico{flex:0 0 auto;width:44px;height:44px;border-radius:50%;background:var(--subtle);
|
|
214
279
|
display:flex;align-items:center;justify-content:center;color:var(--ink);position:relative}
|
|
215
280
|
/* 会话状态:用颜色区分(无角标、无描边) */
|
|
@@ -217,8 +282,19 @@ textarea{font:inherit}
|
|
|
217
282
|
.srow__ico--wait{background:var(--tint-wait);color:var(--warning)}
|
|
218
283
|
.srow__ico--done{background:var(--tint-done);color:var(--success)}
|
|
219
284
|
.srow__ico--idle{background:var(--subtle);color:var(--muted)}
|
|
220
|
-
/*
|
|
285
|
+
/* 等待中(串行排队):紫色 + 一圈**呼吸**的环。
|
|
286
|
+
与「运行中」的扫光刻意分开 —— 扫光在转(在动),这圈只明暗(在等);
|
|
287
|
+
光靠颜色区分的话,绿色(已完成)与紫色(排队)在小屏上容易看混,
|
|
288
|
+
加一层「慢呼吸」就有了第二种线索。
|
|
289
|
+
⚠️ 只有**一种**等待样式:新任务排队、以及会话有追问在排队,用的都是它
|
|
290
|
+
(2026-10-21 用户:「排队标志在会话左边的图标里设计 1 个等待样式即可」)。 */
|
|
221
291
|
.srow__ico--queued{background:var(--tint-queued);color:var(--queued)}
|
|
292
|
+
.srow__ico--queued::after{content:"";position:absolute;inset:-2px;border-radius:50%;pointer-events:none;
|
|
293
|
+
border:2px solid var(--queued);animation:srow-breathe 2.2s ease-in-out infinite}
|
|
294
|
+
@keyframes srow-breathe{
|
|
295
|
+
0%,100%{opacity:.15}
|
|
296
|
+
50%{opacity:.55}}
|
|
297
|
+
@media (prefers-reduced-motion: reduce){.srow__ico--queued::after{animation:none;opacity:.4}}
|
|
222
298
|
/* 进行中:图标外围一圈圆形扫光(conic 渐隐光带,mask 挖空成环) */
|
|
223
299
|
.srow__ico--run::after{content:"";position:absolute;inset:-3px;border-radius:50%;pointer-events:none;
|
|
224
300
|
background:conic-gradient(from 0turn, transparent, var(--primary-action));
|
|
@@ -229,9 +305,49 @@ textarea{font:inherit}
|
|
|
229
305
|
@media (prefers-reduced-motion: reduce){.srow__ico--run::after{animation:none}}
|
|
230
306
|
.srow__col{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
231
307
|
.srow__t{font-size:15px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
232
|
-
|
|
308
|
+
/* 副标题:设备无关的一行文字(工程名或 引擎 · 模型),过长省略。 */
|
|
309
|
+
.srow__s{display:flex;align-items:center;gap:5px;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden}
|
|
310
|
+
.srow__stext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
233
311
|
.srow__time{flex:0 0 auto;font-size:12px;color:var(--muted)}
|
|
234
|
-
/*
|
|
312
|
+
/* 子会话(团队模式拆出的子任务,2026-10-22):**缩进挂在主会话下面**,不占顶层一行。
|
|
313
|
+
左侧一道竖线 + 小一档图标 —— 读起来是「这条主会话拆出去的活」;点开仍是它自己
|
|
314
|
+
那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。 */
|
|
315
|
+
.sblock{min-width:0}
|
|
316
|
+
.sblock__kids{margin:0 8px 6px 30px;padding-left:8px;border-left:2px solid var(--border)}
|
|
317
|
+
.srow--sub{padding:9px 8px 9px 4px;gap:10px}
|
|
318
|
+
.srow--sub .srow__ico{width:32px;height:32px}
|
|
319
|
+
.srow--sub .srow__ico svg{width:16px;height:16px}
|
|
320
|
+
.srow--sub .srow__t{font-size:14px;font-weight:500}
|
|
321
|
+
.srow--sub .srow__s,.srow--sub .srow__time{font-size:11px}
|
|
322
|
+
/* 团队会话的标志(2026-10-21):core 在 /desk/sessions 里给 mode:"team"。
|
|
323
|
+
不标的话,一条跑过 4 个子任务的会话在列表上与普通会话长得一模一样,
|
|
324
|
+
点进去却是一堆委派卡 —— 用户无从预期。
|
|
325
|
+
标志**就是图标本身**:团队会话的圆里换成「双人」图形,不再叠角标 ——
|
|
326
|
+
与排队标记同一套设计语言(一枚 20px 线图标 + 状态 tint 底,没有额外底色块),
|
|
327
|
+
--run / --queued 的外围状态环照旧生效。 */
|
|
328
|
+
/* 任务页页头(2026-10-21):标题 + 右侧搜索/头像胶囊 */
|
|
329
|
+
.tasks__tools{display:flex;align-items:center;gap:2px;flex:0 0 auto;padding:3px;
|
|
330
|
+
border-radius:var(--r-full);background:var(--subtle)}
|
|
331
|
+
.tasks__tools .topbar__icon{width:40px;height:40px}
|
|
332
|
+
/* 「我的」头像是截图里那枚金色圆头像:比搜索键小一圈,仍是 40px 点击区里的 34px 视觉圆。 */
|
|
333
|
+
.tasks__tools #btnTaskMe{width:34px;height:34px;margin:0 3px;border-radius:50%;
|
|
334
|
+
background:#E8DCB0;color:#4A3F1E}
|
|
335
|
+
/* 搜索态:输入框 + 关闭(搜索时隐藏快捷入口与分组行,结果平铺) */
|
|
336
|
+
.tasks__search{display:flex;align-items:center;gap:8px;flex:0 0 auto;padding:2px 16px 8px}
|
|
337
|
+
/* 搜索态就是截图 2 那一屏:标题行让位给搜索框(✕ 在框外,与截图一致)。 */
|
|
338
|
+
#history.is-searching .history__top{display:none}
|
|
339
|
+
#history.is-searching .tasks__search{padding-top:calc(env(safe-area-inset-top) + 8px)}
|
|
340
|
+
#history.is-searching .history__actions{display:none}
|
|
341
|
+
.tasks__searchbox{flex:1;display:flex;align-items:center;gap:8px;min-width:0;height:46px;
|
|
342
|
+
padding:0 14px;border-radius:var(--r-full);background:var(--subtle);color:var(--muted)}
|
|
343
|
+
/* 原生 search 的清空叉会和右侧的关闭按钮打架,藏掉(清空由用户自己退格)。 */
|
|
344
|
+
.tasks__searchbox input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}
|
|
345
|
+
.tasks__searchbox input{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);
|
|
346
|
+
font-family:inherit;font-size:16px;outline:none}
|
|
347
|
+
.tasks__searchbox input::placeholder{color:var(--muted)}
|
|
348
|
+
.tasks__searchx{flex:0 0 auto;width:44px;height:44px;border-radius:var(--r-full);
|
|
349
|
+
display:flex;align-items:center;justify-content:center;color:var(--ink)}
|
|
350
|
+
.tasks__searchx:active{background:var(--subtle)}
|
|
235
351
|
.history__actions{padding:2px 16px 8px}
|
|
236
352
|
.newtask{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;height:44px;border:1px solid var(--border);border-radius:var(--r-full);background:var(--raised);color:var(--ink);font-size:15px;font-weight:500}
|
|
237
353
|
.newtask:active{background:var(--subtle)}
|
|
@@ -241,8 +357,6 @@ textarea{font:inherit}
|
|
|
241
357
|
.sechead__chev.is-open{transform:rotate(90deg)}
|
|
242
358
|
.sechead__x{color:var(--muted);font-size:12px;padding:0 4px}
|
|
243
359
|
.more{padding:10px 8px;color:var(--primary-action);font-size:13px;font-weight:500;text-align:left}
|
|
244
|
-
.srow__pin{flex:0 0 auto;color:var(--faint);display:inline-flex}
|
|
245
|
-
.srow__pin.is-on{color:var(--primary-action)}
|
|
246
360
|
.wsrow__head{display:flex;align-items:center;gap:4px;padding:2px 0}
|
|
247
361
|
.wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
|
|
248
362
|
.wsrow__open:active{background:var(--subtle)}
|
|
@@ -254,6 +368,15 @@ textarea{font:inherit}
|
|
|
254
368
|
.wsrow__chev.is-open{transform:rotate(90deg)}
|
|
255
369
|
.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)}
|
|
256
370
|
.wsrow__add:active{background:var(--subtle)}
|
|
371
|
+
/* 每个空间自己的「整理」:需求页会同时列出多个空间,顶部那一枚只认当前会话的空间。
|
|
372
|
+
把入口放到组头,用户点哪个空间就初始化/增量哪个空间,不再猜「当前空间」是谁。 */
|
|
373
|
+
.wsrow__sync{flex:0 0 auto;height:32px;padding:0 10px;border-radius:var(--r-full);
|
|
374
|
+
font-size:12px;font-weight:500;color:var(--primary-action);background:var(--subtle);white-space:nowrap}
|
|
375
|
+
.wsrow__sync:active{opacity:.7}
|
|
376
|
+
.wsrow__sync:disabled{color:var(--muted);opacity:.6}
|
|
377
|
+
/* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
|
|
378
|
+
后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
|
|
379
|
+
.wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
|
|
257
380
|
/* 长按会话行的浮动菜单 */
|
|
258
381
|
.rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
|
|
259
382
|
border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
|
|
@@ -262,6 +385,30 @@ textarea{font:inherit}
|
|
|
262
385
|
.rowmenu__item:active{background:var(--subtle)}
|
|
263
386
|
.rowmenu__item--danger{color:var(--error-text)}
|
|
264
387
|
.rowmenu__l{flex:1;min-width:0}
|
|
388
|
+
/* 环境切换菜单(挂在任务页标题下):已保存的连接 + 「添加连接」。
|
|
389
|
+
与会话行长按菜单同一套浮层范式(scrim 挡住下层,点空白即关)。 */
|
|
390
|
+
.envmenu{position:fixed;z-index:1201;width:min(320px,calc(100vw - 24px));
|
|
391
|
+
background:var(--raised);border:1px solid var(--border);border-radius:18px;
|
|
392
|
+
box-shadow:0 18px 44px -14px var(--app-shadow);padding:6px}
|
|
393
|
+
.envmenu__scrim{position:fixed;inset:0;z-index:1200}
|
|
394
|
+
.envmenu__head{padding:10px 12px 6px;font-size:12px;font-weight:700;letter-spacing:.04em;color:var(--muted)}
|
|
395
|
+
.envmenu__item{display:flex;align-items:center;gap:12px;width:100%;padding:11px 12px;
|
|
396
|
+
border-radius:12px;text-align:left}
|
|
397
|
+
.envmenu__item:active{background:var(--subtle)}
|
|
398
|
+
.envmenu__ico{flex:0 0 auto;width:38px;height:38px;border-radius:50%;background:var(--subtle);
|
|
399
|
+
display:flex;align-items:center;justify-content:center;color:var(--ink)}
|
|
400
|
+
.envmenu__col{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
401
|
+
.envmenu__t{display:flex;align-items:center;gap:8px;min-width:0;font-size:15px;font-weight:600;color:var(--ink)}
|
|
402
|
+
.envmenu__name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
403
|
+
.envmenu__badge{flex:0 0 auto;padding:1px 8px;border-radius:var(--r-full);
|
|
404
|
+
font-size:11px;font-weight:600;line-height:17px;color:var(--primary-action);background:var(--subtle)}
|
|
405
|
+
.envmenu__s{font-size:12px;line-height:16px;color:var(--muted);
|
|
406
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
407
|
+
.envmenu__check{flex:0 0 auto;color:var(--primary-action)}
|
|
408
|
+
.envmenu__sep{height:1px;margin:6px 4px;background:var(--border)}
|
|
409
|
+
.envmenu__add{display:flex;align-items:center;gap:10px;width:100%;padding:12px;border-radius:12px;
|
|
410
|
+
font-size:15px;font-weight:500;color:var(--primary-action);text-align:left}
|
|
411
|
+
.envmenu__add:active{background:var(--subtle)}
|
|
265
412
|
/* toast(没有通知能力时的兜底提示) */
|
|
266
413
|
.toast{position:fixed;left:50%;bottom:calc(92px + env(safe-area-inset-bottom));transform:translateX(-50%);
|
|
267
414
|
max-width:82vw;padding:10px 14px;border-radius:12px;background:var(--raised);color:var(--ink);
|
|
@@ -313,6 +460,22 @@ button.setrow{font-family:inherit}
|
|
|
313
460
|
.setrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink)}
|
|
314
461
|
.setrow__v{flex:0 0 auto;font-size:14px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
|
|
315
462
|
.setrow--danger{color:var(--error-text)}
|
|
463
|
+
.setrow--danger .setrow__l,.setrow--danger .setrow__v{color:var(--error-text)}
|
|
464
|
+
/* 两行式配置行(团队模式):左列是「名字 + 这条适合什么」,右列是引擎。
|
|
465
|
+
规则描述必须常显 —— 用户要的是「每类适合什么」一眼可见,而不是藏进详情页。 */
|
|
466
|
+
.setrow__main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
467
|
+
.setrow__sub{font-size:12px;color:var(--faint);line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;
|
|
468
|
+
-webkit-box-orient:vertical;overflow:hidden}
|
|
469
|
+
/* 团队规则编辑:抽屉里的文本框(textarea 不继承 .history__hint 的行高/颜色) */
|
|
470
|
+
.teamedit{width:100%;min-height:76px;margin:6px 0 8px;padding:10px 12px;border-radius:10px;resize:vertical;
|
|
471
|
+
background:var(--subtle);border:1px solid var(--border);color:var(--ink);
|
|
472
|
+
font:13px/1.55 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;-webkit-appearance:none}
|
|
473
|
+
.teamedit::placeholder{color:var(--faint)}
|
|
474
|
+
.teamedit__acts{display:flex;gap:8px;margin-bottom:10px}
|
|
475
|
+
/* 团队抽屉里的分区标题(规则 / 交给谁)与行尾箭头:点开才有的配置必须看得出「这里能点」。 */
|
|
476
|
+
.pick__cap--team{padding:10px 4px 4px;font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--muted)}
|
|
477
|
+
.setrow__chev{flex:0 0 auto;color:var(--faint);font-size:16px;line-height:1}
|
|
478
|
+
.pick__cols--team{height:min(36vh,36dvh,300px)}
|
|
316
479
|
.seg{display:inline-flex;background:var(--subtle);border-radius:var(--r-full);padding:2px;gap:2px}
|
|
317
480
|
.seg__b{height:28px;padding:0 12px;border-radius:var(--r-full);font-size:13px;color:var(--muted)}
|
|
318
481
|
.seg__b.is-on{background:var(--raised);color:var(--ink);font-weight:600}
|
|
@@ -322,14 +485,121 @@ button.setrow{font-family:inherit}
|
|
|
322
485
|
.tgl.is-on::after{left:23px}
|
|
323
486
|
|
|
324
487
|
/* ══════════ 底部 Tab ══════════ */
|
|
325
|
-
|
|
488
|
+
/* 底栏**浮在内容之上**(2026-10-22 用户:「tab 跟会话列表一样是浮在上面」「不要挤压对话框」):
|
|
489
|
+
不再作为 flex 子项占位,改成 absolute 覆盖层 —— 内容(会话列表 / 对话)一直铺到屏幕底,
|
|
490
|
+
从栏背滚过去,靠半透明 + 18px 模糊透出来。这样栏高不进布局,会话页的输入框也不用被它顶上去。
|
|
491
|
+
|
|
492
|
+
代价是**每一页自己留出底部安全距离**,否则最后一行会被压在栏下面:
|
|
493
|
+
· `.history__list` 已有 96px(够 74 + 34 safe-area)
|
|
494
|
+
· 会话页由 composer 的下内边距负责,见下面的 `#conv .composer`
|
|
495
|
+
· 启动页不显示底栏(showLauncher 里 hidden),无需留白
|
|
496
|
+
|
|
497
|
+
min-height 而不是 height:底栏还要吞 safe-area(34px),
|
|
498
|
+
border-box 下固定 height 会让内部 tab 被压到 26px 高、图标文字挤成一团。
|
|
499
|
+
**74**(2026-10-22 定稿):68 时图标 23 + 选中浅底 28 + 标签 12 ≈ 50,上下只剩 18px;
|
|
500
|
+
74 给到上下各约 21px,选中浅底才有完整的一圈呼吸,不会被栏边切掉。 */
|
|
501
|
+
.tabbar{position:absolute;left:0;right:0;bottom:0;z-index:60;
|
|
502
|
+
display:flex;align-items:stretch;
|
|
503
|
+
min-height:74px;
|
|
504
|
+
background:color-mix(in srgb,var(--raised) 86%,transparent);
|
|
505
|
+
-webkit-backdrop-filter:blur(18px) saturate(1.4);backdrop-filter:blur(18px) saturate(1.4);
|
|
506
|
+
box-shadow:0 -1px 0 var(--border),0 -14px 30px -26px var(--app-shadow);
|
|
326
507
|
padding-bottom:env(safe-area-inset-bottom)}
|
|
327
508
|
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;
|
|
328
|
-
|
|
509
|
+
padding:6px 0;
|
|
510
|
+
color:var(--muted);font-size:10.5px;letter-spacing:.02em;transition:color .16s}
|
|
329
511
|
.tab.is-on{color:var(--primary-action)}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
512
|
+
/* 选中态不只换颜色,还给一块 38×28 的浅底(`--activity` 是现成的品牌色低透明令牌):
|
|
513
|
+
颜色是给"看得清"的,形状是给"摸得到"的 —— 暗色下主色是浅蓝,光靠颜色区分太弱。 */
|
|
514
|
+
.tab .icw{display:grid;place-items:center;width:38px;height:28px;border-radius:9px;
|
|
515
|
+
transition:background .18s ease}
|
|
516
|
+
.tab.is-on .icw{background:var(--activity)}
|
|
517
|
+
.tab .lb{line-height:1.15;transition:font-weight .16s}
|
|
518
|
+
.tab.is-on .lb{font-weight:600}
|
|
519
|
+
.tab svg{display:block;transition:transform .16s}
|
|
520
|
+
.tab:active svg{transform:scale(.92)}
|
|
521
|
+
/* ── 「快速新建」:**半嵌入**底栏的圆形主按钮(2026-10-22 定稿)
|
|
522
|
+
用户看完实机截图后的原话:「半嵌入式才对 要融合感更强」。
|
|
523
|
+
|
|
524
|
+
先说清楚「嵌」在视觉上到底靠什么成立 —— 这是前七版全部翻车的地方:
|
|
525
|
+
它**不是**靠按钮长得像半个(拱门/布片都不像),也**不是**靠给按钮加阴影
|
|
526
|
+
(外投影=浮起来,最典型的反效果),更**不是**靠按钮外面套一圈比它大的洞
|
|
527
|
+
(那是「放在一个盘子里」,不是「坐进槽里」)。
|
|
528
|
+
|
|
529
|
+
唯一成立的条件是一条:**按钮和底栏的接触边缘,必须比四周暗。**
|
|
530
|
+
光从上方来 → 按钮挡住光 → 它和槽壁相贴的那一圈落在阴影里;
|
|
531
|
+
阴影从接触处向**外**逐渐淡掉。方向反了就一定是光晕:
|
|
532
|
+
前几版用 `inset` 阴影,把亮度峰值放在了紧贴按钮的一圈(实测像素 246→220 由亮到暗),
|
|
533
|
+
于是看起来像按钮自带发光轮廓。
|
|
534
|
+
|
|
535
|
+
实现:
|
|
536
|
+
① **洞**:`::before` 是一块悬在栏顶边界内的半圆(宽 38*2 / 高 38,下两角半径 38),
|
|
537
|
+
填槽底 `--fab-well`,在底栏上边缘切出一个半圆缺口。
|
|
538
|
+
高度只到 38,**不侵入上方内容**(试过 78px 整圆,会盖住列表底部)。
|
|
539
|
+
② **嵌**:按钮直径 60,圆心落在栏顶边线上(上露 30 / 下沉 30),
|
|
540
|
+
于是下半实打实坐进洞里。圆心压边线而不是抬高,是「嵌」与「放」的分界。
|
|
541
|
+
③ **接触影**:`radial-gradient` 把暗环**锚定在 r=30(按钮半径)**上,
|
|
542
|
+
从 29.5px 起最暗、到 33px 淡完。这是唯一能精确控制「暗在哪一圈」的写法 ——
|
|
543
|
+
`box-shadow: inset` 的暗边永远从元素边界向内,管不到元素外的那道槽。
|
|
544
|
+
实测梯度(正下方):浅色 189→237→255,暗色 21→27→31,两端都单调、无回亮。
|
|
545
|
+
几何全用相对量(`bottom:calc(100% - 30px)`、`top:0`),安全区变化时洞和按钮一起走。 */
|
|
546
|
+
.tabfab{flex:0 0 76px}
|
|
547
|
+
/* 洞 = 半圆 + 接触影。
|
|
548
|
+
槽底是 `--fab-well`(栏面稍暗一档的**同色系**),不是 `--canvas`:
|
|
549
|
+
用页面底色在暗色下会成黑箍,在浅色下又几乎看不见 —— 两头都不对。
|
|
550
|
+
接触影必须画在槽底**之上**,且暗环两端用硬锚点,
|
|
551
|
+
避免模糊半径把亮度峰值推到按钮外面(那正是前几版读成光晕的原因)。
|
|
552
|
+
|
|
553
|
+
**露出的槽宽只有 3px**(洞半径 33 − 按钮半径 30)。曾经用 38(露 8px):
|
|
554
|
+
那圈灰色槽底读作「按钮下面垫了个盘子」,正是用户否掉的形态。
|
|
555
|
+
3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。
|
|
556
|
+
洞的高也取 33 而不是更深:槽只要在栏顶边界内切出缺口,深了只是浪费。 */
|
|
557
|
+
.tabbar::before{content:"";position:absolute;z-index:1;pointer-events:none;
|
|
558
|
+
left:50%;top:0;transform:translateX(-50%);
|
|
559
|
+
width:66px;height:33px;
|
|
560
|
+
border-radius:0 0 33px 33px;
|
|
561
|
+
background:
|
|
562
|
+
radial-gradient(circle 33px at 50% 0,
|
|
563
|
+
var(--fab-notch) 29.5px,
|
|
564
|
+
var(--fab-notch) 30.5px,
|
|
565
|
+
color-mix(in srgb,var(--fab-notch) 35%,transparent) 32px,
|
|
566
|
+
transparent 33px),
|
|
567
|
+
var(--fab-well)}
|
|
568
|
+
.tabfab__b{position:absolute;z-index:2;
|
|
569
|
+
left:50%;bottom:calc(100% - 30px); /* 圆心钉在栏顶边线上:上露 30 / 下沉 30 */
|
|
570
|
+
transform:translateX(-50%);
|
|
571
|
+
width:60px;height:60px;
|
|
572
|
+
display:flex;align-items:center;justify-content:center;
|
|
573
|
+
color:var(--on-primary-action);
|
|
574
|
+
background:linear-gradient(178deg,rgba(255,255,255,.14),rgba(255,255,255,0) 60%),var(--primary-action);
|
|
575
|
+
border:0;border-radius:50%;
|
|
576
|
+
box-shadow:
|
|
577
|
+
inset 0 -7px 13px -7px rgba(15,23,42,.50), /* 下半圈内侧暗边:光从上来 → 这一圈在阴影里 */
|
|
578
|
+
inset 0 3px 3px -2px rgba(255,255,255,.34); /* 上半圈内侧亮边:受光面 */
|
|
579
|
+
/* 没有外投影:嵌进去的东西不在平面上留影子 */
|
|
580
|
+
transition:transform .14s ease, box-shadow .14s ease}
|
|
581
|
+
.tabfab__b:active{transform:translateX(-50%) translateY(2px);
|
|
582
|
+
box-shadow:inset 0 -6px 12px -7px rgba(15,23,42,.56), inset 0 2px 3px -2px rgba(255,255,255,.26)}
|
|
583
|
+
.tabfab__b svg{display:block;stroke-width:2.4;transform:translateY(-1px)}
|
|
584
|
+
/* 会话页(2026-10-22 用户:「不要挤压对话框」「tab 跟会话列表一样是浮在上面」):
|
|
585
|
+
底栏浮起后不再占位,composer 只需让出「栏高 + 安全区 + 12px 呼吸」——
|
|
586
|
+
输入框贴着栏上沿 12px。原来 composer 下留 60(= +上探 30 + 呼吸 30),再叠上占位的 68 栏,
|
|
587
|
+
屏幕底到输入框一共 162;现在 74 + 34 + 12 = 120,对话区多出 42px,也再没有那段死区。
|
|
588
|
+
74 必须与 .tabbar 的 min-height 对齐(改栏高要一起改)。
|
|
589
|
+
**只叠一次**:safe-area 由这里一次性算进来,别再给 .composer 的基础 padding 加一遍。 */
|
|
590
|
+
#conv .composer{padding-bottom:calc(12px + 74px + env(safe-area-inset-bottom))}
|
|
591
|
+
|
|
592
|
+
/* 会话页的「+」收进槽里(不上浮):
|
|
593
|
+
输入框离栏只剩 12px,骑在边线上的 + 会顶到输入框下沿;而且这一屏已经有 composer
|
|
594
|
+
左侧的附件「+」,两个浮起的圆会让"哪个是新建"含混。
|
|
595
|
+
只改**高度关系**,不改颜色 / 水平位置:圆仍钉在正中,从"骑在边线上"变成"坐在栏里"。
|
|
596
|
+
直径 60 → 56 是跟着 74 的栏高走的(上下各留 9px),不是"缩小来躲"。 */
|
|
597
|
+
body.is-conv .tabbar::before{display:none}
|
|
598
|
+
body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-bottom) + 9px);
|
|
599
|
+
box-shadow:
|
|
600
|
+
inset 0 -6px 11px -6px rgba(15,23,42,.42),
|
|
601
|
+
inset 0 2px 2px -2px rgba(255,255,255,.28),
|
|
602
|
+
0 6px 14px -8px rgba(15,23,42,.35)}
|
|
333
603
|
|
|
334
604
|
/* 组件挂载点:占满剩余高度 */
|
|
335
605
|
#conv{flex:1;min-height:0;display:flex}
|
|
@@ -387,6 +657,21 @@ button.setrow{font-family:inherit}
|
|
|
387
657
|
.notice{border-radius:12px}
|
|
388
658
|
.conv__empty{font-size:14px;line-height:1.75}
|
|
389
659
|
|
|
660
|
+
/* 任务列表卡(团队模式一条应答里唯一那块,见 shared/conv 的 todoList):
|
|
661
|
+
共用皮肤给的是桌面字阶(10–11.5px 的汇总头与行),手机上偏小 —— 本壳按自己
|
|
662
|
+
14 / 13.5px 那一档抬一档;结构与状态色仍归 shared/conv/conv.css。 */
|
|
663
|
+
.qa-todo-panel{border-radius:14px;background:var(--raised)}
|
|
664
|
+
.qa-todo-panel .qa-todo-hd{height:42px;padding:0 12px;gap:8px}
|
|
665
|
+
.qa-todo-tt{font-size:14px;font-weight:600}
|
|
666
|
+
.qa-todo-sum{font-size:11.5px}
|
|
667
|
+
.qa-todo-list{padding:8px 12px 10px}
|
|
668
|
+
.qa-todo-row{gap:9px;padding:5px 0;font-size:13.5px;line-height:1.5}
|
|
669
|
+
.qa-todo-ico{width:14px;height:14px;border-width:1.5px}
|
|
670
|
+
.qa-todo-ico svg{width:9px;height:9px}
|
|
671
|
+
.qa-todo-meta{font-size:11px;max-width:52%}
|
|
672
|
+
.qa-todo-toggle{width:26px;height:26px}
|
|
673
|
+
.qa-todo-toggle svg{width:14px;height:14px}
|
|
674
|
+
|
|
390
675
|
/* 底部安全区:iPhone 的 home indicator 会压住输入框 */
|
|
391
676
|
.composer{padding-bottom:calc(16px + env(safe-area-inset-bottom))}
|
|
392
677
|
|
|
@@ -410,33 +695,72 @@ button.setrow{font-family:inherit}
|
|
|
410
695
|
/* 阅读页比对话流再大一号(手机上看文档,12.5px 偏小) */
|
|
411
696
|
.reader__body .md-body{font-size:14px;line-height:1.75}
|
|
412
697
|
|
|
698
|
+
/* 图片大图浮层:缩略图(移动端只取 480 宽)点开看原图。
|
|
699
|
+
黑底单张 contain、点任意处关闭 —— 手机上看细节的默认动作就是「点开、再点走」。
|
|
700
|
+
z-index 压过 .reader(1100):从文档里点图也要能盖上去。 */
|
|
701
|
+
.imgview{position:fixed;inset:0;z-index:1250;background:rgba(0,0,0,.94);
|
|
702
|
+
display:flex;align-items:center;justify-content:center;
|
|
703
|
+
padding:calc(12px + env(safe-area-inset-top)) 8px calc(12px + env(safe-area-inset-bottom))}
|
|
704
|
+
.imgview[hidden]{display:none}
|
|
705
|
+
.imgview img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
|
|
706
|
+
-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
|
|
707
|
+
.imgview__x{position:absolute;top:calc(10px + env(safe-area-inset-top));right:12px;
|
|
708
|
+
width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.16);
|
|
709
|
+
color:#fff;font-size:16px;line-height:1}
|
|
710
|
+
.imgview__nm{position:absolute;left:0;right:0;bottom:calc(14px + env(safe-area-inset-bottom));
|
|
711
|
+
padding:0 24px;text-align:center;color:rgba(255,255,255,.72);font-size:12px;
|
|
712
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
713
|
+
|
|
413
714
|
/* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════
|
|
414
715
|
盖住整页,瓶居中,绿雾在后。灌满后整屏淡出(`.is-out`),由下面的 JS 在最迟 1.6s 摘掉。
|
|
415
716
|
只用 transform / opacity:出现时主线程正在跑首屏初始化,动 layout 会把自己卡住。
|
|
416
717
|
`pointer-events:none` 写在 `.splash` 上:闪屏是动画不是弹窗,不能拦掉点击。 */
|
|
417
|
-
|
|
418
|
-
|
|
718
|
+
/* ══════════ 启动闪屏:掌天瓶灌绿液 ══════════
|
|
719
|
+
**瓶身必须与原生启动屏逐像素对齐**,否则两段动画之间会"腰斩":
|
|
720
|
+
原生那帧画 52×84dp 的瓶子,WebView 接上来只有一半大 —— 看起来像瓶子猛地缩了一下。
|
|
721
|
+
(2026-10-22 用户:「启动动画非常奇怪」。实测 170px → 85px,正好 2 倍:
|
|
722
|
+
原生 launch_screen.xml 是 52×84dp,这里被前几轮「再小一倍」改成了 26×42。)
|
|
723
|
+
现在两边都锚在 **26×42**:原生 drawable 缩到 26×42dp,这里保持 26×42 CSS px,
|
|
724
|
+
WebView 默认 1 CSS px = 1 dp,于是交接处**首尾同尺寸、同位置**。
|
|
725
|
+
|
|
726
|
+
定位也一起对齐:原生用 `gravity="center"` 把瓶子放在**屏幕正中**。
|
|
727
|
+
原来这里用 `flex-direction:column` + `gap`,瓶子会被下方名字顶上去 ~9px;
|
|
728
|
+
所以瓶子和雾都改成绝对定位到 (50%,50%),名字脱离文档流放在瓶子下面 ——
|
|
729
|
+
名字浮出来时瓶子一动不动,原生那帧和网页这帧的瓶心才是同一个点。 */
|
|
730
|
+
.splash{position:fixed;inset:0;z-index:2000;pointer-events:none;
|
|
419
731
|
background:var(--canvas);opacity:1;transition:opacity .38s ease}
|
|
420
732
|
/* 淡出后彻底撤掉(display:none 不够:要让它不再参与合成层) */
|
|
421
733
|
.splash[hidden]{display:none}
|
|
422
734
|
.splash.is-out{opacity:0}
|
|
423
735
|
|
|
424
736
|
/* 绿雾:比 loading 里那团更大更亮 —— 启动这一屏只有这一件东西,绿要当主角 */
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
737
|
+
/* 光晕跟着瓶身缩(瓶 26 宽 → 雾圈 60):不缩的话小瓶子会陷进一大团绿雾里 */
|
|
738
|
+
.splash__glow{position:absolute;left:50%;top:50%;width:60px;height:60px;
|
|
739
|
+
margin:-30px 0 0 -30px;border-radius:50%;pointer-events:none;
|
|
740
|
+
background:radial-gradient(circle,var(--q-bottle-glow, rgba(45,200,120,.30)) 0%,transparent 70%);
|
|
428
741
|
animation:splashGlow 2.4s ease-in-out infinite}
|
|
429
742
|
|
|
430
|
-
.
|
|
431
|
-
|
|
432
|
-
|
|
743
|
+
/* 尺寸与 viewBox 同比例(64:104):26×42 才不留空白,见 shared/conversation.css。
|
|
744
|
+
**这个数值必须与 android 的 launch_screen.xml / ic_splash_bottle.xml 一致** ——
|
|
745
|
+
两段动画交接时尺寸一变就读作"瓶子突然缩小"(2026-10-22 实测踩到,见文件顶部说明)。
|
|
746
|
+
绝对定位到屏幕正中:与原生 `gravity="center"` 同一个锚点,名字浮出来也不顶动它。 */
|
|
747
|
+
.splash__bottle{position:absolute;left:50%;top:50%;width:26px;height:42px;
|
|
748
|
+
margin:-21px 0 0 -13px;
|
|
749
|
+
color:var(--q-bottle-line, #43A06E)}
|
|
750
|
+
.splash__bottle .qbottle{display:block;width:26px;height:42px}
|
|
433
751
|
|
|
434
|
-
/*
|
|
435
|
-
|
|
752
|
+
/* 名字:等绿液灌到一半才浮出来(它在给「灌满了」这件事做收尾)。
|
|
753
|
+
绝对定位在瓶子下方 —— 它出现/位移都不能影响瓶子的位置,否则两段动画的瓶心会错开。 */
|
|
754
|
+
.splash__name{position:absolute;left:50%;top:50%;transform:translateX(-50%);
|
|
755
|
+
margin-top:30px;
|
|
756
|
+
font-family:'AA Caveat',cursive;font-size:19px;letter-spacing:.12em;
|
|
436
757
|
color:var(--muted);opacity:0;animation:splashName .5s ease .55s forwards}
|
|
437
758
|
|
|
438
759
|
@keyframes splashGlow{0%,100%{opacity:.5;transform:scale(.92)}50%{opacity:.95;transform:scale(1.06)}}
|
|
439
|
-
|
|
760
|
+
/* ⚠️ 名字用 translateX(-50%) 水平居中,所以关键帧里**只能写垂直位移**,
|
|
761
|
+
不能写 transform:none —— 那会把水平居中一起抹掉、名字跳到右边半屏。
|
|
762
|
+
用 margin-top 做上浮量(不动 transform 的 x 分量)。 */
|
|
763
|
+
@keyframes splashName{from{opacity:0;margin-top:34px}to{opacity:1;margin-top:30px}}
|
|
440
764
|
|
|
441
765
|
/* 用户开了「减弱动态效果」:不留空瓶子干等,直接给满瓶、不淡出不闪 —— 但**还是要出现**
|
|
442
766
|
(它是 App 的身份动作)。灌液动画与呼吸都关掉。 */
|
|
@@ -473,14 +797,31 @@ button.setrow{font-family:inherit}
|
|
|
473
797
|
<symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></symbol>
|
|
474
798
|
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
|
|
475
799
|
<symbol id="i-tune" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h8M18 7h2M4 17h2M12 17h8"/><circle cx="15" cy="7" r="2.2"/><circle cx="9" cy="17" r="2.2"/></symbol>
|
|
476
|
-
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.
|
|
477
|
-
|
|
800
|
+
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8.4" r="3.6"/><path d="M4.9 20.2a7.3 7.3 0 0 1 14.2 0"/></symbol>
|
|
801
|
+
<!-- 底栏「任务」= 两行勾选清单(无外框)。
|
|
802
|
+
不用对话气泡:气泡的语义是「聊天」,跟这里本来就有的会话概念重复;
|
|
803
|
+
清单能直接读出"待办/已完成",且**没有外框**,与「需求」的文档轮廓先分家。
|
|
804
|
+
24 网格 · 20 安全区 · 1.75 描边(2026-10-22 定稿) -->
|
|
805
|
+
<symbol id="i-task" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
|
806
|
+
<path d="M3.6 8.4 5.8 10.6 10.3 5.9"/><path d="M13 8.3h7.4"/>
|
|
807
|
+
<path d="M3.6 17.5 5.8 19.7 10.3 15"/><path d="M13 17.4h7.4"/>
|
|
808
|
+
</symbol>
|
|
809
|
+
<!-- 底栏「需求」= 有折角、有正文的规格文档(与上面的 i-doc 区分:那个是列表里的小图标,
|
|
810
|
+
可以留空壳;底栏这枚必须有"内容",否则读作占位符) -->
|
|
811
|
+
<symbol id="i-req" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
|
812
|
+
<path d="M13.7 3.6H7.9A3.3 3.3 0 0 0 4.6 6.9v10.2a3.3 3.3 0 0 0 3.3 3.3h8.2a3.3 3.3 0 0 0 3.3-3.3V9.5Z"/>
|
|
813
|
+
<path d="M13.7 3.6v4.3a1.6 1.6 0 0 0 1.6 1.6h4.3"/>
|
|
814
|
+
<path d="M8.3 13.1h7.4"/><path d="M8.3 16.8h4.8"/>
|
|
815
|
+
</symbol>
|
|
478
816
|
<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>
|
|
479
|
-
<symbol id="i-compass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.
|
|
817
|
+
<symbol id="i-compass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.6"/><path d="M15.2 8.8 13.4 13.4 8.8 15.2 10.6 10.6Z"/></symbol>
|
|
480
818
|
<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>
|
|
481
819
|
<symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4.5h10a1 1 0 0 1 1 1V20l-6-3.6L6 20V5.5a1 1 0 0 1 1-1z"/></symbol>
|
|
482
820
|
<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>
|
|
483
821
|
<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>
|
|
822
|
+
<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>
|
|
823
|
+
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><circle cx="11" cy="11" r="6.4"/><path d="m15.8 15.8 4 4"/></symbol>
|
|
824
|
+
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
|
|
484
825
|
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L19 9a2 2 0 0 0 0-3l-1-1a2 2 0 0 0-3 0L4 16z"/><path d="M14 6l4 4"/></symbol>
|
|
485
826
|
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/></symbol>
|
|
486
827
|
<symbol id="i-laptop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="6" width="14" height="9" rx="1.6"/><path d="M3 18.5h18"/></symbol>
|
|
@@ -496,11 +837,15 @@ button.setrow{font-family:inherit}
|
|
|
496
837
|
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M12 4.5l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z"/></symbol>
|
|
497
838
|
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 3.5v3.7h-3.7"/></symbol>
|
|
498
839
|
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="6.5" cy="12" r="2.4"/><circle cx="17" cy="6" r="2.4"/><circle cx="17" cy="18" r="2.4"/><path d="M8.7 10.9l6.1-3.6M8.7 13.1l6.1 3.6"/></symbol>
|
|
840
|
+
<symbol id="i-team" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-1.8a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4V21"/><circle cx="9.5" cy="7.5" r="3.6"/><path d="M21 21v-1.8a4 4 0 0 0-3-3.87"/><path d="M16 3.9a3.6 3.6 0 0 1 0 7.2"/></symbol>
|
|
499
841
|
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 11.5L12 4.5l7.5 7"/><path d="M6.5 10v9.5h11V10"/></symbol>
|
|
500
842
|
</svg>
|
|
501
843
|
|
|
502
844
|
<!-- ══════════ 启动页 ══════════ -->
|
|
503
845
|
<div class="launcher" id="launcher" hidden>
|
|
846
|
+
<button class="launcher__back" id="btnLauncherBack" type="button" hidden>
|
|
847
|
+
<svg width="18" height="18"><use href="#i-back"/></svg><span>返回</span>
|
|
848
|
+
</button>
|
|
504
849
|
<h1 class="launcher__title">magic-agent · 远程控制</h1>
|
|
505
850
|
<p class="launcher__desc">打开桌面配对页里的连接地址,远程继续你的会话。</p>
|
|
506
851
|
|
|
@@ -518,17 +863,32 @@ button.setrow{font-family:inherit}
|
|
|
518
863
|
|
|
519
864
|
<div class="recent" id="recent" hidden>
|
|
520
865
|
<div class="recent__head">
|
|
521
|
-
<span class="recent__title"
|
|
866
|
+
<span class="recent__title">已保存的连接</span>
|
|
522
867
|
<button class="recent__clear" id="btnClear">清空</button>
|
|
523
868
|
</div>
|
|
524
869
|
<div id="recentList"></div>
|
|
525
870
|
</div>
|
|
526
871
|
</div>
|
|
527
872
|
|
|
528
|
-
<!-- ══════════
|
|
873
|
+
<!-- ══════════ 任务列表(页头右侧:搜索 + 我的)══════════ -->
|
|
529
874
|
<div class="history" id="history" hidden>
|
|
530
875
|
<div class="history__top">
|
|
531
|
-
|
|
876
|
+
<!-- 标题即环境切换入口:点开列出已保存的多台桌面(2026-10-22) -->
|
|
877
|
+
<button class="history__title history__title--btn" type="button" id="btnEnvSwitch" aria-label="切换环境">
|
|
878
|
+
<span id="historyTitle">任务</span>
|
|
879
|
+
<svg class="history__title-chev" width="16" height="16"><use href="#i-chev-d"/></svg>
|
|
880
|
+
</button>
|
|
881
|
+
<span class="tasks__tools">
|
|
882
|
+
<button class="topbar__icon" type="button" id="btnTaskSearch" aria-label="搜索任务"><svg width="21" height="21"><use href="#i-search"/></svg></button>
|
|
883
|
+
<button class="topbar__icon" type="button" id="btnTaskMe" aria-label="我的"><svg width="21" height="21"><use href="#i-user"/></svg></button>
|
|
884
|
+
</span>
|
|
885
|
+
</div>
|
|
886
|
+
<div class="tasks__search" id="taskSearchBar" hidden>
|
|
887
|
+
<label class="tasks__searchbox">
|
|
888
|
+
<svg width="19" height="19"><use href="#i-search"/></svg>
|
|
889
|
+
<input id="taskSearchInput" type="search" placeholder="搜索任务" autocomplete="off" enterkeyhint="search">
|
|
890
|
+
</label>
|
|
891
|
+
<button class="tasks__searchx" type="button" id="btnTaskSearchClose" aria-label="关闭搜索"><svg width="22" height="22"><use href="#i-x"/></svg></button>
|
|
532
892
|
</div>
|
|
533
893
|
<div class="history__actions">
|
|
534
894
|
<button class="newtask" id="btnNewTask"><svg width="18" height="18"><use href="#i-plus"/></svg><span>新建任务</span></button>
|
|
@@ -575,7 +935,18 @@ button.setrow{font-family:inherit}
|
|
|
575
935
|
<div class="setrow"><span class="setrow__l">语言</span><span class="setrow__v">跟随系统</span></div>
|
|
576
936
|
<button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
|
|
577
937
|
</div>
|
|
938
|
+
<!-- 团队模式(2026-10-21):开关**按会话**给,放会话页右上角的运行设置里(与权限模式并排),
|
|
939
|
+
所以这里不展示全局面板状态;这里只放**这台设备的配置**入口 —— 具体每类任务交给谁
|
|
940
|
+
进子页面(#teamPage)再配,不在「我的」里铺五行。 -->
|
|
941
|
+
<div class="setcard" id="teamCard">
|
|
942
|
+
<button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
|
|
943
|
+
</div>
|
|
578
944
|
<div class="setcard">
|
|
945
|
+
<!-- 版本号(2026-10-22 用户:「移动端我的tab中显示版本号」)。
|
|
946
|
+
值由 /desk/health 的 version 给(= core 的 `--version`)—— 移动端界面是
|
|
947
|
+
桌面端 /m/ 远程加载的、本身不带版本,用户要看的是「连着的这份是哪一版」。
|
|
948
|
+
老 core 不给 version 时保持「—」,不写死。 -->
|
|
949
|
+
<div class="setrow"><span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span></div>
|
|
579
950
|
<button class="setrow" id="btnUpgrade"><span class="setrow__l">检查更新</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
|
|
580
951
|
</div>
|
|
581
952
|
<div class="setcard">
|
|
@@ -584,14 +955,56 @@ button.setrow{font-family:inherit}
|
|
|
584
955
|
</div>
|
|
585
956
|
</div>
|
|
586
957
|
|
|
587
|
-
<!-- ══════════
|
|
588
|
-
|
|
589
|
-
|
|
958
|
+
<!-- ══════════ 我的 → 团队模式(计划 + 7 类子任务)══════════
|
|
959
|
+
从「我的」那一行点进来。这里配的全都是**设备级偏好**(开关本身按会话,在会话页运行设置里)。
|
|
960
|
+
**只有一套划分**:计划 + 7 个角色(writer / researcher / reviewer / coder / designer /
|
|
961
|
+
director / builder)共 8 行,行内直接写清「这类适合什么」—— 不再并列「6 个类别」那一层
|
|
962
|
+
(用户 2026-10-22:「只需要计划+7类子任务即可 描述好每一类适合的规则 规则、引擎都可配置」)。
|
|
963
|
+
每行点开 = 引擎/模型 + 规则:计划配拆解粒度与两个开关,角色配「这类任务适合什么」
|
|
964
|
+
(写进 core 的拆解提示词,--team-rule role:<name>=…)。 -->
|
|
965
|
+
<div class="history" id="teamPage" hidden>
|
|
966
|
+
<div class="history__top">
|
|
967
|
+
<button class="topbar__icon" id="btnTeamBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
968
|
+
<span class="history__title">团队模式</span>
|
|
969
|
+
</div>
|
|
590
970
|
<div class="history__list">
|
|
591
|
-
<div class="
|
|
971
|
+
<div class="history__group">计划与 7 类子任务</div>
|
|
972
|
+
<div class="setcard" id="teamRouteCard"></div>
|
|
973
|
+
<div class="history__hint" style="text-align:left">点一行配置引擎与规则。留空 = 跟随主引擎 / 使用内置描述。</div>
|
|
592
974
|
</div>
|
|
593
975
|
</div>
|
|
594
976
|
|
|
977
|
+
<!-- ══════════ 会话页 → 任务运行设置(独立子页面)══════════
|
|
978
|
+
会话页右上角的调节键点进来(2026-10-21 用户:「运行设置在任务对话框里 不也应该是独立页面吗」)。
|
|
979
|
+
四项都是**当前这条会话**的运行参数:设备只读,其余三项点开还是原来的选择器(选择器只有一份)。 -->
|
|
980
|
+
<div class="history" id="runSettingsPage" hidden>
|
|
981
|
+
<div class="history__top">
|
|
982
|
+
<button class="topbar__icon" id="btnRunSettingsBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
983
|
+
<span class="history__title">任务运行设置</span>
|
|
984
|
+
</div>
|
|
985
|
+
<div class="history__list">
|
|
986
|
+
<div class="setcard">
|
|
987
|
+
<div class="setrow"><span class="setrow__l">设备</span><span class="setrow__v" id="rsDevice">本机</span></div>
|
|
988
|
+
<button class="setrow" id="btnRsWorkspace"><span class="setrow__l">工作空间</span><span class="setrow__v" id="rsWorkspace">默认(不属于任何空间)</span></button>
|
|
989
|
+
<button class="setrow" id="btnRsPermission"><span class="setrow__l">权限模式</span><span class="setrow__v" id="rsPermission">沿用默认</span></button>
|
|
990
|
+
<button class="setrow" id="btnRsTeam"><span class="setrow__l">团队模式</span><span class="setrow__v" id="rsTeam">关闭</span></button>
|
|
991
|
+
</div>
|
|
992
|
+
</div>
|
|
993
|
+
</div>
|
|
994
|
+
|
|
995
|
+
<!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
|
|
996
|
+
数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
|
|
997
|
+
按「工作区 → 模块 → 功能点」两级分组;点一行用既有的阅读器打开那篇 markdown。
|
|
998
|
+
⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
|
|
999
|
+
(用户 2026-10-22 定稿)。 -->
|
|
1000
|
+
<div class="history" id="reqs" hidden>
|
|
1001
|
+
<div class="history__top">
|
|
1002
|
+
<span class="history__title">需求</span>
|
|
1003
|
+
<button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>
|
|
1004
|
+
</div>
|
|
1005
|
+
<div class="history__list" id="reqList"></div>
|
|
1006
|
+
</div>
|
|
1007
|
+
|
|
595
1008
|
<!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
|
|
596
1009
|
<div class="history" id="discover" hidden>
|
|
597
1010
|
<div class="history__top"><span class="history__title">发现</span></div>
|
|
@@ -698,12 +1111,30 @@ button.setrow{font-family:inherit}
|
|
|
698
1111
|
<div class="reader__body" id="rdBody"></div>
|
|
699
1112
|
</div>
|
|
700
1113
|
|
|
701
|
-
<!-- ══════════
|
|
1114
|
+
<!-- ══════════ 图片大图浮层(缩略图点开看原图)══════════ -->
|
|
1115
|
+
<div class="imgview" id="imgview" hidden>
|
|
1116
|
+
<img id="imgviewImg" alt="" draggable="false">
|
|
1117
|
+
<button class="imgview__x" id="imgviewClose" type="button" aria-label="关闭">✕</button>
|
|
1118
|
+
<span class="imgview__nm" id="imgviewName"></span>
|
|
1119
|
+
</div>
|
|
1120
|
+
|
|
1121
|
+
<!-- ══════════ 底部 Tab ══════════
|
|
1122
|
+
五个槽位:任务 · 需求 ·「+」· 发现 · 我的。
|
|
1123
|
+
「定时任务」那个 tab 已删(2026-10-22 用户:「定时任务tab先删除」)——
|
|
1124
|
+
功能本身没删:会话菜单里的「定时任务」仍然能唤起,只是不再占一个底部入口
|
|
1125
|
+
(那一页本来也只有一句「还没有这个接口」的说明,占着导航反而误导)。
|
|
1126
|
+
中间那枚「+」不是 tab:它没有 data-tab、不参与 setActive,点了就是新建会话。 -->
|
|
702
1127
|
<nav class="tabbar" id="tabbar" hidden>
|
|
703
|
-
<button class="tab" type="button" data-tab="tasks"><svg width="
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1128
|
+
<button class="tab" type="button" data-tab="tasks"><span class="icw"><svg width="23" height="23"><use href="#i-task"/></svg></span><span class="lb">任务</span></button>
|
|
1129
|
+
<!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
|
|
1130
|
+
老 core 上点进来会由 loadRequirements 的 501 分支给出明确文案,
|
|
1131
|
+
不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
|
|
1132
|
+
<button class="tab" type="button" data-tab="reqs"><span class="icw"><svg width="23" height="23"><use href="#i-req"/></svg></span><span class="lb">需求</span></button>
|
|
1133
|
+
<span class="tabfab">
|
|
1134
|
+
<button class="tabfab__b" type="button" id="btnFabNew" aria-label="新建会话"><svg width="26" height="26"><use href="#i-plus"/></svg></button>
|
|
1135
|
+
</span>
|
|
1136
|
+
<button class="tab" type="button" data-tab="discover"><span class="icw"><svg width="23" height="23"><use href="#i-compass"/></svg></span><span class="lb">发现</span></button>
|
|
1137
|
+
<button class="tab" type="button" data-tab="mine"><span class="icw"><svg width="23" height="23"><use href="#i-user"/></svg></span><span class="lb">我的</span></button>
|
|
707
1138
|
</nav>
|
|
708
1139
|
<!-- 第三方库归口 shared/vendor(与内核共用一份):markdown 渲染栈要用 marked + DOMPurify;
|
|
709
1140
|
highlight.js 由内核按需加载,地址在下面给(内核自己不知道页面的相对根;
|
|
@@ -750,15 +1181,21 @@ button.setrow{font-family:inherit}
|
|
|
750
1181
|
var historyEl = document.getElementById('history');
|
|
751
1182
|
var historyList = document.getElementById('historyList');
|
|
752
1183
|
var historyTitle = document.getElementById('historyTitle');
|
|
1184
|
+
var taskSearchBar = document.getElementById('taskSearchBar');
|
|
1185
|
+
var taskSearchInput = document.getElementById('taskSearchInput');
|
|
753
1186
|
var mineEl = document.getElementById('mine');
|
|
754
1187
|
var mineDevice = document.getElementById('mineDevice');
|
|
1188
|
+
var mineVersion = document.getElementById('mineVersion');
|
|
755
1189
|
var segTheme = document.getElementById('segTheme');
|
|
756
1190
|
var segFont = document.getElementById('segFont');
|
|
757
1191
|
var segMode = document.getElementById('segMode');
|
|
758
1192
|
var tglHaptic = document.getElementById('tglHaptic');
|
|
759
1193
|
var tabbarEl = document.getElementById('tabbar');
|
|
760
|
-
var
|
|
1194
|
+
var reqsEl = document.getElementById('reqs');
|
|
1195
|
+
var reqListEl = document.getElementById('reqList');
|
|
761
1196
|
var discoverEl = document.getElementById('discover');
|
|
1197
|
+
var teamPageEl = document.getElementById('teamPage');
|
|
1198
|
+
var runSettingsPageEl = document.getElementById('runSettingsPage');
|
|
762
1199
|
var pluginsEl = document.getElementById('plugins');
|
|
763
1200
|
var pluginListEl = document.getElementById('pluginList');
|
|
764
1201
|
var pluginDetailEl = document.getElementById('pluginDetail');
|
|
@@ -781,6 +1218,9 @@ button.setrow{font-family:inherit}
|
|
|
781
1218
|
/* ── 当前连接:连上后由它驱动 transport ── */
|
|
782
1219
|
var BASE = '', TOKEN = '';
|
|
783
1220
|
var conv = null;
|
|
1221
|
+
/* 当前组件里装着哪条会话 / 哪个空间:同一条会话再次打开时直接复用这一屏
|
|
1222
|
+
(见 mountConversation)。返回列表只是 hideAll(),组件与画面本来就还在。 */
|
|
1223
|
+
var convSid = null, convWs = null;
|
|
784
1224
|
var WS = ''; // 当前工作空间('' = 默认,不传 -w)
|
|
785
1225
|
/* 工作空间要跨重启记住(与引擎 / 模型同一口径):只放内存的话,App 重启后
|
|
786
1226
|
「任务运行设置」里选的空间就回到默认,用户会以为没保存。 */
|
|
@@ -790,48 +1230,332 @@ button.setrow{font-family:inherit}
|
|
|
790
1230
|
提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
|
|
791
1231
|
var RUN_PREF = loadRunPref();
|
|
792
1232
|
var ENGINES = []; // 最近一次从 /desk/engines?models=1 拿到的清单
|
|
793
|
-
|
|
794
|
-
/*
|
|
795
|
-
|
|
796
|
-
|
|
1233
|
+
var ENGINES_STALE_AT = ''; // 非空 = 这份清单是插件侧的兜底缓存(探测超时/失败),值是缓存时间
|
|
1234
|
+
/* ── 团队模式(2026-10-21)──
|
|
1235
|
+
开关**跟着会话**(用户 2026-10-21:「团队模式开关是跟着会话的 不是改了所有会话都改了」):
|
|
1236
|
+
`sessions` 是 sid → 开/关 的映射,每条会话一份;`fresh` 只临时装「新会话还没拿到
|
|
1237
|
+
session_id 时」的开关(首帧带回 id 就落到 `sessions` 上,见 bindFreshTeam)。
|
|
1238
|
+
ROUTES:计划阶段与每类子任务各交给谁 ——
|
|
1239
|
+
`{plan:{engine,model}|null, text:…, code:…, image:…, video:…, game:…}`,null / 空引擎 = 跟随主引擎
|
|
1240
|
+
(五类一致,core 侧同一套语义:不单独指定就用主引擎 + 主模型)。
|
|
1241
|
+
路由与并行度才是**这台设备的偏好**;开关不是 —— 它属于某条会话。 */
|
|
1242
|
+
/* 团队配置子页面的 8 行 = 计划 + 7 个角色(角色清单见下面的 teamRoleList)。
|
|
1243
|
+
不再有「6 个类别」那一层:类别是角色的派生属性,界面上直接按角色配(用户 2026-10-22:
|
|
1244
|
+
「只需要计划+7类子任务即可」)。 */
|
|
1245
|
+
var TEAM_KIND_LABELS = { plan: '计划', text: '文本', code: '代码', image: '图像', video: '视频', game: '游戏' };
|
|
1246
|
+
/* ── 按角色指定路由(2026-10-22)──
|
|
1247
|
+
键是 `role:<name>`(core 的 --team-route 同口径),与上面的类别键共用一个
|
|
1248
|
+
`magic.team.routes.v1` —— teamConfig() 原样下发,不需要第二份配置。
|
|
1249
|
+
角色清单优先用 /desk/health 的 teamRoles(core 上报,客户端不硬编码);
|
|
1250
|
+
老 core 没有这个字段 → 空 → 回落到下面这份内置表(与 internal/team 一致)。 */
|
|
1251
|
+
var TEAM_ROLE_LABELS = {
|
|
1252
|
+
writer: '写作', researcher: '调研', reviewer: '评审', coder: '编码',
|
|
1253
|
+
designer: '视觉', director: '影像', builder: '工程'
|
|
1254
|
+
};
|
|
1255
|
+
var TEAM_ROLE_BUILTIN = [
|
|
1256
|
+
{ name: 'writer', kind: 'text', duty: '把材料写成结构清晰、可直接交付的文档' },
|
|
1257
|
+
{ name: 'researcher', kind: 'text', duty: '查证事实、比对方案,给出有依据的结论' },
|
|
1258
|
+
{ name: 'reviewer', kind: 'text', duty: '按验收标准挑问题,不重写别人的产出' },
|
|
1259
|
+
{ name: 'coder', kind: 'code', duty: '把复杂代码改动落到可构建、可测试的文件上' },
|
|
1260
|
+
{ name: 'designer', kind: 'image', duty: '产出可直接使用的画面资产' },
|
|
1261
|
+
{ name: 'director', kind: 'video', duty: '把脚本 / 分镜变成可播放的影像' },
|
|
1262
|
+
{ name: 'builder', kind: 'game', duty: '交付能跑起来的工程与可复现的启动方式' }
|
|
1263
|
+
];
|
|
1264
|
+
var TEAM_ROLES = []; // core 上报的角色清单;空 = 老 core(或还没连上)→ 用内置表
|
|
1265
|
+
function teamRoleList() { return TEAM_ROLES.length ? TEAM_ROLES : TEAM_ROLE_BUILTIN; }
|
|
1266
|
+
/* 路由键 → 界面文案(toast / 选择器标题共用)。 */
|
|
1267
|
+
function teamRouteLabel(key) {
|
|
1268
|
+
if (String(key).indexOf('role:') === 0) {
|
|
1269
|
+
var n = String(key).slice(5);
|
|
1270
|
+
return '角色「' + (TEAM_ROLE_LABELS[n] || n) + '」';
|
|
1271
|
+
}
|
|
1272
|
+
if (key === 'plan') return '计划任务';
|
|
1273
|
+
return TEAM_KIND_LABELS[key] + '类任务';
|
|
1274
|
+
}
|
|
1275
|
+
var TEAM = loadTeamPref();
|
|
1276
|
+
/* 行签名(8 行文案 + 引擎):不变就不重建,避免 5s 轮询时闪。 */
|
|
1277
|
+
var TEAM_ROLE_SIG = '';
|
|
1278
|
+
/* core 的能力位清单(/desk/health 的 features)。团队开关要不要显示就看它 ——
|
|
1279
|
+
**连上桌面后才有**,所以首屏是空数组(= 不支持 = 卡片藏起来)。
|
|
1280
|
+
⚠️ 声明必须与 TEAM 同批:首屏 paintTeamPref() 会读它,放到后面虽能靠 var 提升
|
|
1281
|
+
取到 undefined,但一取 .indexOf 就抛异常,整个页面初始化会在这里断掉。 */
|
|
1282
|
+
var CORE_FEATURES = [];
|
|
1283
|
+
function teamSupported() { return CORE_FEATURES.indexOf('feature.session.team') >= 0; }
|
|
1284
|
+
function loadTeamPref() {
|
|
1285
|
+
var o = { sessions: {}, fresh: false, routes: {}, rules: {}, parallel: 4, split: 'standard', splitCode: false, adaptive: true };
|
|
797
1286
|
try {
|
|
798
|
-
var
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1287
|
+
var m = JSON.parse(localStorage.getItem('magic.team.sessions.v1') || '{}');
|
|
1288
|
+
if (m && typeof m === 'object' && !Array.isArray(m)) {
|
|
1289
|
+
Object.keys(m).forEach(function (sid) { o.sessions[sid] = !!m[sid]; });
|
|
1290
|
+
}
|
|
1291
|
+
} catch (e) {}
|
|
1292
|
+
/* 旧版是全设备一个 `magic.team.on` —— 正是「改一条会话,所有会话都变」的来源。
|
|
1293
|
+
不迁移:把全局值抄给每条会话等于把 bug 换个地方;直接清掉,新会话默认关。 */
|
|
1294
|
+
try { localStorage.removeItem('magic.team.on'); } catch (e) {}
|
|
1295
|
+
try {
|
|
1296
|
+
var r = JSON.parse(localStorage.getItem('magic.team.routes.v1') || '{}');
|
|
1297
|
+
if (r && typeof r === 'object') o.routes = r;
|
|
1298
|
+
} catch (e) {}
|
|
1299
|
+
/* 角色规则覆盖(「这类任务适合什么」):键 role:<name>,留空/缺项 = 用 core 内置描述。 */
|
|
1300
|
+
try {
|
|
1301
|
+
var ru = JSON.parse(localStorage.getItem('magic.team.rules.v1') || '{}');
|
|
1302
|
+
if (ru && typeof ru === 'object' && !Array.isArray(ru)) {
|
|
1303
|
+
Object.keys(ru).forEach(function (k) {
|
|
1304
|
+
var v = String(ru[k] || '').replace(/\s+/g, ' ').trim();
|
|
1305
|
+
if (v) o.rules[k] = v;
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
} catch (e) {}
|
|
1309
|
+
try {
|
|
1310
|
+
var n = parseInt(localStorage.getItem('magic.team.parallel') || '', 10);
|
|
1311
|
+
if (n > 0) o.parallel = n;
|
|
1312
|
+
} catch (e) {}
|
|
1313
|
+
/* 拆解规则:三档只认 core 的三个值,脏值留在 standard(不让坏数据把请求打成 400)。 */
|
|
1314
|
+
try {
|
|
1315
|
+
var sp = localStorage.getItem('magic.team.split');
|
|
1316
|
+
if (sp === 'coarse' || sp === 'standard' || sp === 'fine') o.split = sp;
|
|
1317
|
+
} catch (e) {}
|
|
1318
|
+
try { o.splitCode = localStorage.getItem('magic.team.splitcode') === '1'; } catch (e) {}
|
|
1319
|
+
try { if (localStorage.getItem('magic.team.adaptive') === '0') o.adaptive = false; } catch (e) {}
|
|
1320
|
+
return o;
|
|
1321
|
+
}
|
|
1322
|
+
function saveTeamPref() {
|
|
1323
|
+
/* 最多留 400 条,别让 localStorage 随会话数无限长(超出按 key 顺序丢,只影响容量)。 */
|
|
1324
|
+
var ids = Object.keys(TEAM.sessions || {});
|
|
1325
|
+
if (ids.length > 400) {
|
|
1326
|
+
ids.slice(0, ids.length - 400).forEach(function (sid) { delete TEAM.sessions[sid]; });
|
|
1327
|
+
}
|
|
1328
|
+
try { localStorage.setItem('magic.team.sessions.v1', JSON.stringify(TEAM.sessions || {})); } catch (e) {}
|
|
1329
|
+
try { localStorage.setItem('magic.team.routes.v1', JSON.stringify(TEAM.routes || {})); } catch (e) {}
|
|
1330
|
+
try { localStorage.setItem('magic.team.rules.v1', JSON.stringify(TEAM.rules || {})); } catch (e) {}
|
|
1331
|
+
try { localStorage.setItem('magic.team.parallel', String(TEAM.parallel || 4)); } catch (e) {}
|
|
1332
|
+
try { localStorage.setItem('magic.team.split', TEAM.split || 'standard'); } catch (e) {}
|
|
1333
|
+
try { localStorage.setItem('magic.team.splitcode', TEAM.splitCode ? '1' : '0'); } catch (e) {}
|
|
1334
|
+
try { localStorage.setItem('magic.team.adaptive', TEAM.adaptive === false ? '0' : '1'); } catch (e) {}
|
|
1335
|
+
paintTeamPref();
|
|
1336
|
+
}
|
|
1337
|
+
/* 这条会话的开关:本地记过就用本地;没记过 → 跟会话自己的运行模式(core 的 `mode:"team"`)。
|
|
1338
|
+
新会话(还没有 sid)用 `fresh` —— 它**不跨会话继承**:每次新建任务都从「关」起。 */
|
|
1339
|
+
function teamOnFor(sid, s) {
|
|
1340
|
+
if (sid) {
|
|
1341
|
+
if (Object.prototype.hasOwnProperty.call(TEAM.sessions, sid)) return !!TEAM.sessions[sid];
|
|
1342
|
+
return !!(s && s.mode === 'team');
|
|
1343
|
+
}
|
|
1344
|
+
return !!TEAM.fresh;
|
|
1345
|
+
}
|
|
1346
|
+
/* 新会话的开关要等 core 给出 session_id 才落得下来(见 mountConversation 的 onEvent)。 */
|
|
1347
|
+
function bindFreshTeam(sid) {
|
|
1348
|
+
if (!TEAM.fresh || !sid) return;
|
|
1349
|
+
TEAM.sessions[String(sid)] = true;
|
|
1350
|
+
TEAM.fresh = false;
|
|
1351
|
+
saveTeamPref();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/* ══════════ 环境(多连接)══════════
|
|
1355
|
+
2026-10-22 用户:「移动端要支持环境切换 可以有多个连接」。
|
|
1356
|
+
一台手机可以记住多台桌面(家里 / 公司 / 另一台机器),任务页标题就是切换入口。
|
|
1357
|
+
键 = 地址 + 令牌:令牌是 magic-client 落盘复用、跨重启不变的机器身份(与排队队列的
|
|
1358
|
+
devKey 同一口径),地址会随 IP / 热点变化,所以不能只用地址。 */
|
|
1359
|
+
var CONN_KEY = 'magic.remote.conns.v1', CONN_MAX = 8;
|
|
1360
|
+
var LS_KEY = 'magic.remote.recent.v1'; // 旧版「最近连接」:只在首次升级时搬一次
|
|
1361
|
+
var ACTIVE_ID = '';
|
|
1362
|
+
|
|
1363
|
+
function connHost(url) {
|
|
1364
|
+
try { return new URL(url).host; }
|
|
1365
|
+
catch (e) { return String(url || '').replace(/^https?:\/\//, '').replace(/\/.*$/, ''); }
|
|
1366
|
+
}
|
|
1367
|
+
function connIdOf(t) { return String(t.url || '') + '|' + String(t.token || ''); }
|
|
1368
|
+
function loadConns() {
|
|
1369
|
+
var list;
|
|
1370
|
+
try { list = JSON.parse(localStorage.getItem(CONN_KEY) || '[]'); } catch (e) { list = []; }
|
|
1371
|
+
if (!Array.isArray(list)) list = [];
|
|
1372
|
+
list = list.filter(function (c) { return c && c.url; });
|
|
1373
|
+
if (!list.length) {
|
|
1374
|
+
/* 老版本只记最近 5 条连接:升级后并过来,用户不必重新扫一次码。 */
|
|
1375
|
+
try {
|
|
1376
|
+
var old = JSON.parse(localStorage.getItem(LS_KEY) || '[]');
|
|
1377
|
+
if (Array.isArray(old) && old.length) {
|
|
1378
|
+
list = old.filter(function (c) { return c && c.url; }).map(function (c) {
|
|
1379
|
+
return { id: connIdOf(c), url: c.url, token: c.token || '', name: c.name || '',
|
|
1380
|
+
host: connHost(c.url), app: '', at: c.at || 0 };
|
|
1381
|
+
});
|
|
1382
|
+
saveConns(list);
|
|
1383
|
+
}
|
|
1384
|
+
} catch (e) {}
|
|
1385
|
+
}
|
|
1386
|
+
return list;
|
|
1387
|
+
}
|
|
1388
|
+
function saveConns(list) {
|
|
1389
|
+
try { localStorage.setItem(CONN_KEY, JSON.stringify((list || []).slice(0, CONN_MAX))); } catch (e) {}
|
|
802
1390
|
}
|
|
803
|
-
function
|
|
804
|
-
|
|
805
|
-
var
|
|
806
|
-
|
|
807
|
-
|
|
1391
|
+
function findConn(id) {
|
|
1392
|
+
var list = loadConns();
|
|
1393
|
+
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
/* 记一条连接(连过就记住):扫码带的 name 与 health 报的 app 都比地址更适合当名字。 */
|
|
1397
|
+
function upsertConn(t) {
|
|
1398
|
+
var id = connIdOf(t);
|
|
1399
|
+
var old = findConn(id) || {};
|
|
1400
|
+
var list = loadConns().filter(function (c) { return c.id !== id; });
|
|
1401
|
+
var next = {
|
|
1402
|
+
id: id, url: t.url || '', token: t.token || '',
|
|
1403
|
+
name: (t.name != null && t.name !== '') ? t.name : (old.name || ''),
|
|
1404
|
+
host: connHost(t.url), app: old.app || '', at: Date.now()
|
|
1405
|
+
};
|
|
1406
|
+
list.unshift(next);
|
|
1407
|
+
saveConns(list);
|
|
1408
|
+
return next;
|
|
1409
|
+
}
|
|
1410
|
+
function removeConn(id) { saveConns(loadConns().filter(function (c) { return c.id !== id; })); }
|
|
1411
|
+
function clearConns() { saveConns([]); }
|
|
1412
|
+
|
|
1413
|
+
/* 当前连接:先按地址 + 令牌找;App 壳注入的连接还没落盘时给一条临时记录,
|
|
1414
|
+
这样标题与菜单里仍然看得见「现在连的是谁」。 */
|
|
1415
|
+
function activeConn() {
|
|
1416
|
+
var list = loadConns(), i;
|
|
1417
|
+
for (i = 0; i < list.length; i++) {
|
|
1418
|
+
if (list[i].url === BASE && String(list[i].token || '') === String(TOKEN || '')) return list[i];
|
|
1419
|
+
}
|
|
1420
|
+
for (i = 0; i < list.length; i++) if (ACTIVE_ID && list[i].id === ACTIVE_ID) return list[i];
|
|
1421
|
+
if (BASE || TOKEN) {
|
|
1422
|
+
return { id: connIdOf({ url: BASE, token: TOKEN }), url: BASE, token: TOKEN, name: '', host: connHost(BASE), app: '' };
|
|
1423
|
+
}
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
/* 环境显示名:扫码带的 name > 桌面 health 报的 app > host。 */
|
|
1427
|
+
function connLabel(c) {
|
|
1428
|
+
if (!c) return '';
|
|
1429
|
+
return c.name || c.app || c.host || connHost(c.url);
|
|
1430
|
+
}
|
|
1431
|
+
function envLabel() { return connLabel(activeConn()) || histSubtitle || ''; }
|
|
1432
|
+
|
|
1433
|
+
/* 桌面 health 回来的应用名(app)补进当前连接:它比 host 更像「这台设备是谁」。 */
|
|
1434
|
+
function noteActiveMeta(meta) {
|
|
1435
|
+
var c = activeConn();
|
|
1436
|
+
if (!c || !c.id) return;
|
|
1437
|
+
var list = loadConns(), changed = false;
|
|
1438
|
+
for (var i = 0; i < list.length; i++) {
|
|
1439
|
+
if (list[i].id !== c.id) continue;
|
|
1440
|
+
if (meta.app && list[i].app !== meta.app) { list[i].app = meta.app; changed = true; }
|
|
1441
|
+
if (!list[i].host) { list[i].host = connHost(list[i].url); changed = true; }
|
|
1442
|
+
}
|
|
1443
|
+
if (!changed) return;
|
|
1444
|
+
saveConns(list);
|
|
1445
|
+
if (!historyEl.hidden) historyTitle.textContent = envLabel() || '任务';
|
|
808
1446
|
}
|
|
809
|
-
function clearRecent() { saveRecent([]); }
|
|
810
1447
|
|
|
811
1448
|
function fmtTime(ms) {
|
|
812
1449
|
try { return new Date(ms).toLocaleString(); } catch (e) { return ''; }
|
|
813
1450
|
}
|
|
814
1451
|
function renderRecent() {
|
|
815
|
-
var list =
|
|
1452
|
+
var list = loadConns();
|
|
816
1453
|
recentListEl.innerHTML = '';
|
|
817
1454
|
if (!list.length) { recentEl.hidden = true; return; }
|
|
818
1455
|
recentEl.hidden = false;
|
|
819
1456
|
list.forEach(function (item) {
|
|
820
|
-
var
|
|
821
|
-
|
|
1457
|
+
var row = document.createElement('div');
|
|
1458
|
+
row.className = 'recent__row';
|
|
822
1459
|
var b = document.createElement('button');
|
|
823
1460
|
b.type = 'button';
|
|
824
1461
|
b.className = 'recent__item';
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1462
|
+
b.appendChild(elSpan('recent__t', connLabel(item) || item.host || item.url));
|
|
1463
|
+
b.appendChild(elSpan('recent__s', (item.host || connHost(item.url)) + ' · ' + fmtTime(item.at)));
|
|
1464
|
+
b.onclick = function () { connect({ url: item.url, token: item.token, name: item.name, host: item.host || connHost(item.url) }); };
|
|
1465
|
+
/* 多台机器就必然要能删:删掉只是忘掉这条记录,不打断当前连接。 */
|
|
1466
|
+
var del = document.createElement('button');
|
|
1467
|
+
del.type = 'button';
|
|
1468
|
+
del.className = 'recent__del';
|
|
1469
|
+
del.setAttribute('aria-label', '删除连接');
|
|
1470
|
+
del.appendChild(svgUse('i-trash', 18));
|
|
1471
|
+
del.onclick = function () { removeConn(item.id); renderRecent(); };
|
|
1472
|
+
row.appendChild(b); row.appendChild(del);
|
|
1473
|
+
recentListEl.appendChild(row);
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/* ── 环境切换菜单(任务页标题点开)── */
|
|
1478
|
+
var envMenuEl = null, envScrimEl = null;
|
|
1479
|
+
function closeEnvMenu() {
|
|
1480
|
+
if (envMenuEl) { envMenuEl.remove(); envMenuEl = null; }
|
|
1481
|
+
if (envScrimEl) { envScrimEl.remove(); envScrimEl = null; }
|
|
1482
|
+
var btn = document.getElementById('btnEnvSwitch');
|
|
1483
|
+
if (btn) btn.classList.remove('is-open');
|
|
1484
|
+
}
|
|
1485
|
+
/* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
|
|
1486
|
+
排队任务按令牌分别落库(见 QUEUE_KEY),旧环境那批留在原地,切回来再接着跑。 */
|
|
1487
|
+
function switchEnv(c) {
|
|
1488
|
+
if (!c) return;
|
|
1489
|
+
if (c.url === BASE && String(c.token || '') === String(TOKEN || '')) {
|
|
1490
|
+
toast('已在「' + (connLabel(c) || '当前环境') + '」');
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
|
|
1494
|
+
convEl.innerHTML = '';
|
|
1495
|
+
stopPoll();
|
|
1496
|
+
PENDING = []; ASK_WAITING = []; SERIAL = {}; QUEUE_RESTORED = false;
|
|
1497
|
+
SERVER_VERSION = ''; CORE_FEATURES = []; TEAM_ROLES = []; ENGINES = []; ENGINES_STALE_AT = '';
|
|
1498
|
+
TITLES = {}; LAST_SESSIONS = []; DW = ''; DW_READY = false; ROOT_DIRS = [];
|
|
1499
|
+
/* 工作空间属于某台机器的目录树:沿用旧路径会落到不存在的目录,一律回到默认。 */
|
|
1500
|
+
WS = '';
|
|
1501
|
+
saveWs();
|
|
1502
|
+
connect({ url: c.url, token: c.token, name: c.name, host: c.host || connHost(c.url) });
|
|
1503
|
+
toast('已切换到「' + (connLabel(c) || connHost(c.url)) + '」');
|
|
1504
|
+
}
|
|
1505
|
+
function openEnvMenu() {
|
|
1506
|
+
if (envMenuEl) { closeEnvMenu(); return; }
|
|
1507
|
+
var cur = activeConn();
|
|
1508
|
+
var list = loadConns();
|
|
1509
|
+
if (cur && !list.some(function (c) { return c.id === cur.id; })) list = [cur].concat(list);
|
|
1510
|
+
envScrimEl = document.createElement('div');
|
|
1511
|
+
envScrimEl.className = 'envmenu__scrim';
|
|
1512
|
+
envScrimEl.onclick = closeEnvMenu;
|
|
1513
|
+
envMenuEl = document.createElement('div');
|
|
1514
|
+
envMenuEl.className = 'envmenu';
|
|
1515
|
+
envMenuEl.appendChild(elSpan('envmenu__head', '切换环境'));
|
|
1516
|
+
list.forEach(function (c) {
|
|
1517
|
+
var active = !!(cur && c.id === cur.id);
|
|
1518
|
+
var b = document.createElement('button');
|
|
1519
|
+
b.type = 'button';
|
|
1520
|
+
b.className = 'envmenu__item';
|
|
1521
|
+
var ico = document.createElement('span');
|
|
1522
|
+
ico.className = 'envmenu__ico';
|
|
1523
|
+
ico.appendChild(svgUse('i-laptop', 20));
|
|
1524
|
+
b.appendChild(ico);
|
|
1525
|
+
var col = document.createElement('span');
|
|
1526
|
+
col.className = 'envmenu__col';
|
|
1527
|
+
var tl = document.createElement('span');
|
|
1528
|
+
tl.className = 'envmenu__t';
|
|
1529
|
+
tl.appendChild(elSpan('envmenu__name', connLabel(c) || c.host || '未命名'));
|
|
1530
|
+
if (c.app) tl.appendChild(elSpan('envmenu__badge', c.app));
|
|
1531
|
+
col.appendChild(tl);
|
|
1532
|
+
col.appendChild(elSpan('envmenu__s', (c.host || connHost(c.url)) + (active ? ' · 当前' : '')));
|
|
1533
|
+
b.appendChild(col);
|
|
1534
|
+
if (active) {
|
|
1535
|
+
var ck = document.createElement('span');
|
|
1536
|
+
ck.className = 'envmenu__check';
|
|
1537
|
+
ck.appendChild(svgUse('i-check', 18));
|
|
1538
|
+
b.appendChild(ck);
|
|
1539
|
+
}
|
|
1540
|
+
b.onclick = function () { closeEnvMenu(); switchEnv(c); };
|
|
1541
|
+
envMenuEl.appendChild(b);
|
|
834
1542
|
});
|
|
1543
|
+
envMenuEl.appendChild(elSpan('envmenu__sep', ''));
|
|
1544
|
+
var add = document.createElement('button');
|
|
1545
|
+
add.type = 'button';
|
|
1546
|
+
add.className = 'envmenu__add';
|
|
1547
|
+
add.appendChild(svgUse('i-plus', 18));
|
|
1548
|
+
add.appendChild(elSpan('', '添加连接'));
|
|
1549
|
+
add.onclick = function () { closeEnvMenu(); showLauncher(); };
|
|
1550
|
+
envMenuEl.appendChild(add);
|
|
1551
|
+
document.body.appendChild(envScrimEl);
|
|
1552
|
+
document.body.appendChild(envMenuEl);
|
|
1553
|
+
var btn = document.getElementById('btnEnvSwitch');
|
|
1554
|
+
var r = btn.getBoundingClientRect();
|
|
1555
|
+
var mw = envMenuEl.offsetWidth;
|
|
1556
|
+
envMenuEl.style.top = (r.bottom + 8) + 'px';
|
|
1557
|
+
envMenuEl.style.left = Math.max(12, Math.min(r.left, window.innerWidth - mw - 12)) + 'px';
|
|
1558
|
+
btn.classList.add('is-open');
|
|
835
1559
|
}
|
|
836
1560
|
|
|
837
1561
|
/* ══════════ 解析 + 校验连接地址 ══════════
|
|
@@ -861,21 +1585,33 @@ button.setrow{font-family:inherit}
|
|
|
861
1585
|
}
|
|
862
1586
|
|
|
863
1587
|
/* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
|
|
864
|
-
|
|
1588
|
+
/* 离开会话页要摘掉 is-conv,否则底栏的「+」会一直保持"收进槽里"的形态 */
|
|
1589
|
+
function hideAll() { document.body.classList.remove('is-conv'); launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; reqsEl.hidden = true; discoverEl.hidden = true; teamPageEl.hidden = true; runSettingsPageEl.hidden = true; pluginsEl.hidden = true; pluginDetailEl.hidden = true; sessionEl.hidden = true; }
|
|
865
1590
|
function setActive(name) {
|
|
866
1591
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
867
1592
|
b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
|
|
868
1593
|
});
|
|
869
1594
|
}
|
|
870
|
-
function showLauncher() {
|
|
1595
|
+
function showLauncher() {
|
|
1596
|
+
hideAll(); launcherEl.hidden = false; tabbarEl.hidden = true; renderRecent();
|
|
1597
|
+
/* 已连着桌面时进来(切换环境 → 添加连接)必须有路可退,否则用户被困在启动页。 */
|
|
1598
|
+
var back = document.getElementById('btnLauncherBack');
|
|
1599
|
+
if (back) back.hidden = !(BASE || TOKEN);
|
|
1600
|
+
}
|
|
871
1601
|
function showHistory(address) {
|
|
872
1602
|
hideAll(); historyEl.hidden = false; tabbarEl.hidden = false; setActive('tasks');
|
|
873
1603
|
if (address != null) histSubtitle = address;
|
|
874
|
-
historyTitle.textContent =
|
|
1604
|
+
historyTitle.textContent = envLabel() || '任务';
|
|
875
1605
|
loadSessions();
|
|
876
1606
|
}
|
|
877
|
-
function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
|
|
878
|
-
|
|
1607
|
+
function showSession(sub) { hideAll(); document.body.classList.add('is-conv'); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
|
|
1608
|
+
/* 需求页:进页面即拉一次清单(GET /desk/requirements,只读、不调模型)。
|
|
1609
|
+
「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
|
|
1610
|
+
两者代价差三个数量级,绝不能混成一个动作。 */
|
|
1611
|
+
function showReqs() {
|
|
1612
|
+
hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
|
|
1613
|
+
loadRequirements(false);
|
|
1614
|
+
}
|
|
879
1615
|
function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
|
|
880
1616
|
function showMine() {
|
|
881
1617
|
hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
|
|
@@ -886,6 +1622,50 @@ button.setrow{font-family:inherit}
|
|
|
886
1622
|
tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
|
|
887
1623
|
paintRunPref();
|
|
888
1624
|
refreshVpn();
|
|
1625
|
+
/* 版本号:先用已拉到的值立刻画(列表页的 /desk/health 顺手带回来的),
|
|
1626
|
+
再补一次请求 —— 用户可能一连上就直接点「我的」,那时列表那次还没回来。 */
|
|
1627
|
+
paintMineVersion();
|
|
1628
|
+
refreshMineVersion();
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/* ══════════ 版本号(「我的」)══════════
|
|
1632
|
+
来源:桌面端 `GET /desk/health` 的 `version`(= core 的 `--version`,ldflags 注入)。
|
|
1633
|
+
为什么不是移动端自己写死一个常量:这份界面是桌面端 `/m/` 远程加载的,页面文件跟着
|
|
1634
|
+
桌面端一起升级;只有服务端报的版本才是「用户此刻连着的这份」。
|
|
1635
|
+
health 没有 version(插件是旧构建、但磁盘上的 core 已经是新的)时,再问一次
|
|
1636
|
+
`/desk/contract` —— 它原样代理 core 的 envelope,新 core 里带 version。这样
|
|
1637
|
+
**不必重启正在跑的服务**就能显示真实版本。仍然没有(老 core)就保持「—」,不编。 */
|
|
1638
|
+
var SERVER_VERSION = '';
|
|
1639
|
+
function paintMineVersion() {
|
|
1640
|
+
if (!mineVersion) return;
|
|
1641
|
+
mineVersion.textContent = SERVER_VERSION ? ('v' + SERVER_VERSION) : '—';
|
|
1642
|
+
}
|
|
1643
|
+
function refreshMineVersion() {
|
|
1644
|
+
if (!TOKEN) { paintMineVersion(); return; }
|
|
1645
|
+
var hdr = { 'X-Magic-Token': TOKEN };
|
|
1646
|
+
fetch(BASE + '/desk/health', { headers: hdr })
|
|
1647
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1648
|
+
.then(function (h) {
|
|
1649
|
+
if (h && typeof h.version === 'string' && h.version) { SERVER_VERSION = h.version; return; }
|
|
1650
|
+
return fetch(BASE + '/desk/contract', { headers: hdr })
|
|
1651
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1652
|
+
.then(function (c) {
|
|
1653
|
+
if (c && typeof c.version === 'string' && c.version) SERVER_VERSION = c.version;
|
|
1654
|
+
})
|
|
1655
|
+
.catch(function () {});
|
|
1656
|
+
})
|
|
1657
|
+
.then(paintMineVersion)
|
|
1658
|
+
.catch(function () { paintMineVersion(); }); // 网络抖动:保留旧值,不把「—」又闪回来
|
|
1659
|
+
}
|
|
1660
|
+
/* 「我的」→ 团队模式:整块配置进子页面(与插件列表同一套子页面范式)。 */
|
|
1661
|
+
function showTeamPage() {
|
|
1662
|
+
hideAll(); teamPageEl.hidden = false; tabbarEl.hidden = true;
|
|
1663
|
+
paintTeamPref();
|
|
1664
|
+
}
|
|
1665
|
+
/* 会话页 → 任务运行设置:同一条子页面范式(返回键 + 标题 + 卡片),不再用底部抽屉。 */
|
|
1666
|
+
function showRunSettings() {
|
|
1667
|
+
hideAll(); runSettingsPageEl.hidden = false; tabbarEl.hidden = true;
|
|
1668
|
+
paintRunSettings();
|
|
889
1669
|
}
|
|
890
1670
|
function markSeg(seg, v) {
|
|
891
1671
|
Array.prototype.forEach.call(seg.children, function (b) {
|
|
@@ -1332,20 +2112,73 @@ button.setrow{font-family:inherit}
|
|
|
1332
2112
|
var PREFS = loadPrefs();
|
|
1333
2113
|
applyThemePref(PREFS.theme); applyFontPref(PREFS.font); applyHapticPref(PREFS.haptic);
|
|
1334
2114
|
paintRunPref();
|
|
2115
|
+
paintTeamPref(); // 团队配置卡片(能力位要到连上桌面后才亮,见 loadSessions)
|
|
1335
2116
|
|
|
1336
2117
|
/* 挂 conversation:传输层由宿主注入,组件不连后端(见 CLIENT-CONTRACT.md 3.2.1)。
|
|
1337
2118
|
sid 非空 = 打开该会话(组件会走 readSession 回放历史);s = 该会话对象(标题取 prompt_head);空 = 新会话。 */
|
|
1338
2119
|
function mountConversation(sid, s) {
|
|
2120
|
+
/* 同一条会话、同一个空间:组件还活着,画面也还在 —— 直接把这一屏端回来。
|
|
2121
|
+
以前这里无条件 destroy() + create() + innerHTML='',于是「返回列表 → 再点进来」
|
|
2122
|
+
每次都要重拉全量历史、重新建 DOM、重新回放(长会话 = 又慢又费流量)。
|
|
2123
|
+
标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。 */
|
|
2124
|
+
if (conv && sid && sid === convSid && WS === convWs) {
|
|
2125
|
+
try { conv.setSession(sid, s); } catch (e) {}
|
|
2126
|
+
window.magicConv = conv;
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
1339
2129
|
if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
|
|
1340
2130
|
convEl.innerHTML = '';
|
|
2131
|
+
convSid = sid || null;
|
|
2132
|
+
convWs = WS;
|
|
2133
|
+
/* 每次重建组件都丢掉「上一条还没落定的新会话」的临时开关 —— 否则用户在新会话里
|
|
2134
|
+
开了团队但没发消息,转去打开一条历史会话时,那条会话的首帧会把开关误记成开启。
|
|
2135
|
+
老会话的开关由 teamOnFor 从它自己那份记录里取;新会话从「关」起。 */
|
|
2136
|
+
TEAM.fresh = false;
|
|
1341
2137
|
try {
|
|
1342
2138
|
var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN, client: 'mobile' });
|
|
1343
2139
|
conv = window.MagicConversation.create({
|
|
1344
2140
|
el: convEl, transport: transport, titleEl: titleEl, workspace: WS,
|
|
1345
2141
|
engine: RUN_PREF.engine, model: RUN_PREF.model,
|
|
1346
2142
|
showPermission: false, showAttach: true, beforeAsk: gateAsk,
|
|
2143
|
+
onInterrupt: cancelWaitingAsk,
|
|
2144
|
+
/* 组件内排队也要落库(2026-10-21 用户:「非团队的串行排队也没落库」):
|
|
2145
|
+
会话正在跑、或第一句还压在串行门里时又提交的那一句,原先只在组件内存里,
|
|
2146
|
+
刷新就消失。这三个钩子把它接进下面同一套 PENDING(magic.serial.queue.v1),
|
|
2147
|
+
重开 App 由 restoreQueue 接着发。 */
|
|
2148
|
+
onQueuePush: queuePushed,
|
|
2149
|
+
onQueueTake: queueTaken,
|
|
2150
|
+
onQueueDrop: queueDropped,
|
|
1347
2151
|
onPick: rememberRunPick,
|
|
1348
|
-
|
|
2152
|
+
/* 团队模式:开关初值按**这条会话**给;配置是设备偏好(每轮现读,改配置立刻生效)。
|
|
2153
|
+
features 也给函数:能力清单是异步拉到的(/desk/health),
|
|
2154
|
+
查到之后开关才会亮出来,不必重新挂载组件。 */
|
|
2155
|
+
team: teamOnFor(sid, s),
|
|
2156
|
+
onTeamChange: onTeamToggleFromConv,
|
|
2157
|
+
teamConfig: function () {
|
|
2158
|
+
return {
|
|
2159
|
+
routes: TEAM.routes || {},
|
|
2160
|
+
rules: TEAM.rules || {},
|
|
2161
|
+
parallel: TEAM.parallel,
|
|
2162
|
+
split: TEAM.split || 'standard',
|
|
2163
|
+
splitCode: !!TEAM.splitCode,
|
|
2164
|
+
adaptive: TEAM.adaptive !== false
|
|
2165
|
+
};
|
|
2166
|
+
},
|
|
2167
|
+
features: function () { return CORE_FEATURES; },
|
|
2168
|
+
/* 新会话的开关先记在 TEAM.fresh:core 的首帧带回 session_id 时,当场绑到这条会话上
|
|
2169
|
+
(否则返回列表再点进来就找不到它了)。 */
|
|
2170
|
+
onEvent: function (ev) { bindFreshTeam(ev && ev.session_id); },
|
|
2171
|
+
/* 菜单里的「定时任务」还在,但底部那个 tab 已经删了(2026-10-22 用户:
|
|
2172
|
+
「定时任务tab先删除」)。现在点它给一句说明,而不是跳去一张只有一个占位符的页 ——
|
|
2173
|
+
原先那页的正文就是这句说明,页面本身没有承载任何数据。
|
|
2174
|
+
core 补上清单接口后,这里换成真正的调度页即可。 */
|
|
2175
|
+
onMenu: function (key) {
|
|
2176
|
+
if (key === 'scheduled') {
|
|
2177
|
+
/* openComingSheet 走 textContent(不是 innerHTML),所以这里给纯文本,别塞 <br>。 */
|
|
2178
|
+
openComingSheet('定时任务',
|
|
2179
|
+
'magic-agent 当前没有「定时任务清单」接口。需要的话可以按 core 的自动化能力接上。');
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
1349
2182
|
});
|
|
1350
2183
|
if (sid) conv.setSession(sid, s);
|
|
1351
2184
|
window.magicConv = conv;
|
|
@@ -1357,25 +2190,52 @@ button.setrow{font-family:inherit}
|
|
|
1357
2190
|
}
|
|
1358
2191
|
}
|
|
1359
2192
|
function openSession(sid, s) {
|
|
1360
|
-
|
|
2193
|
+
/* ⚠️ 一律以会话自己的归属为准,**不能**只在其有 workspace 时才改 WS(2026-10-21):
|
|
2194
|
+
原写法 `if (s && s.workspace) { WS = s.workspace; }` 会让「打开一条默认空间的会话」
|
|
2195
|
+
保留上一条会话的空间 —— 于是点进默认会话、发追问,实际跑在了上一个空间里,
|
|
2196
|
+
而设置面板显示的空间名也跟着串。空 workspace = 默认,同样要写回(写成 '')。 */
|
|
2197
|
+
WS = (s && s.workspace) || '';
|
|
2198
|
+
saveWs();
|
|
1361
2199
|
showSession(s && s.workspace ? basename(s.workspace) : histSubtitle);
|
|
1362
2200
|
mountConversation(sid, s);
|
|
1363
2201
|
}
|
|
1364
|
-
|
|
2202
|
+
/* ws:不传 = 默认(不属于任何空间);传字符串 = 在该空间里新建(列表里空间组的「+」)。
|
|
2203
|
+
⚠️ 「新建任务」大按钮必须从**默认**起(2026-10-21 用户:「大的新建会话 空间应该是
|
|
2204
|
+
默认 不是上一次历史的空间选择」)。WS 是跨会话记忆的:打开过某个空间的会话后,
|
|
2205
|
+
它就一直粘在 WS 上,于是下一次点「新建任务」会静默落进上次那个空间 —— 用户完全
|
|
2206
|
+
没有选过它,却拿到了别的目录。归属只在两种情况下确定:显式选空间,或点空间组的「+」。 */
|
|
2207
|
+
function newConversation(ws) {
|
|
2208
|
+
WS = ws || '';
|
|
2209
|
+
saveWs();
|
|
2210
|
+
showSession();
|
|
2211
|
+
mountConversation(null);
|
|
2212
|
+
}
|
|
1365
2213
|
|
|
1366
2214
|
function connect(target) {
|
|
1367
2215
|
BASE = target.url || '';
|
|
1368
2216
|
TOKEN = target.token || '';
|
|
1369
|
-
|
|
2217
|
+
var c = upsertConn(target);
|
|
2218
|
+
ACTIVE_ID = c.id;
|
|
1370
2219
|
showError('');
|
|
1371
|
-
showHistory(
|
|
2220
|
+
showHistory(c.host || '');
|
|
1372
2221
|
startPoll();
|
|
2222
|
+
restoreQueue(); // 上次没轮到的排队任务:连上就接着排(见 restoreQueue)
|
|
1373
2223
|
}
|
|
1374
2224
|
function disconnect() {
|
|
1375
2225
|
stopPoll();
|
|
2226
|
+
closeEnvMenu();
|
|
1376
2227
|
if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
|
|
1377
2228
|
convEl.innerHTML = '';
|
|
1378
2229
|
BASE = ''; TOKEN = '';
|
|
2230
|
+
ACTIVE_ID = '';
|
|
2231
|
+
SERVER_VERSION = ''; // 版本号属于「那一条连接」:断开就清掉,避免下次连到别的机器还显示旧值
|
|
2232
|
+
/* 排队任务**不跟着断连消失**(它们已经落库):只清内存里的等待态,下次连上由
|
|
2233
|
+
restoreQueue 从磁盘接回来。串行门的 FIFO 也一并重置 —— 里面的 tail 是挂在一条
|
|
2234
|
+
已废弃页面上的 promise,留着会让新连接的第一条永远排在它后面。 */
|
|
2235
|
+
PENDING = [];
|
|
2236
|
+
ASK_WAITING = [];
|
|
2237
|
+
SERIAL = {};
|
|
2238
|
+
QUEUE_RESTORED = false;
|
|
1379
2239
|
showLauncher();
|
|
1380
2240
|
}
|
|
1381
2241
|
|
|
@@ -1399,8 +2259,12 @@ button.setrow{font-family:inherit}
|
|
|
1399
2259
|
进度那一列不靠重绘:RUN_TICK 每 3s 就地刷「第 N 步」与时长(见 tickRunning)。 */
|
|
1400
2260
|
var LIST_POLL = null;
|
|
1401
2261
|
function sessionSig(list) {
|
|
2262
|
+
/* 带 status:会话级状态变化(status 变了、row 的 state 可能没变)要能触发重绘。
|
|
2263
|
+
父字段也要比:子会话**挂在哪条主会话下面**是结构信息,父会话晚到/换 id 时
|
|
2264
|
+
清单若判「没变」就不重绘,子会话会一直留在顶层或挂错地方。 */
|
|
1402
2265
|
return (list || []).map(function (s) {
|
|
1403
|
-
return sidOf(s) + ':' + (s.state || '') + ':' + (s.updated_at || '')
|
|
2266
|
+
return sidOf(s) + ':' + (s.status || '') + ':' + (s.state || '') + ':' + (s.updated_at || '')
|
|
2267
|
+
+ ':' + (s.parent_session_id || '') + ':' + (s.parent_run_id || '');
|
|
1404
2268
|
}).join('|');
|
|
1405
2269
|
}
|
|
1406
2270
|
function startListPoll() {
|
|
@@ -1437,9 +2301,28 @@ button.setrow{font-family:inherit}
|
|
|
1437
2301
|
Promise.all([pList, pTitles, pHealth, pDirs]).then(function (a) {
|
|
1438
2302
|
TITLES = a[1] || {};
|
|
1439
2303
|
DW = (a[2] && a[2].defaultWorkspace) || '';
|
|
2304
|
+
DW_READY = true;
|
|
1440
2305
|
SPACES_ROOT = (a[2] && a[2].workspacesRoot) || '';
|
|
2306
|
+
/* core 版本(2026-10-22):/desk/health 的 version,顺手缓存,「我的」页打开即用。
|
|
2307
|
+
老 core 没有该字段 → 不覆盖已有值(可能刚由 refreshMineVersion 拿到)。 */
|
|
2308
|
+
if (a[2] && a[2].version) SERVER_VERSION = a[2].version;
|
|
2309
|
+
if (!mineEl.hidden) paintMineVersion();
|
|
2310
|
+
/* core 能力位(feature.*):团队开关要不要显示就看它。连上桌面后才有,
|
|
2311
|
+
所以「我的」那张卡片与运行设置里的那一行都要在这里刷一次。 */
|
|
2312
|
+
CORE_FEATURES = ((a[2] && a[2].features) || []);
|
|
2313
|
+
/* 连接的身份(app):连着哪台设备就记到哪条连接上,环境菜单里当类型标签用。 */
|
|
2314
|
+
if (a[2] && a[2].app) noteActiveMeta({ app: a[2].app });
|
|
2315
|
+
/* 团队角色清单(2026-10-22):core 上报什么就画什么,客户端不硬编码角色名。
|
|
2316
|
+
老 core 没有 teamRoles → 空数组 → paintTeamPref 用内置表兜底。 */
|
|
2317
|
+
TEAM_ROLES = ((a[2] && a[2].teamRoles) || []).filter(function (r) {
|
|
2318
|
+
return r && r.name;
|
|
2319
|
+
});
|
|
2320
|
+
paintTeamPref();
|
|
1441
2321
|
ROOT_DIRS = (((a[3] && a[3].dirs) || [])).filter(function (d) { return String(d.name).indexOf('magic-') === 0; });
|
|
1442
2322
|
renderSessions(Array.isArray(a[0]) ? a[0] : []);
|
|
2323
|
+
/* 排队任务的接续要等 DW 到手(空间键靠它归一)—— 首次列表到手正是这个时刻。
|
|
2324
|
+
幂等:QUEUE_RESTORED 保证只接一次。之前若因 DW 未就绪被跳过,这里补上。 */
|
|
2325
|
+
restoreQueue();
|
|
1443
2326
|
/* 给**还没归纳过**的会话补一次(core 幂等,已有的直接回 cached 不重复调 LLM)。
|
|
1444
2327
|
判定认 core 的 `title_summarized`,**不能**认「title 为空」——
|
|
1445
2328
|
core 的 title 恒非空(没归纳就回落 prompt_head),拿空判会永远不触发
|
|
@@ -1460,23 +2343,31 @@ button.setrow{font-family:inherit}
|
|
|
1460
2343
|
var SEC_COLLAPSED = {};
|
|
1461
2344
|
var TASKS_EXPANDED = false;
|
|
1462
2345
|
var WS_OPEN = {};
|
|
2346
|
+
/* 任务页分组口径:置顶 / 任务 / 空间分段;搜索态(TASK_QUERY 非空)恒为平铺 ——
|
|
2347
|
+
跨分组找同一条任务才有意义。 */
|
|
2348
|
+
var TASK_QUERY = '';
|
|
1463
2349
|
var PINNED = [];
|
|
1464
2350
|
try { PINNED = JSON.parse(localStorage.getItem('magic.pinned') || '[]') || []; } catch (e) { PINNED = []; }
|
|
1465
2351
|
function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} }
|
|
1466
2352
|
var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
|
|
1467
2353
|
var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
|
|
2354
|
+
var DW_READY = false; // DW 是否已从 health 落定(空间键归一依赖它,见 restoreQueue)
|
|
1468
2355
|
var SPACES_ROOT = ''; // 工作空间根(health.workspacesRoot)
|
|
1469
2356
|
var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
|
|
1470
2357
|
var CUSTOM_SPACES = [];
|
|
1471
2358
|
try { CUSTOM_SPACES = JSON.parse(localStorage.getItem('magic.spaces') || '[]') || []; } catch (e) { CUSTOM_SPACES = []; }
|
|
1472
2359
|
function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} }
|
|
1473
|
-
/* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
|
|
2360
|
+
/* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
|
|
2361
|
+
⚠️ 会话的 workspace 为空时**不要**拿 DW 顶上(原写法 `s.workspace || DW`):
|
|
2362
|
+
那样「默认(不指定)」与「默认目录名」会变成两行同名选项,一个落「任务」段、
|
|
2363
|
+
一个落「空间」段,用户点哪个都说不清(2026-10-21 报「无法选择默认」)。
|
|
2364
|
+
没有 workspace 的会话本来就属于「默认」,不该在候选里再出现一次。 */
|
|
1474
2365
|
function collectSpaceKeys() {
|
|
1475
2366
|
var seen = {}, out = [];
|
|
1476
2367
|
function add(p) { if (p && !seen[p]) { seen[p] = 1; out.push(p); } }
|
|
1477
2368
|
(ROOT_DIRS || []).forEach(function (d) { add(d.path); });
|
|
1478
2369
|
CUSTOM_SPACES.forEach(add);
|
|
1479
|
-
(LAST_SESSIONS || []).forEach(function (s) { add(s.workspace
|
|
2370
|
+
(LAST_SESSIONS || []).forEach(function (s) { add(s && s.workspace); });
|
|
1480
2371
|
return out;
|
|
1481
2372
|
}
|
|
1482
2373
|
/* 会话标题:**以 core 给的 title 为准**(2026-10-15,用户「应该 magic-agent 统一归纳作为
|
|
@@ -1486,6 +2377,31 @@ button.setrow{font-family:inherit}
|
|
|
1486
2377
|
TITLES 仍作为**兜底**:老版本 core 的 --sessions 没有 title 字段。 */
|
|
1487
2378
|
function titleOf(s) { return s.title || TITLES[sidOf(s)] || s.prompt_head || String(sidOf(s)).slice(0, 8); }
|
|
1488
2379
|
|
|
2380
|
+
/* ── 任务页:工程名(用于「分组视图」与搜索命中)──
|
|
2381
|
+
工程名取会话 workspace 的目录名;没有 workspace 的会话用桌面默认工作空间 DW 顶上
|
|
2382
|
+
(它确实跑在那儿:core 用调用方 cwd)。 */
|
|
2383
|
+
function projectOf(s) {
|
|
2384
|
+
var w = (s && s.workspace) || DW || '';
|
|
2385
|
+
return w ? basename(w) : '';
|
|
2386
|
+
}
|
|
2387
|
+
/* 搜索口径:标题 / 工程名 / 引擎 / 模型 / 首问,任一处命中即可(大小写不敏感)。 */
|
|
2388
|
+
function taskMatches(s) {
|
|
2389
|
+
if (!TASK_QUERY) return true;
|
|
2390
|
+
var hay = [titleOf(s), projectOf(s), s.engine || '', qaModelText(s.model) || '', s.prompt_head || '']
|
|
2391
|
+
.join(' ').toLowerCase();
|
|
2392
|
+
return hay.indexOf(TASK_QUERY) >= 0;
|
|
2393
|
+
}
|
|
2394
|
+
/* 搜索态:隐藏分组行,结果平铺(对齐截图 2)。关掉时清空词并回到原口径。 */
|
|
2395
|
+
function setTaskSearch(on) {
|
|
2396
|
+
taskSearchBar.hidden = !on;
|
|
2397
|
+
var box = document.getElementById('history');
|
|
2398
|
+
if (box) box.classList.toggle('is-searching', !!on);
|
|
2399
|
+
TASK_QUERY = '';
|
|
2400
|
+
taskSearchInput.value = '';
|
|
2401
|
+
renderSessions(LAST_SESSIONS);
|
|
2402
|
+
if (on) { try { taskSearchInput.focus(); } catch (e) {} }
|
|
2403
|
+
}
|
|
2404
|
+
|
|
1489
2405
|
/* ── 让 core 归纳标题(POST /desk/session/{id}/title/summarize)──
|
|
1490
2406
|
为什么需要它:归纳口径已收进 core,但**要有人去触发**。桌面端有客户端在触发,
|
|
1491
2407
|
移动端此前没有 —— 于是从手机发起的会话永远没人替它归纳,只能显示首问原文
|
|
@@ -1604,18 +2520,27 @@ button.setrow{font-family:inherit}
|
|
|
1604
2520
|
}
|
|
1605
2521
|
return h;
|
|
1606
2522
|
}
|
|
1607
|
-
/* 会话状态:由 /desk/sessions 的
|
|
2523
|
+
/* 会话状态:由 /desk/sessions 的 status(会话级统一状态,见 core 的 session-status.json)
|
|
2524
|
+
映射;老版本 CLI 不给 status 时回落 state。running / waiting_approval / done / 其它。 */
|
|
1608
2525
|
function statusOf(s) {
|
|
1609
|
-
var st = String(s.state || '');
|
|
2526
|
+
var st = String((s && (s.status || s.state)) || '');
|
|
1610
2527
|
if (st === 'running' || s.alive === true) return 'run';
|
|
1611
2528
|
if (st === 'waiting_approval') return 'wait';
|
|
1612
2529
|
if (st === 'done') return 'done';
|
|
1613
2530
|
return 'idle';
|
|
1614
2531
|
}
|
|
1615
|
-
/*
|
|
2532
|
+
/* 会话行图标:团队会话=双人图形(mode:"team"),其余**设备形状按来源端**
|
|
2533
|
+
(mobile=手机 / 其它=电脑);状态一律用颜色区分(无角标)。
|
|
2534
|
+
这条会话有追问在排队时(FUP 里有它),图标切到 queued 这一种等待样式 ——
|
|
2535
|
+
排队标记**只做在图标里**,行尾不再挂角标
|
|
2536
|
+
(2026-10-21 用户:「排队标志在会话左边的图标里设计 1 个等待样式即可」)。
|
|
2537
|
+
团队标记同理:直接换图形,不再叠小圆角标。 */
|
|
1616
2538
|
function sessionIcon(s) {
|
|
1617
|
-
var
|
|
1618
|
-
ico
|
|
2539
|
+
var st = (FUP[sidOf(s)] || []).length ? 'queued' : statusOf(s);
|
|
2540
|
+
var ico = elSpan('srow__ico srow__ico--' + st);
|
|
2541
|
+
var glyph = (s && s.mode === 'team') ? '#i-team'
|
|
2542
|
+
: (String(s && s.source) === 'mobile' ? '#i-phone' : '#i-laptop');
|
|
2543
|
+
ico.appendChild(svgUse(glyph, 20));
|
|
1619
2544
|
return ico;
|
|
1620
2545
|
}
|
|
1621
2546
|
/* ── 运行中任务的**真实进度** ──
|
|
@@ -1633,7 +2558,10 @@ button.setrow{font-family:inherit}
|
|
|
1633
2558
|
var m = Math.floor(sec / 60), ss = sec % 60;
|
|
1634
2559
|
return m >= 60 ? (Math.floor(m / 60) + ':' + pad2(m % 60) + ':' + pad2(ss)) : (m + ':' + pad2(ss));
|
|
1635
2560
|
}
|
|
1636
|
-
function runningSub(s, steps) {
|
|
2561
|
+
function runningSub(s, steps) {
|
|
2562
|
+
var p = projectOf(s);
|
|
2563
|
+
return (steps ? '运行中 · 第 ' + steps + ' 步' : '运行中') + (p ? ' · ' + p : '');
|
|
2564
|
+
}
|
|
1637
2565
|
/* 取「已跑到第几步」。**读过的部分不再重读**(2026-10-20):
|
|
1638
2566
|
原来每 3s 对每条 running 会话拉一次全量 `/messages`,只为数 tool_use 的条数 ——
|
|
1639
2567
|
长会话下等于让手机每 3 秒解析一遍几百 MB 的历史(列表页的主要卡顿来源)。
|
|
@@ -1678,20 +2606,45 @@ button.setrow{font-family:inherit}
|
|
|
1678
2606
|
function ensureTicker() {
|
|
1679
2607
|
if (!RUN_TICK && Object.keys(RUN_ROWS).length) RUN_TICK = setInterval(tickRunning, 3000);
|
|
1680
2608
|
}
|
|
1681
|
-
|
|
2609
|
+
/* 子会话(团队模式拆出的子任务,2026-10-22):core 在 /desk/sessions 里给出
|
|
2610
|
+
parent_session_id / parent_run_id(见 internal/session 的 Record)。子任务是**普通会话**
|
|
2611
|
+
(自己的登记 + 自己的日志,点进去看完整过程),列表里只做两件事:
|
|
2612
|
+
· 不占顶层一行 —— 缩进挂在主会话下面(CHILD_OF:父 key → 子会话数组);
|
|
2613
|
+
· 不进「任务 / 空间」分组(它们的 workspace 是主会话里的任务目录,不该自成一个空间)。
|
|
2614
|
+
⚠️ 父会话不在列表里(被删 / 老 core 没给父字段)→ 回落成顶层一行,
|
|
2615
|
+
宁可多一行也不能让子会话凭空消失。
|
|
2616
|
+
⚠️ 主会话可能还没拿到 session_id,parent_session_id 同时给了 parent_run_id 兜底。 */
|
|
2617
|
+
var CHILD_OF = {}, CHILD_IDS = {};
|
|
2618
|
+
function parentKeyOf(s) { return (s && (s.parent_session_id || s.parent_run_id)) || ''; }
|
|
2619
|
+
function sessionBlock(s) {
|
|
2620
|
+
var wrap = document.createElement('div');
|
|
2621
|
+
wrap.className = 'sblock';
|
|
2622
|
+
wrap.appendChild(s.pendingWait ? pendingRow(s) : sessionRow(s, !!CHILD_IDS[sidOf(s)]));
|
|
2623
|
+
var kids = CHILD_OF[sidOf(s)] || [];
|
|
2624
|
+
if (kids.length) {
|
|
2625
|
+
var box = document.createElement('div');
|
|
2626
|
+
box.className = 'sblock__kids';
|
|
2627
|
+
kids.forEach(function (k) { box.appendChild(sessionRow(k, true)); });
|
|
2628
|
+
wrap.appendChild(box);
|
|
2629
|
+
}
|
|
2630
|
+
return wrap;
|
|
2631
|
+
}
|
|
2632
|
+
function sessionRow(s, sub) {
|
|
1682
2633
|
var b = document.createElement('button');
|
|
1683
|
-
b.type = 'button'; b.className = 'srow';
|
|
2634
|
+
b.type = 'button'; b.className = 'srow' + (sub ? ' srow--sub' : '');
|
|
1684
2635
|
b.setAttribute('data-sid', sidOf(s)); // ensureTitle 就地改标题时要按 id 找这一行
|
|
1685
2636
|
var run = statusOf(s) === 'run';
|
|
1686
2637
|
b.appendChild(sessionIcon(s));
|
|
1687
2638
|
var col = elSpan('srow__col');
|
|
1688
2639
|
col.appendChild(elSpan('srow__t', titleOf(s)));
|
|
1689
2640
|
var sid = sidOf(s);
|
|
2641
|
+
/* 副标题 = 引擎 · 模型;运行中换成「运行中 · 第 N 步」。
|
|
2642
|
+
进度只改这一个文本节点(RUN_ROWS.subEl 指向它)。 */
|
|
1690
2643
|
var subEl = elSpan('srow__s', run ? runningSub(s, RUN_STEPS[sid] || 0)
|
|
1691
|
-
: [s.engine || '', s.model
|
|
2644
|
+
: [s.engine || '', qaModelText(s.model)].filter(Boolean).join(' · '));
|
|
1692
2645
|
col.appendChild(subEl);
|
|
1693
|
-
|
|
1694
|
-
b.appendChild(col);
|
|
2646
|
+
/* 排队与团队标记都不在这里做:两者都做在图标里(见 sessionIcon),行尾不再挂角标。 */
|
|
2647
|
+
b.appendChild(col);
|
|
1695
2648
|
var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
|
|
1696
2649
|
b.appendChild(timeEl);
|
|
1697
2650
|
if (run) { RUN_ROWS[sid] = { subEl: subEl, timeEl: timeEl, s: s }; refreshProgress(sid); }
|
|
@@ -1703,6 +2656,10 @@ button.setrow{font-family:inherit}
|
|
|
1703
2656
|
b.addEventListener('pointerup', cancel);
|
|
1704
2657
|
b.addEventListener('pointerleave', cancel);
|
|
1705
2658
|
b.addEventListener('pointercancel', cancel);
|
|
2659
|
+
/* 桌面端把「右键」当「长按」:PC 上没有长按习惯,右键必须能出同一个菜单
|
|
2660
|
+
(2026-10-22 用户:「桌面这个没有兼容pc端 比如右键无法出选择」)。
|
|
2661
|
+
顺手 preventDefault 掉系统菜单,免得和浮动菜单一起冒出来。 */
|
|
2662
|
+
b.addEventListener('contextmenu', function (e) { e.preventDefault(); cancel(); openRowMenu(b, s); });
|
|
1706
2663
|
b.onclick = function () { if (lp) { lp = false; return; } openSession(sidOf(s), s); };
|
|
1707
2664
|
return b;
|
|
1708
2665
|
}
|
|
@@ -1727,10 +2684,10 @@ button.setrow{font-family:inherit}
|
|
|
1727
2684
|
tog.onclick = function () { WS_OPEN[key] = !open; renderSessions(LAST_SESSIONS); };
|
|
1728
2685
|
var add = document.createElement('button'); add.type = 'button'; add.className = 'wsrow__add';
|
|
1729
2686
|
add.setAttribute('aria-label', '在此空间新建会话'); add.appendChild(svgUse('#i-plus', 16));
|
|
1730
|
-
add.onclick = function () {
|
|
2687
|
+
add.onclick = function () { newConversation(key); };
|
|
1731
2688
|
head.appendChild(tog); head.appendChild(add);
|
|
1732
2689
|
wrap.appendChild(head);
|
|
1733
|
-
if (open) list.forEach(function (s) { wrap.appendChild(
|
|
2690
|
+
if (open) list.forEach(function (s) { wrap.appendChild(sessionBlock(s)); });
|
|
1734
2691
|
return wrap;
|
|
1735
2692
|
}
|
|
1736
2693
|
function renderSessions(list) {
|
|
@@ -1746,12 +2703,60 @@ button.setrow{font-family:inherit}
|
|
|
1746
2703
|
rows.sort(function (a, b) {
|
|
1747
2704
|
return String(b.updated_at || b.started_at || '').localeCompare(String(a.updated_at || a.started_at || ''));
|
|
1748
2705
|
});
|
|
2706
|
+
/* 子会话归类(团队模式):父会话在列表里的,子会话从顶层摘走、改挂到父会话下面;
|
|
2707
|
+
父会话不在列表里的(被删 / 父字段缺失)留作顶层一行 —— 不能凭空消失。
|
|
2708
|
+
子会话内部按 started_at 正序(与任务列表 t1..tn 同序,倒序会看得莫名其妙)。 */
|
|
2709
|
+
/* ⚠️ 认父要**按 id 别名**认,不能只按 sidOf 比字面:子会话创建时父会话还没拿到
|
|
2710
|
+
session_id,core 只能把 parent_run_id 写进登记(见 internal/session 的 Record)——
|
|
2711
|
+
等父会话的 session_id 回来,sidOf(父) 就变成 session_id 了,而子会话手里仍是 run_id,
|
|
2712
|
+
字面比对必然失配 → 子会话掉回顶层、又被当成 workspace 自成一个「空间」。
|
|
2713
|
+
KEY2SID 把 session_id 与 run_id 都指向同一个规范 key(sidOf),认父时统一折算。 */
|
|
2714
|
+
var KEY2SID = {};
|
|
2715
|
+
rows.forEach(function (s) {
|
|
2716
|
+
var sid = sidOf(s);
|
|
2717
|
+
if (!sid) return;
|
|
2718
|
+
KEY2SID[sid] = sid;
|
|
2719
|
+
if (s.run_id) KEY2SID[s.run_id] = sid;
|
|
2720
|
+
if (s.session_id) KEY2SID[s.session_id] = sid;
|
|
2721
|
+
});
|
|
2722
|
+
CHILD_OF = {}; CHILD_IDS = {};
|
|
2723
|
+
var top = [];
|
|
2724
|
+
rows.forEach(function (s) {
|
|
2725
|
+
var pk = KEY2SID[parentKeyOf(s)] || '';
|
|
2726
|
+
if (pk && pk !== sidOf(s)) {
|
|
2727
|
+
(CHILD_OF[pk] = CHILD_OF[pk] || []).push(s);
|
|
2728
|
+
CHILD_IDS[sidOf(s)] = 1;
|
|
2729
|
+
} else top.push(s);
|
|
2730
|
+
});
|
|
2731
|
+
Object.keys(CHILD_OF).forEach(function (k) {
|
|
2732
|
+
CHILD_OF[k].sort(function (a, b) {
|
|
2733
|
+
return String(a.started_at || '').localeCompare(String(b.started_at || ''));
|
|
2734
|
+
});
|
|
2735
|
+
});
|
|
2736
|
+
rows = top;
|
|
2737
|
+
/* ⚠️ **追问排队不占一行**(2026-10-21 用户:「追问排队不要出现一个新会话啊」):
|
|
2738
|
+
带 session 的排队条目属于它那条会话,行还在的话只在那一行上加个「追问排队中」角标
|
|
2739
|
+
(见 sessionRow 里的 FUP 判断)。否则列表凭空多出一条「新任务」,看着像用户自己
|
|
2740
|
+
又开了一个会话 —— 而这恰恰是串行门要防的误会。
|
|
2741
|
+
只有**那条会话暂时不在列表里**(刚建、列表还没刷新)时才退回成独立一行,
|
|
2742
|
+
宁可多一行也不能让排队的任务从界面上消失(那正是上次报障「排队任务丢了」)。
|
|
2743
|
+
⚠️ 这段必须排在**渲染置顶之前**:置顶行也是 sessionRow,算晚了角标就丢了。 */
|
|
2744
|
+
var PEND_VIS = PENDING.filter(function (p) { return !p.hide; });
|
|
2745
|
+
var rowIds = {};
|
|
2746
|
+
FUP = {}; // 模块级:sessionRow 要用(见其上方注释)
|
|
2747
|
+
rows.forEach(function (s) { rowIds[sidOf(s)] = 1; });
|
|
2748
|
+
PEND_VIS.forEach(function (p) {
|
|
2749
|
+
if (p.session && rowIds[p.session]) (FUP[p.session] = FUP[p.session] || []).push(p);
|
|
2750
|
+
});
|
|
2751
|
+
var PEND_ROWS = PEND_VIS.filter(function (p) { return !p.session || !rowIds[p.session]; });
|
|
1749
2752
|
/* 一次性构建到 DocumentFragment,最后 replaceChildren 单次替换 —— 无「先清空」的中间空态,
|
|
1750
2753
|
所以删除、折叠、展开等重绘都不闪。 */
|
|
1751
2754
|
var frag = document.createDocumentFragment();
|
|
1752
|
-
|
|
2755
|
+
var searching = !taskSearchBar.hidden;
|
|
2756
|
+
var query = TASK_QUERY.trim();
|
|
2757
|
+
if (!rows.length && !PEND_VIS.length) {
|
|
1753
2758
|
var hint = document.createElement('div'); hint.className = 'history__hint';
|
|
1754
|
-
hint.textContent = '还没有会话。点上面「新建任务」开一个。';
|
|
2759
|
+
hint.textContent = searching ? '还没有任务。' : '还没有会话。点上面「新建任务」开一个。';
|
|
1755
2760
|
frag.appendChild(hint);
|
|
1756
2761
|
historyList.replaceChildren(frag);
|
|
1757
2762
|
ensureTicker();
|
|
@@ -1759,26 +2764,47 @@ button.setrow{font-family:inherit}
|
|
|
1759
2764
|
return;
|
|
1760
2765
|
}
|
|
1761
2766
|
|
|
2767
|
+
/* 搜索态:不分组、不出「分组视图」行,命中项平铺(对齐截图 2)。 */
|
|
2768
|
+
if (searching) {
|
|
2769
|
+
var matched = query ? rows.filter(taskMatches) : rows;
|
|
2770
|
+
if (!matched.length && query) {
|
|
2771
|
+
var miss = document.createElement('div'); miss.className = 'history__hint';
|
|
2772
|
+
miss.textContent = '没有匹配「' + TASK_QUERY + '」的任务。';
|
|
2773
|
+
frag.appendChild(miss);
|
|
2774
|
+
} else {
|
|
2775
|
+
/* 没输入词时,刚建还没进列表的排队任务也要看得见(与下面分组口径一致)。 */
|
|
2776
|
+
if (!query) PEND_ROWS.forEach(function (p) { frag.appendChild(pendingRow(p)); });
|
|
2777
|
+
matched.forEach(function (s) { frag.appendChild(sessionBlock(s)); });
|
|
2778
|
+
}
|
|
2779
|
+
historyList.replaceChildren(frag);
|
|
2780
|
+
ensureTicker();
|
|
2781
|
+
if (!Object.keys(RUN_ROWS).length && RUN_TICK) { clearInterval(RUN_TICK); RUN_TICK = null; }
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
1762
2785
|
/* 置顶:只在这里显示;置顶项不再出现在「任务 / 空间」里(去重) */
|
|
1763
2786
|
var pinned = rows.filter(isPinned);
|
|
1764
2787
|
var rest = rows.filter(function (s) { return !isPinned(s); });
|
|
1765
|
-
if (pinned.length) {
|
|
1766
|
-
frag.appendChild(secHead('置顶', pinned.length, false, 'pin'));
|
|
1767
|
-
if (!SEC_COLLAPSED.pin) pinned.forEach(function (s) { frag.appendChild(sessionRow(s)); });
|
|
1768
|
-
}
|
|
1769
2788
|
|
|
1770
2789
|
/* 任务:**不属于任何空间**的普通任务(且未置顶)+ 默认空间(不指定)的串行等待任务。
|
|
1771
2790
|
显式选了空间的等待任务归到下面的「空间」组(用户 2026-10-20:「串行等待中的任务
|
|
1772
2791
|
现在没有显示在所属 workspace 中」)—— 它们还没真正下发、没有 session_id,不能走
|
|
1773
|
-
sessionRow,所以带 pendingWait 标记混进空间组,由 workspaceGroup 分流渲染。
|
|
1774
|
-
|
|
2792
|
+
sessionRow,所以带 pendingWait 标记混进空间组,由 workspaceGroup 分流渲染。
|
|
2793
|
+
⚠️ 只在列表里画**可见**的排队条目:hide 的那批是刚进队、还在等一次探测才知道要不要
|
|
2794
|
+
真等的(见 gateAsk 的 markWaiting);但它们在磁盘上已经存在,关 App 也不丢。
|
|
2795
|
+
PEND_ROWS = 需要**自己占一行**的排队条目(上面已算好:新任务,或那条会话暂时不在列表里)。 */
|
|
2796
|
+
var pendingTasks = PEND_ROWS.filter(function (p) { return !p.explicit; });
|
|
1775
2797
|
var tasks = rest.filter(function (s) { return !s.workspace; });
|
|
2798
|
+
if (pinned.length) {
|
|
2799
|
+
frag.appendChild(secHead('置顶', pinned.length, false, 'pin'));
|
|
2800
|
+
if (!SEC_COLLAPSED.pin) pinned.forEach(function (s) { frag.appendChild(sessionBlock(s)); });
|
|
2801
|
+
}
|
|
1776
2802
|
if (tasks.length || pendingTasks.length) {
|
|
1777
2803
|
frag.appendChild(secHead('任务', tasks.length + pendingTasks.length, true, 'tasks'));
|
|
1778
2804
|
if (!SEC_COLLAPSED.tasks) {
|
|
1779
2805
|
pendingTasks.forEach(function (p) { frag.appendChild(pendingRow(p)); });
|
|
1780
2806
|
var limit = TASKS_EXPANDED ? tasks.length : Math.min(8, tasks.length);
|
|
1781
|
-
tasks.slice(0, limit).forEach(function (s) { frag.appendChild(
|
|
2807
|
+
tasks.slice(0, limit).forEach(function (s) { frag.appendChild(sessionBlock(s)); });
|
|
1782
2808
|
if (tasks.length > limit) {
|
|
1783
2809
|
var more = document.createElement('button'); more.type = 'button'; more.className = 'more';
|
|
1784
2810
|
more.textContent = '展开更多 (' + (tasks.length - limit) + ')';
|
|
@@ -1796,8 +2822,9 @@ button.setrow{font-family:inherit}
|
|
|
1796
2822
|
});
|
|
1797
2823
|
/* 等待中的新任务:显式空间 → 放进该空间组**最上面**(状态最需要被看见)。
|
|
1798
2824
|
不能只靠上面的 rest 建 key:某空间只有排队任务、还没有任何会话时,bySpace 里
|
|
1799
|
-
本来没有它,下面的 sKeys 就不会出现这个空间 —— 这正是「没显示在所属 workspace」的另一半。
|
|
1800
|
-
|
|
2825
|
+
本来没有它,下面的 sKeys 就不会出现这个空间 —— 这正是「没显示在所属 workspace」的另一半。
|
|
2826
|
+
用 PEND_ROWS 而不是 PEND_VIS:并进原会话行的追问不该再在空间组里插一行。 */
|
|
2827
|
+
PEND_ROWS.forEach(function (p) {
|
|
1801
2828
|
if (!p.explicit || !p.ws) return;
|
|
1802
2829
|
(bySpace[p.ws] = bySpace[p.ws] || []).unshift(p);
|
|
1803
2830
|
});
|
|
@@ -1849,6 +2876,46 @@ button.setrow{font-family:inherit}
|
|
|
1849
2876
|
if (b < 1024 * 1024) return (b / 1024).toFixed(b < 10240 ? 1 : 0) + ' KB';
|
|
1850
2877
|
return (b / 1024 / 1024).toFixed(1) + ' MB';
|
|
1851
2878
|
}
|
|
2879
|
+
/* ══════════ 图片大图(缩略图点开看原图)══════════
|
|
2880
|
+
缩略图在移动端按 480 宽取(transport-http 的 thumbQuery),细节看不清;
|
|
2881
|
+
点一下换成**同一条回读地址去掉 w 参数**的原图(服务端 /desk/media 不带 w 就发原文件)。
|
|
2882
|
+
浮层黑底 contain、点任意处关闭 —— 与 PC 端 #shot-viewer 同一套语义。
|
|
2883
|
+
⚠️ 必须**去掉 w**而不是另拼一条地址:路径 / 令牌 / 编码都在缩略图地址里,
|
|
2884
|
+
重拼一份只会漏掉其中某一截(旧令牌、base 前缀都是这么踩过的)。 */
|
|
2885
|
+
var imgViewEl = document.getElementById('imgview');
|
|
2886
|
+
var imgViewImg = document.getElementById('imgviewImg');
|
|
2887
|
+
var imgViewName = document.getElementById('imgviewName');
|
|
2888
|
+
function bigSrc(src) {
|
|
2889
|
+
if (!src) return '';
|
|
2890
|
+
try {
|
|
2891
|
+
var u = new URL(src, location.href);
|
|
2892
|
+
if (!u.searchParams.has('w')) return u.href;
|
|
2893
|
+
u.searchParams.delete('w');
|
|
2894
|
+
return u.href;
|
|
2895
|
+
} catch (e) { return src; }
|
|
2896
|
+
}
|
|
2897
|
+
function closeImgView() {
|
|
2898
|
+
if (!imgViewEl || imgViewEl.hidden) return;
|
|
2899
|
+
imgViewEl.hidden = true;
|
|
2900
|
+
imgViewImg.removeAttribute('src'); // 原图别一直占着内存
|
|
2901
|
+
}
|
|
2902
|
+
function openImgView(src, name) {
|
|
2903
|
+
if (!imgViewEl || !src) return;
|
|
2904
|
+
imgViewImg.src = bigSrc(src);
|
|
2905
|
+
imgViewImg.alt = name || '';
|
|
2906
|
+
imgViewName.textContent = name || '';
|
|
2907
|
+
imgViewEl.hidden = false;
|
|
2908
|
+
}
|
|
2909
|
+
if (imgViewEl) {
|
|
2910
|
+
imgViewEl.addEventListener('click', function (e) {
|
|
2911
|
+
e.preventDefault(); e.stopPropagation();
|
|
2912
|
+
closeImgView();
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
document.addEventListener('keydown', function (e) {
|
|
2916
|
+
if (e.key === 'Escape' && imgViewEl && !imgViewEl.hidden) { e.preventDefault(); closeImgView(); }
|
|
2917
|
+
}, true);
|
|
2918
|
+
|
|
1852
2919
|
function closeReader() { if (readerEl) readerEl.hidden = true; }
|
|
1853
2920
|
function openReader(p) {
|
|
1854
2921
|
if (!readerEl) return;
|
|
@@ -1883,7 +2950,7 @@ button.setrow{font-family:inherit}
|
|
|
1883
2950
|
try { box.innerHTML = mdToHtmlOnly(text); } catch (e) { box.textContent = text; }
|
|
1884
2951
|
rdBody.appendChild(box);
|
|
1885
2952
|
if (typeof highlightIn === 'function') { try { highlightIn(box); } catch (e) {} }
|
|
1886
|
-
/* 文档里的本机图片:换成回读地址(移动端 fileUrl 自带 w=
|
|
2953
|
+
/* 文档里的本机图片:换成回读地址(移动端 fileUrl 自带 w=480,慢网也出得来)。 */
|
|
1887
2954
|
if (window.desk && typeof window.desk.mediaUrl === 'function') {
|
|
1888
2955
|
Array.prototype.forEach.call(box.querySelectorAll('img[src]'), function (im) {
|
|
1889
2956
|
var src = im.getAttribute('src') || '';
|
|
@@ -1905,6 +2972,14 @@ button.setrow{font-family:inherit}
|
|
|
1905
2972
|
}
|
|
1906
2973
|
document.addEventListener('click', function (e) {
|
|
1907
2974
|
var t = e.target;
|
|
2975
|
+
/* 图片:缩略图(产物卡 / 正文截图 / 输入框附件条)点开看原图 —— 优先于下面的产物卡分支,
|
|
2976
|
+
因为图片卡本身也是 .qa-art(走到那里只会被判成「不是文件档」而放过)。 */
|
|
2977
|
+
var im = (t && t.closest) ? t.closest('.qa-art-image img, .qa-atts img, .ag-att .thumb, .qa-t img') : null;
|
|
2978
|
+
if (im) {
|
|
2979
|
+
e.preventDefault(); e.stopPropagation();
|
|
2980
|
+
openImgView(im.getAttribute('src'), im.getAttribute('alt'));
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
1908
2983
|
var card = (t && t.closest) ? t.closest('.qa-art') : null;
|
|
1909
2984
|
if (!card) return;
|
|
1910
2985
|
if (card.querySelector('video,audio')) return; // 音视频:让原生控件自己处理
|
|
@@ -1922,6 +2997,308 @@ button.setrow{font-family:inherit}
|
|
|
1922
2997
|
document.addEventListener('keydown', function (e) {
|
|
1923
2998
|
if (e.key === 'Escape' && readerEl && !readerEl.hidden) { e.preventDefault(); closeReader(); }
|
|
1924
2999
|
}, true);
|
|
3000
|
+
|
|
3001
|
+
/* ══════════ 需求页(会话自动聚合成的功能点文档)══════════
|
|
3002
|
+
数据形状见 CLIENT-CONTRACT §3.4.1:{ok, root, workspaces[{id,dir,name,pending,memory,
|
|
3003
|
+
modules:[{name,features:[{module,name,file,desc,points,conclusions,conclusion,
|
|
3004
|
+
sources,has_final,final_preview,updated_at}]}],feature_count,last_consolidated_at,last_error}]}
|
|
3005
|
+
|
|
3006
|
+
三个口径(照抄契约,别自己发明):
|
|
3007
|
+
· 列表只读:`loadRequirements` 只打 GET /desk/requirements,**不触发归纳**;
|
|
3008
|
+
· 「整理」才归纳:POST /desk/requirements/consolidate(可能跑几十秒,按钮态要变);
|
|
3009
|
+
· 一篇文档就是一个绝对路径:点行直接进既有阅读器(markdown,不另做渲染)。 */
|
|
3010
|
+
var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
|
|
3011
|
+
var REQ_BUSY = false; // 「整理」进行中(防重复点击)
|
|
3012
|
+
var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
|
|
3013
|
+
var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
|
|
3014
|
+
|
|
3015
|
+
function reqFeatureRow(f) {
|
|
3016
|
+
/* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
|
|
3017
|
+
标题(功能点名称)→ 归纳结论 / 需求描述 → 「N 需求点 · M 结论 · 来源 K」。
|
|
3018
|
+
为什么优先结论:这一页要回答的是「这个功能定了什么」,不是「记录了多少条」。 */
|
|
3019
|
+
var b = hEl('button', 'srow srow--req');
|
|
3020
|
+
b.type = 'button';
|
|
3021
|
+
var ico = hEl('span', 'srow__ico srow__ico--idle');
|
|
3022
|
+
ico.appendChild(svgUse('#i-doc', 20));
|
|
3023
|
+
b.appendChild(ico);
|
|
3024
|
+
var col = hEl('span', 'srow__col');
|
|
3025
|
+
col.appendChild(hEl('span', 'srow__t', f.name || '(未命名)'));
|
|
3026
|
+
/* 先结论,再一句话描述,最后才是定稿预览。定稿预览是 markdown(可能带 `**目标**:`),
|
|
3027
|
+
直接塞进这一行会把星号原样显示出来;有 desc 时它更适合当副标题。 */
|
|
3028
|
+
var sub = f.conclusion || f.desc || f.final_preview || '';
|
|
3029
|
+
if (sub) col.appendChild(hEl('span', 'srow__s', sub));
|
|
3030
|
+
var meta = [];
|
|
3031
|
+
if (f.points) meta.push(f.points + ' 需求点');
|
|
3032
|
+
if (f.conclusions) meta.push(f.conclusions + ' 结论');
|
|
3033
|
+
if (f.sources) meta.push('来源 ' + f.sources);
|
|
3034
|
+
if (!f.has_final) meta.push('未定稿');
|
|
3035
|
+
if (meta.length) col.appendChild(hEl('span', 'srow__s', meta.join(' · ')));
|
|
3036
|
+
b.appendChild(col);
|
|
3037
|
+
if (f.updated_at) {
|
|
3038
|
+
var d = new Date(Number(f.updated_at) || 0);
|
|
3039
|
+
if (!isNaN(d.getTime())) {
|
|
3040
|
+
b.appendChild(hEl('span', 'srow__time',
|
|
3041
|
+
(d.getMonth() + 1) + '-' + ('0' + d.getDate()).slice(-2)));
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
b.onclick = function () { openReader(f.file); };
|
|
3045
|
+
return b;
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
/* 工作区组:与会话列表的 workspaceGroup 同一套骨架(可折叠的组头 + 组内行)。
|
|
3049
|
+
差别只有两点:组头右侧没有「+ 新建」,行是需求文档不是会话。 */
|
|
3050
|
+
function reqWorkspaceBlock(w) {
|
|
3051
|
+
var wrap = hEl('div', '');
|
|
3052
|
+
var head = hEl('div', 'wsrow__head');
|
|
3053
|
+
var key = w.id || w.dir || w.name || '';
|
|
3054
|
+
var count = Number(w.feature_count) || 0;
|
|
3055
|
+
var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : true; // 需求页默认全展开
|
|
3056
|
+
var tog = document.createElement('button');
|
|
3057
|
+
tog.type = 'button'; tog.className = 'wsrow__open';
|
|
3058
|
+
tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
|
|
3059
|
+
/* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
|
|
3060
|
+
if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
|
|
3061
|
+
/* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
|
|
3062
|
+
此时不提示 —— 不能把「不知道」画成「未初始化」。 */
|
|
3063
|
+
if (w.initialized === false) tog.appendChild(elSpan('wsrow__badge wsrow__badge--init', '未初始化'));
|
|
3064
|
+
var chev = elSpan('wsrow__chev' + (open ? ' is-open' : ''));
|
|
3065
|
+
chev.appendChild(svgUse('#i-chev-r', 16));
|
|
3066
|
+
tog.appendChild(chev);
|
|
3067
|
+
tog.onclick = function () { REQ_OPEN[key] = !open; renderRequirements(); };
|
|
3068
|
+
head.appendChild(tog);
|
|
3069
|
+
/* 组头右侧的「整理」:只作用于这个空间(初始化它 / 归纳它的新会话)。 */
|
|
3070
|
+
var sync = document.createElement('button');
|
|
3071
|
+
sync.type = 'button'; sync.className = 'wsrow__sync'; sync.textContent = '整理';
|
|
3072
|
+
sync.setAttribute('data-ws', key);
|
|
3073
|
+
sync.onclick = function (ev) {
|
|
3074
|
+
if (ev && ev.stopPropagation) ev.stopPropagation();
|
|
3075
|
+
consolidateRequirements(w);
|
|
3076
|
+
};
|
|
3077
|
+
head.appendChild(sync);
|
|
3078
|
+
wrap.appendChild(head);
|
|
3079
|
+
if (!open) return wrap;
|
|
3080
|
+
|
|
3081
|
+
if (w.last_error) {
|
|
3082
|
+
var err = hEl('div', 'history__hint', '上次整理失败:' + w.last_error);
|
|
3083
|
+
err.style.color = 'var(--warning)';
|
|
3084
|
+
wrap.appendChild(err);
|
|
3085
|
+
}
|
|
3086
|
+
(w.modules || []).forEach(function (m) {
|
|
3087
|
+
/* 模块(大功能点)用一条小节标题,形状与「置顶 / 任务 / 空间」的 sechead 一致:
|
|
3088
|
+
名字 + 条数。未归类(name 为空)不标题、直接平铺 ——
|
|
3089
|
+
老数据是平铺目录,硬给它编个「未归类」反而像出了错。 */
|
|
3090
|
+
var fs = (m.features || []);
|
|
3091
|
+
if (!fs.length) return;
|
|
3092
|
+
if (m.name) {
|
|
3093
|
+
var mh = hEl('div', 'sechead');
|
|
3094
|
+
mh.style.paddingLeft = '16px';
|
|
3095
|
+
mh.appendChild(elSpan('sechead__t', m.name + ' (' + fs.length + ')'));
|
|
3096
|
+
wrap.appendChild(mh);
|
|
3097
|
+
}
|
|
3098
|
+
fs.forEach(function (f) { wrap.appendChild(reqFeatureRow(f)); });
|
|
3099
|
+
});
|
|
3100
|
+
return wrap;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
function renderRequirements() {
|
|
3104
|
+
if (!reqListEl) return;
|
|
3105
|
+
var data = REQ_DATA;
|
|
3106
|
+
if (!data) return;
|
|
3107
|
+
/* 只画**有文档**的工作区(用户 2026-10-22:「没有的空间不显示」)。
|
|
3108
|
+
feature_count 是 core 给的真值;老 core 不给时退化成数一遍模块里的行数。 */
|
|
3109
|
+
var all = (data && data.workspaces) || [];
|
|
3110
|
+
var list = all.filter(function (w) {
|
|
3111
|
+
if (Number(w.feature_count) > 0) return true;
|
|
3112
|
+
return (w.modules || []).some(function (m) { return (m.features || []).length > 0; });
|
|
3113
|
+
});
|
|
3114
|
+
var frag = document.createDocumentFragment();
|
|
3115
|
+
if (!list.length) {
|
|
3116
|
+
/* 一个功能点都还没有:说清「是没聊过」还是「聊过但没整理」——
|
|
3117
|
+
后者要引导用户点「整理」,否则这一页看起来永远长不出东西。 */
|
|
3118
|
+
var pend = 0, uninit = 0;
|
|
3119
|
+
all.forEach(function (w) {
|
|
3120
|
+
pend += Number(w.pending) || 0;
|
|
3121
|
+
if (w.initialized === false) uninit++;
|
|
3122
|
+
});
|
|
3123
|
+
frag.appendChild(hEl('div', 'history__hint',
|
|
3124
|
+
pend > 0
|
|
3125
|
+
? '有 ' + pend + ' 条对话还没整理成需求。点空间右侧「整理」只处理这个空间;右上角「全部整理」一次跑完所有空间。'
|
|
3126
|
+
: (uninit > 0
|
|
3127
|
+
? '还没有需求。点空间右侧「整理」会先按空间目录生成一版需求基线;右上角「全部整理」一次处理所有空间。'
|
|
3128
|
+
: '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。')));
|
|
3129
|
+
reqListEl.replaceChildren(frag);
|
|
3130
|
+
return;
|
|
3131
|
+
}
|
|
3132
|
+
/* 工作区顺序照 core 给的(最后归纳时间倒序)—— 最近在动的排最前,
|
|
3133
|
+
用户打开这一页多半是想看「刚聊的那些变成什么了」。 */
|
|
3134
|
+
list.forEach(function (w) { frag.appendChild(reqWorkspaceBlock(w)); });
|
|
3135
|
+
reqListEl.replaceChildren(frag);
|
|
3136
|
+
/* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
|
|
3137
|
+
if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
function loadRequirements(quiet) {
|
|
3141
|
+
if (!reqListEl) return;
|
|
3142
|
+
if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
3143
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3144
|
+
fetch(BASE + '/desk/requirements', { headers: hdr })
|
|
3145
|
+
.then(function (r) {
|
|
3146
|
+
if (r.status === 501) {
|
|
3147
|
+
/* 老 core 没有这个能力位:**说清楚**,不要给一页空白
|
|
3148
|
+
(空白看起来像「没有需求」,是最难查的一种)。 */
|
|
3149
|
+
return r.json().then(function (e) { throw new Error((e && e.error) || '当前 core 不支持需求聚合'); });
|
|
3150
|
+
}
|
|
3151
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3152
|
+
return r.json();
|
|
3153
|
+
})
|
|
3154
|
+
.then(function (d) {
|
|
3155
|
+
REQ_DATA = d || { workspaces: [] };
|
|
3156
|
+
renderRequirements();
|
|
3157
|
+
})
|
|
3158
|
+
.catch(function (e) {
|
|
3159
|
+
if (quiet && REQ_DATA) return; // 后台刷新失败不覆盖已有画面
|
|
3160
|
+
reqListEl.innerHTML = '';
|
|
3161
|
+
reqListEl.appendChild(hEl('div', 'history__hint', '读取需求失败:' + ((e && e.message) || e)));
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
/* 「整理」:手动触发一轮(POST /desk/requirements/consolidate)。
|
|
3166
|
+
服务端按 state 判断走哪条路:
|
|
3167
|
+
· 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
|
|
3168
|
+
· 已初始化 → 只消费上次整理之后的新会话。
|
|
3169
|
+
入口有两个:
|
|
3170
|
+
· 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
|
|
3171
|
+
· 右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
|
|
3172
|
+
两个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
|
|
3173
|
+
function reqPostConsolidate(ws) {
|
|
3174
|
+
var hdr = { 'Content-Type': 'application/json' };
|
|
3175
|
+
if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
|
|
3176
|
+
return fetch(BASE + '/desk/requirements/consolidate', {
|
|
3177
|
+
method: 'POST', headers: hdr,
|
|
3178
|
+
body: JSON.stringify({
|
|
3179
|
+
workspace: ws || '',
|
|
3180
|
+
engine: RUN_PREF.engine || '',
|
|
3181
|
+
model: (RUN_PREF.engine && RUN_PREF.model) || ''
|
|
3182
|
+
})
|
|
3183
|
+
})
|
|
3184
|
+
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
|
3185
|
+
.then(function (x) {
|
|
3186
|
+
var d = x.d || {};
|
|
3187
|
+
if (!x.ok) throw new Error(d.error || ('HTTP ' + (x.d && x.d.status)));
|
|
3188
|
+
if (d.error) throw new Error(d.error);
|
|
3189
|
+
return d;
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
/* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
|
|
3194
|
+
label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。 */
|
|
3195
|
+
function reqSetBusy(on, label) {
|
|
3196
|
+
if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
|
|
3197
|
+
else REQ_BUSY_LABEL = '全部整理';
|
|
3198
|
+
var btn = document.getElementById('btnReqSync');
|
|
3199
|
+
if (btn) {
|
|
3200
|
+
btn.disabled = !!on;
|
|
3201
|
+
if (label !== undefined) btn.textContent = label;
|
|
3202
|
+
}
|
|
3203
|
+
Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
|
|
3204
|
+
b.disabled = !!on;
|
|
3205
|
+
b.textContent = on ? '整理中…' : '整理';
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
function consolidateRequirements(target) {
|
|
3210
|
+
if (REQ_BUSY || !target) return;
|
|
3211
|
+
var ws = target.dir || target.id || '';
|
|
3212
|
+
if (!ws) return;
|
|
3213
|
+
var firstTime = target.initialized === false;
|
|
3214
|
+
REQ_BUSY = true;
|
|
3215
|
+
reqSetBusy(true, firstTime ? '初始化中…' : '整理中…');
|
|
3216
|
+
var targetKey = target.id || target.dir || '';
|
|
3217
|
+
var groupBtn = null;
|
|
3218
|
+
Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
|
|
3219
|
+
if (!groupBtn && targetKey && b.getAttribute('data-ws') === targetKey) groupBtn = b;
|
|
3220
|
+
});
|
|
3221
|
+
/* 点的是哪个空间,就只让哪个组头显示「初始化中…」;其它组头保持「整理中…」。 */
|
|
3222
|
+
if (groupBtn) groupBtn.textContent = firstTime ? '初始化中…' : '整理中…';
|
|
3223
|
+
reqPostConsolidate(ws)
|
|
3224
|
+
.then(function (d) {
|
|
3225
|
+
if (d.bootstrap) {
|
|
3226
|
+
if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
|
|
3227
|
+
else toast('已按空间目录初始化:暂未识别到功能点');
|
|
3228
|
+
}
|
|
3229
|
+
else if (d.noop) toast('没有新的对话需要整理');
|
|
3230
|
+
else if (d.updated || d.created) toast('整理完成:更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
|
|
3231
|
+
else toast('整理完成');
|
|
3232
|
+
loadRequirements(true); // 后台刷新(不闪「读取中」)
|
|
3233
|
+
})
|
|
3234
|
+
.catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
|
|
3235
|
+
.then(function () {
|
|
3236
|
+
REQ_BUSY = false;
|
|
3237
|
+
reqSetBusy(false, '全部整理');
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
/* 全部整理:按空间串行跑,避免多个初始化同时读目录把机器拖垮。
|
|
3242
|
+
顺序 = 有待整理的 → 未初始化的 → 其它(最近动过的仍在前),先做便宜/该做的。
|
|
3243
|
+
每个空间单独一次 HTTP(各自 10 分钟超时);某个失败不影响后面的。 */
|
|
3244
|
+
function reqAllWorkspaces() {
|
|
3245
|
+
var seen = {};
|
|
3246
|
+
var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
|
|
3247
|
+
var key = w.dir || w.id || '';
|
|
3248
|
+
if (!key || seen[key]) return false;
|
|
3249
|
+
seen[key] = true;
|
|
3250
|
+
return true;
|
|
3251
|
+
});
|
|
3252
|
+
function rank(w) {
|
|
3253
|
+
if (Number(w.pending) > 0) return 0;
|
|
3254
|
+
if (w.initialized === false) return 1;
|
|
3255
|
+
return 2;
|
|
3256
|
+
}
|
|
3257
|
+
return list.sort(function (a, b) { return rank(a) - rank(b); });
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
function consolidateAllRequirements() {
|
|
3261
|
+
if (REQ_BUSY) return;
|
|
3262
|
+
var list = reqAllWorkspaces();
|
|
3263
|
+
if (!list.length) {
|
|
3264
|
+
toast('还没有可整理的空间');
|
|
3265
|
+
return;
|
|
3266
|
+
}
|
|
3267
|
+
REQ_BUSY = true;
|
|
3268
|
+
reqSetBusy(true, '整理中 0/' + list.length);
|
|
3269
|
+
var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
|
|
3270
|
+
function done() {
|
|
3271
|
+
REQ_BUSY = false;
|
|
3272
|
+
reqSetBusy(false, '全部整理');
|
|
3273
|
+
loadRequirements(true);
|
|
3274
|
+
if (fail) {
|
|
3275
|
+
toast('全部整理完成:成功 ' + ok + ' · 失败 ' + fail + (firstErr ? '(' + firstErr + ')' : ''));
|
|
3276
|
+
} else {
|
|
3277
|
+
toast('全部整理完成:' + ok + ' 个空间 · 新建 ' + created + ' · 更新 ' + updated);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
function step() {
|
|
3281
|
+
if (i >= list.length) { done(); return; }
|
|
3282
|
+
var w = list[i++];
|
|
3283
|
+
REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
|
|
3284
|
+
var btn = document.getElementById('btnReqSync');
|
|
3285
|
+
if (btn) btn.textContent = REQ_BUSY_LABEL;
|
|
3286
|
+
reqPostConsolidate(w.dir || w.id || '')
|
|
3287
|
+
.then(function (d) {
|
|
3288
|
+
ok++;
|
|
3289
|
+
created += Number(d.created) || 0;
|
|
3290
|
+
updated += Number(d.updated) || 0;
|
|
3291
|
+
})
|
|
3292
|
+
.catch(function (e) {
|
|
3293
|
+
fail++;
|
|
3294
|
+
if (!firstErr) firstErr = (e && e.message) || String(e);
|
|
3295
|
+
})
|
|
3296
|
+
.then(step);
|
|
3297
|
+
}
|
|
3298
|
+
step();
|
|
3299
|
+
}
|
|
3300
|
+
on('btnReqSync', function () { consolidateAllRequirements(); });
|
|
3301
|
+
|
|
1925
3302
|
function openHostSheet(title, build) {
|
|
1926
3303
|
closeHostSheet();
|
|
1927
3304
|
hostScrim = hEl('div', 'pick-scrim'); hostScrim.onclick = closeHostSheet;
|
|
@@ -1937,8 +3314,11 @@ button.setrow{font-family:inherit}
|
|
|
1937
3314
|
document.addEventListener('keydown', onHostKey, true);
|
|
1938
3315
|
build(body);
|
|
1939
3316
|
}
|
|
1940
|
-
|
|
3317
|
+
/* selected:当前生效项打勾。**必须画出来**——底部抽屉关掉再开只需要一眼,不需要
|
|
3318
|
+
用户记住上次点了哪一行;没有勾时「点了没反应」和「点了已生效」长得一模一样。 */
|
|
3319
|
+
function hRow(body, label, value, onClick, danger, selected) {
|
|
1941
3320
|
var b = hEl('button', 'pick__row'); b.type = 'button';
|
|
3321
|
+
if (selected) { b.setAttribute('aria-selected', 'true'); b.appendChild(hEl('span', 'pick__ck', '✓')); }
|
|
1942
3322
|
var l = hEl('span', 'pick__l', label); l.style.flex = '1'; l.style.minWidth = '0';
|
|
1943
3323
|
b.appendChild(l);
|
|
1944
3324
|
if (value != null) {
|
|
@@ -1951,71 +3331,570 @@ button.setrow{font-family:inherit}
|
|
|
1951
3331
|
body.appendChild(b);
|
|
1952
3332
|
}
|
|
1953
3333
|
function applyWs() { if (conv && conv.setWorkspace) conv.setWorkspace(WS); }
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
3334
|
+
/* 工作空间值的显示:WS 为空 = 默认(不传 -w,core 用调用方 cwd)。
|
|
3335
|
+
⚠️ **绝不显示目录名**(2026-10-21 用户:「默认就是要不属于任何空间目录」)。
|
|
3336
|
+
两次踩坑都在这句:`basename(WS || DW)` 让默认显示成目录名;改成「默认 · 目录名」
|
|
3337
|
+
仍然在说「你这个默认就是那个目录」。而 client 进程的 cwd 本身就在某个 magic-* 里,
|
|
3338
|
+
那个目录又同时是空间列表的一项 —— 文案一带上名字,用户立刻读成「我把那个空间
|
|
3339
|
+
选成了默认」。这里是一条**归属**声明,不是路径提示:默认 = 不属于任何空间。 */
|
|
3340
|
+
function wsLabel() { return WS ? (basename(WS) || WS) : '默认(不属于任何空间)'; }
|
|
3341
|
+
/* 运行设置页的四个值:设备只读;工作空间来自 WS;权限 / 团队读当前会话组件。
|
|
3342
|
+
组件没挂载时给「沿用默认 / 关闭」,不编造。 */
|
|
3343
|
+
function paintRunSettings() {
|
|
3344
|
+
var d = document.getElementById('rsDevice');
|
|
3345
|
+
if (d) d.textContent = histSubtitle || '本机';
|
|
3346
|
+
var w = document.getElementById('rsWorkspace');
|
|
3347
|
+
if (w) w.textContent = wsLabel();
|
|
3348
|
+
var p = document.getElementById('rsPermission');
|
|
3349
|
+
if (p) p.textContent = (conv && conv.permissionLabel) ? conv.permissionLabel() : '沿用默认';
|
|
3350
|
+
/* 全权限在本页也用红色行(与抽屉里 hRow 的 danger 同一口径)。 */
|
|
3351
|
+
var permBtn = document.getElementById('btnRsPermission');
|
|
3352
|
+
if (permBtn) permBtn.classList.toggle('setrow--danger', !!(conv && conv.permissionTier && conv.permissionTier() === 'full'));
|
|
3353
|
+
var t = document.getElementById('rsTeam');
|
|
3354
|
+
if (t) t.textContent = (conv && conv.teamLabel) ? conv.teamLabel() : '关闭';
|
|
3355
|
+
/* 团队模式:与权限模式并列(用户 2026-10-21:「团队模式开关在界面右上角跟权限模式一样」)。
|
|
3356
|
+
core 没有这个能力位时**不显示**这一行 —— 显示一个点了必然失败的开关是最坏的做法。 */
|
|
3357
|
+
var showTeam = !conv || typeof conv.featureOn !== 'function' || conv.featureOn('feature.session.team');
|
|
3358
|
+
var teamBtn = document.getElementById('btnRsTeam');
|
|
3359
|
+
if (teamBtn) teamBtn.hidden = !showTeam;
|
|
1965
3360
|
}
|
|
1966
3361
|
/* ── 任务模式门(串行)──
|
|
1967
|
-
serial
|
|
1968
|
-
|
|
1969
|
-
历史会话的追问 isNew=false,永远不等待。
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
3362
|
+
serial:同一空间同时只跑**一个新任务**;新任务若发现该空间已有任务在跑,就排队等待,
|
|
3363
|
+
并在列表里以「等待中」状态(紫色)显示。
|
|
3364
|
+
历史会话的追问 isNew=false,永远不等待。
|
|
3365
|
+
|
|
3366
|
+
⚠️ 2026-10-21 修「串行模式下还是并行多个在跑」(用户报障):旧实现里**每个**
|
|
3367
|
+
beforeAsk 各自每 3s 打一次 /desk/sessions 判「有没有 running」。它有两个必然的并发口子:
|
|
3368
|
+
① 同一刻都判空闲 → 一起放行(连发两个新任务);
|
|
3369
|
+
② 同一刻都判忙 → 3 秒后同一拍一起放行 —— 排队者之间**没有任何互斥**,
|
|
3370
|
+
它们只是在各自和「正在跑的那条」比,谁也没看别人。
|
|
3371
|
+
现在每个 workspace 一条 FIFO(SERIAL[ws]):只有队首能检查,其余在原地看着「等待中」。
|
|
3372
|
+
队首放行后也还不能立刻轮到下一条 —— core 起进程 + Begin 写登记表有窗口期,这期间
|
|
3373
|
+
/desk/sessions 里看不到它,下一条会误判空闲照样并行。所以放行时先 held++ 占位,
|
|
3374
|
+
等登记表真的出现该空间的 running 再 held--(probeLanded,带超时兜底)。
|
|
3375
|
+
⚠️ 口径仍是**按空间**串行:不同空间之间并行(这是原设计,见上面的注释)。
|
|
3376
|
+
|
|
3377
|
+
⚠️ 2026-10-21 队列落库(用户报障「串行中任务关闭app再打开就丢失了 没落库」):
|
|
3378
|
+
排队条目原先只活在内存数组里,关掉 App 就整条消失 —— 连「它存在过」都不留痕。
|
|
3379
|
+
现在 PENDING 与 localStorage('magic.serial.queue.v1') **同源**:进队即写、放行即删。
|
|
3380
|
+
重开 App 后 restoreQueue() 把残留条目按原顺序重新排进队列,轮到就用 transport 直接
|
|
3381
|
+
下发(不挂会话页面)—— 与「关掉 App 时正在跑的任务」同一口径:连接断了不等于取消,
|
|
3382
|
+
事件照常落进会话日志,点开会话能回放。
|
|
3383
|
+
每条都带 dev(桌面端地址):只恢复发给**同一台机器**的条目,否则换个桌面连上去,
|
|
3384
|
+
上次的排队任务会被打到另一台电脑上。 */
|
|
3385
|
+
var SERIAL = {}; // ws → { tail: Promise, held: number, waiters: number }
|
|
3386
|
+
var SLOT_POLL_MS = 3000; // 排队中:多久再看一眼
|
|
3387
|
+
var PROBE_MS = 700; // 已放行:多久确认一次它落盘了(这段要快,用户正在等)
|
|
3388
|
+
var LAND_TIMEOUT_MS = 20000; // 放行后等它落盘的上限(超过就认为这条没跑起来,放行下一条)
|
|
3389
|
+
var PENDING = []; // 等待中的新任务(本地,未真正下发)
|
|
3390
|
+
/* renderSessions 每帧算一次:session_id → 该会话**排队中的追问**列表。
|
|
3391
|
+
sessionRow 渲染时从这里取角标(追问不另起一行,见 renderSessions 里的说明)。 */
|
|
3392
|
+
var FUP = {};
|
|
3393
|
+
|
|
3394
|
+
/* 空间的归一键:**空 workspace 与桌面默认空间是同一个空间**(core 不传 -w 时用 cwd,
|
|
3395
|
+
登记表里那条记录的 workspace 就是空 —— 两边必须归一到同一个键,否则「默认空间里
|
|
3396
|
+
已经有一条在跑」会被判成另一空间而照样并行)。 */
|
|
3397
|
+
function wsKey(w) { return w || DW; }
|
|
3398
|
+
function wsOf(s) { return wsKey(s && s.workspace); }
|
|
3399
|
+
/* 「这个空间有任务在跑」的判据。waiting_approval **不算**(沿用旧口径):
|
|
3400
|
+
它卡在等审批 / 等回答上,用户可能一直不理,算进去就等于把整个空间永久锁死;
|
|
3401
|
+
代价是审批期间可能多起一个进程 —— 这是有意的取舍,不是遗漏。
|
|
3402
|
+
跑没跑只认**会话级** status(同一条会话各轮一个记录,只看 state 会挑中旧轮得出
|
|
3403
|
+
「还在跑」,追问就一直排队);没有 status 的老 CLI 回落 state。 */
|
|
3404
|
+
function serialBusy(s) { return (s.status || s.state) === 'running' || s.alive === true; }
|
|
3405
|
+
function serialQ(ws) {
|
|
3406
|
+
return SERIAL[ws] || (SERIAL[ws] = { tail: Promise.resolve(), held: 0, waiters: 0 });
|
|
3407
|
+
}
|
|
3408
|
+
function addPending(ws, info, hide) {
|
|
3409
|
+
/* explicit:**显式选了空间**才归进「空间」段;默认(不指定)落「任务」段 ——
|
|
3410
|
+
与 renderSessions 里会话按 `workspace` 是否为空分组的判定保持一致。
|
|
3411
|
+
w 存**原始** workspace(空 = 不指定):恢复时原样下发给 core,归组仍按 explicit 判。 */
|
|
3412
|
+
var p = { id: 'p' + Date.now() + '-' + Math.floor(Math.random() * 1000),
|
|
3413
|
+
ws: ws, w: info.workspace || '', explicit: !!info.workspace, pendingWait: true,
|
|
3414
|
+
/* session:**历史会话的追问**要带着原会话 id 排队(2026-10-21 用户:
|
|
3415
|
+
「历史会话追问也要串行」)。不带的话,这条排队任务一旦经过重启恢复,
|
|
3416
|
+
就会被当成「一段新会话」发出去 —— 追问落到新会话里,原会话里什么都没有。
|
|
3417
|
+
(gateAsk 只用它来标记条目,下发给 core 走 fireQueued。) */
|
|
3418
|
+
session: info.session || '',
|
|
3419
|
+
prompt: info.prompt || '', engine: info.engine || '',
|
|
3420
|
+
model: info.model || '', permission: info.permission || '',
|
|
3421
|
+
/* team:重发时原样还原团队配置(见 fireQueued)——
|
|
3422
|
+
丢了它这条排队任务就会从「团队」静默降级成单任务。 */
|
|
3423
|
+
team: info.team || null,
|
|
3424
|
+
attachments: (info.attachments || []).slice(),
|
|
3425
|
+
hide: !!hide, dev: devKey(), at: Date.now() };
|
|
3426
|
+
PENDING.push(p); saveQueue();
|
|
3427
|
+
if (!hide) renderSessions(LAST_SESSIONS);
|
|
3428
|
+
return p;
|
|
3429
|
+
}
|
|
3430
|
+
function dropPending(id) {
|
|
3431
|
+
if (id == null) return;
|
|
3432
|
+
var n = PENDING.length;
|
|
3433
|
+
PENDING = PENDING.filter(function (p) { return p.id !== id; });
|
|
3434
|
+
purgeQueue(id);
|
|
3435
|
+
if (PENDING.length !== n) renderSessions(LAST_SESSIONS);
|
|
3436
|
+
}
|
|
3437
|
+
/* ── 组件内排队 → 落库 ──
|
|
3438
|
+
组件在**同一轮还没收尾**时会把新提交的追问压在自己内存里的队列(conversation.js
|
|
3439
|
+
的 state.queue)。它和串行门是两条不同的队:串行门挡「还没开始的一轮」(跨会话/空间),
|
|
3440
|
+
组件队排「同一会话上一轮还没结束的追问」。用户看到的都是「等待中 · 上一个完成后
|
|
3441
|
+
自动开始」,但后者原先**一点都没落盘** —— 刷新/关 App 就整条消失
|
|
3442
|
+
(2026-10-21 用户:「非团队的串行排队也没落库」)。
|
|
3443
|
+
这里把它接进同一个 PENDING:进队即写 magic.serial.queue.v1、轮到就删行、取消就清盘。
|
|
3444
|
+
重开 App 后由 restoreQueue 直接下发(不挂会话页面,事件仍进会话日志,点开可回放)。 */
|
|
3445
|
+
var QPEND = {}; // 组件条目 id(q…)→ 本端 PENDING id(p…)
|
|
3446
|
+
function queuePushed(info) {
|
|
3447
|
+
if (!info || !info.id || !info.prompt || QPEND[info.id]) return;
|
|
3448
|
+
/* hide:用户此刻就在会话里,气泡已经画出来了 —— 列表那条「等待中」延后
|
|
3449
|
+
PENDING_SHOW_MS 再出现(与 gateAsk 同一口径:空间本来就空闲时不会闪一条假排队)。 */
|
|
3450
|
+
var p = addPending(wsKey(info.workspace), info, true);
|
|
3451
|
+
QPEND[info.id] = p.id;
|
|
3452
|
+
setTimeout(function () {
|
|
3453
|
+
if (PENDING.indexOf(p) < 0 || p.gone) return;
|
|
3454
|
+
p.hide = false;
|
|
1983
3455
|
renderSessions(LAST_SESSIONS);
|
|
3456
|
+
}, PENDING_SHOW_MS);
|
|
3457
|
+
}
|
|
3458
|
+
function queueTaken(id) {
|
|
3459
|
+
var pid = QPEND[id];
|
|
3460
|
+
if (!pid) return;
|
|
3461
|
+
delete QPEND[id];
|
|
3462
|
+
dropPending(pid);
|
|
3463
|
+
}
|
|
3464
|
+
function queueDropped(id) {
|
|
3465
|
+
var pid = QPEND[id];
|
|
3466
|
+
if (!pid) return;
|
|
3467
|
+
delete QPEND[id];
|
|
3468
|
+
cancelPending(pid); // 标取消 + 从 PENDING 与磁盘上一并删掉
|
|
3469
|
+
}
|
|
3470
|
+
/* ── 队列落库 ──
|
|
3471
|
+
键用**令牌**而不是地址:令牌是这台桌面的身份(magic-client 把它落盘复用,跨重启不变),
|
|
3472
|
+
而地址会变 —— 手机从 Wi-Fi 切到热点、桌面换了 IP、端口变了,用地址当键就等于把上次的
|
|
3473
|
+
排队任务判成「另一台机器的」而整批丢掉。换一台桌面来配对时令牌不同,自然互不干扰。
|
|
3474
|
+
loadQueue 只认当前令牌那批,别的**留在原地不动**(那台机器再连上来时还能用)——
|
|
3475
|
+
所以这里是读改写,不是整体覆盖。 */
|
|
3476
|
+
var QUEUE_KEY = 'magic.serial.queue.v1';
|
|
3477
|
+
function devKey() { return String(TOKEN || '').slice(0, 12) || String(BASE || ''); }
|
|
3478
|
+
function readQueueAll() {
|
|
3479
|
+
try {
|
|
3480
|
+
var a = JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]');
|
|
3481
|
+
return Array.isArray(a) ? a : [];
|
|
3482
|
+
} catch (e) { return []; }
|
|
3483
|
+
}
|
|
3484
|
+
function saveQueue() {
|
|
3485
|
+
var mine = devKey(), others = readQueueAll().filter(function (p) { return p.dev !== mine; });
|
|
3486
|
+
/* 只落**持久**字段:hide / gone 是这一次页面会话里的显示态,存下去会让「关掉 App 时
|
|
3487
|
+
正好还没到 300ms」的那条永远隐藏 —— 队列里有它、列表上却看不见,最难查的一种。
|
|
3488
|
+
id 保留:恢复时据此与内存里已有的条目去重。 */
|
|
3489
|
+
var mineRows = PENDING.map(function (p) {
|
|
3490
|
+
return { id: p.id, ws: p.ws, w: p.w || '', explicit: !!p.explicit, pendingWait: true,
|
|
3491
|
+
session: p.session || '',
|
|
3492
|
+
prompt: p.prompt || '', engine: p.engine || '', model: p.model || '',
|
|
3493
|
+
permission: p.permission || '', team: p.team || null,
|
|
3494
|
+
attachments: p.attachments || [],
|
|
3495
|
+
dev: p.dev, at: p.at };
|
|
3496
|
+
});
|
|
3497
|
+
try { localStorage.setItem(QUEUE_KEY, JSON.stringify(others.concat(mineRows))); } catch (e) {}
|
|
3498
|
+
}
|
|
3499
|
+
function loadQueue() {
|
|
3500
|
+
var mine = devKey();
|
|
3501
|
+
return readQueueAll().filter(function (p) { return p && p.dev === mine && p.prompt; });
|
|
3502
|
+
}
|
|
3503
|
+
/* 某条排队任务被用户放弃(划掉 / 删除):磁盘上也要删,否则下次启动它又冒出来。 */
|
|
3504
|
+
function purgeQueue(id) {
|
|
3505
|
+
try {
|
|
3506
|
+
var rest = readQueueAll().filter(function (p) { return p.id !== id; });
|
|
3507
|
+
localStorage.setItem(QUEUE_KEY, JSON.stringify(rest));
|
|
3508
|
+
} catch (e) {}
|
|
3509
|
+
}
|
|
3510
|
+
/* 占位何时能还:等登记表里该空间真的出现这一条(= 它的 core 已落盘)。
|
|
3511
|
+
判据不能只看 `running`:几十毫秒就收工的任务,落盘那一刻起就不是 running 了,
|
|
3512
|
+
只看 running 会一直等不到、白占满整个超时窗。所以「出现了一条 t0 之后开始的记录」
|
|
3513
|
+
也算落盘 —— 记录一旦看得见,下一条本来就该按 running 判定自己要不要等。
|
|
3514
|
+
等不到(core 起不来 / 秒挂)就超时还位 —— 宁可退化成「放行了但没看到」,
|
|
3515
|
+
也不能把后面所有任务永久锁死在这道门里。 */
|
|
3516
|
+
function startedAfter(s, t0) {
|
|
3517
|
+
var d = new Date(String((s && s.started_at) || '').replace(' ', 'T'));
|
|
3518
|
+
return !isNaN(d.getTime()) && d.getTime() >= t0 - 2000; // 容 2s 时钟/取整偏差
|
|
3519
|
+
}
|
|
3520
|
+
function probeLanded(ws, q) {
|
|
3521
|
+
var t0 = Date.now(), settled = false;
|
|
3522
|
+
function release() {
|
|
3523
|
+
if (settled) return;
|
|
3524
|
+
settled = true;
|
|
3525
|
+
q.held = Math.max(0, q.held - 1);
|
|
1984
3526
|
}
|
|
1985
|
-
function
|
|
1986
|
-
if (
|
|
1987
|
-
|
|
1988
|
-
|
|
3527
|
+
(function tick() {
|
|
3528
|
+
if (settled || q.held <= 0) return;
|
|
3529
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3530
|
+
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
3531
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
3532
|
+
.then(function (list) {
|
|
3533
|
+
var seen = Array.isArray(list) && list.some(function (s) {
|
|
3534
|
+
return wsOf(s) === ws && (serialBusy(s) || startedAfter(s, t0));
|
|
3535
|
+
});
|
|
3536
|
+
if (seen || Date.now() - t0 >= LAND_TIMEOUT_MS) { release(); return; }
|
|
3537
|
+
setTimeout(tick, PROBE_MS);
|
|
3538
|
+
})
|
|
3539
|
+
.catch(release);
|
|
3540
|
+
})();
|
|
3541
|
+
}
|
|
3542
|
+
/* 队首的等待:服务端该空间没有 running,且本端没有「已放行但还没落盘」的占位。
|
|
3543
|
+
isCancelled:排队期间用户点了停止 —— 每一拍先看它,取消后立刻退出,
|
|
3544
|
+
不能等到「空间空了」才发现不该发(那期间它一直占着队首,后面的任务全被它挡住)。
|
|
3545
|
+
onWait:**真的**要等(占位没还 / 探到该空间在跑)时回调一次,用来把「排队中」标到
|
|
3546
|
+
气泡上(见 gateAsk)。⚠️ 必须由这里判、不能按时间猜:空间本来空闲时这条门一个往返
|
|
3547
|
+
就放行,按时间盖标记就是 2026-10-22 那个「没排队也先显示排队状态再切回」。 */
|
|
3548
|
+
function waitForSlot(ws, q, isCancelled, onWait) {
|
|
3549
|
+
var told = false;
|
|
3550
|
+
function waiting() {
|
|
3551
|
+
if (told) return;
|
|
3552
|
+
told = true;
|
|
3553
|
+
if (typeof onWait === 'function') { try { onWait(); } catch (e) {} }
|
|
1989
3554
|
}
|
|
1990
3555
|
return new Promise(function (resolve) {
|
|
1991
3556
|
function check() {
|
|
3557
|
+
if (isCancelled && isCancelled()) { resolve(); return; }
|
|
3558
|
+
if (TASK_MODE !== 'serial') { resolve(); return; } // 用户中途改回并行:别再挡着
|
|
3559
|
+
if (q.held > 0) { waiting(); setTimeout(check, SLOT_POLL_MS); return; }
|
|
1992
3560
|
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
1993
|
-
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
3561
|
+
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
3562
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1994
3563
|
.then(function (list) {
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
3564
|
+
/* 拿不到清单(网关旧版 / 网络抖动):放行。串行是**保护**不是**闸门**,
|
|
3565
|
+
宁可这一条失去排队保障,也不要把用户的任务永久卡在门里。 */
|
|
3566
|
+
if (!Array.isArray(list)) { resolve(); return; }
|
|
3567
|
+
var busy = list.some(function (s) { return wsOf(s) === ws && serialBusy(s); });
|
|
3568
|
+
if (busy) { waiting(); setTimeout(check, SLOT_POLL_MS); return; }
|
|
3569
|
+
/* 放行前最后确认一次没被取消:上面这个请求有往返时间,用户可能就在这期间
|
|
3570
|
+
点了停止 —— 此时再 held++ 会凭空占掉一个名额,下一条白白多等一轮。 */
|
|
3571
|
+
if (isCancelled && isCancelled()) { resolve(); return; }
|
|
3572
|
+
q.held++; // 占位:本条已放行,直到登记表看得见它
|
|
3573
|
+
probeLanded(ws, q);
|
|
3574
|
+
resolve();
|
|
1999
3575
|
})
|
|
2000
|
-
.catch(function () {
|
|
3576
|
+
.catch(function () { resolve(); });
|
|
2001
3577
|
}
|
|
2002
3578
|
check();
|
|
2003
3579
|
});
|
|
2004
3580
|
}
|
|
3581
|
+
/* 排队行的出现时机:**进队即落库**(关 App 也不能丢,这是用户报障的正题);
|
|
3582
|
+
列表上那条「等待中」行则由**是否真的要等**决定可见性(见 gateAsk 的 markWaiting)。
|
|
3583
|
+
这个常量只剩一处用处:组件内排队(queuePushed,同一会话上一轮还没收尾)在
|
|
3584
|
+
那一刻**一定**是在等,但它和串行门是两条队,仍留一点延时避免连发时闪一下。
|
|
3585
|
+
hide=true 的条目在列表里不画(见 renderSessions 的过滤)。 */
|
|
3586
|
+
var PENDING_SHOW_MS = 300;
|
|
3587
|
+
/* 组件的「打断」打过来时,要知道撤哪一条:这一轮还没下发(没有 askId),
|
|
3588
|
+
唯一能标识它的就是 gateAsk 里刚登记的那条 PENDING。多个对话框可能同时
|
|
3589
|
+
各挂一条,所以用栈;finish 时按 id 摘掉自己那条。 */
|
|
3590
|
+
var ASK_WAITING = [];
|
|
3591
|
+
/* ── 串行门 ──
|
|
3592
|
+
口径:**同一空间同时只有一个轮次在跑**,不分新任务还是历史会话追问。
|
|
3593
|
+
(2026-10-21 用户:「历史会话追问也要串行」。此前 `!info.isNew` 直接放行 ——
|
|
3594
|
+
理由是「追问不受限」,但它恰恰是同一条会话最需要排队的场合:一句话还没回完
|
|
3595
|
+
就再追一句,两个进程会同时往同一份会话日志里写,画面也会互相踩。)
|
|
3596
|
+
返回 false = 用户在排队期间取消了这一轮(见 conversation.js 的 beforeAsk 契约)。 */
|
|
3597
|
+
function gateAsk(info) {
|
|
3598
|
+
if (TASK_MODE !== 'serial' || !info) return Promise.resolve(true);
|
|
3599
|
+
var ws = wsKey(info.workspace);
|
|
3600
|
+
var q = serialQ(ws);
|
|
3601
|
+
/* 落库是同步写,很便宜;而「点了发送、网还没回、用户退出了」正是丢任务的高危窗口,
|
|
3602
|
+
所以不等任何延时,先进队列进磁盘。 */
|
|
3603
|
+
var pend = addPending(ws, info, true);
|
|
3604
|
+
ASK_WAITING.push(pend);
|
|
3605
|
+
/* 「等待中」什么时候露面:**只在真的在等的时候**。
|
|
3606
|
+
⚠️ 2026-10-22 用户:「现在串行没有排队的也会先显示排队状态再切回 直接进入对话就行」:
|
|
3607
|
+
原先这里是无条件 setTimeout(PENDING_SHOW_MS) 翻可见 —— 空间本来就空闲时,
|
|
3608
|
+
这道门一个探测往返就放行,那 300ms 里列表先闪一条紫色「等待中」再消失,
|
|
3609
|
+
气泡上也闪一句「排队中」(见 conversation.js 的 markQueued)。
|
|
3610
|
+
现在两个条件之一成立才露面:① 进队时前面已经有人;② waitForSlot 探到该空间在跑
|
|
3611
|
+
/ 本端还有没落盘的占位。空闲直通的那条从头到尾不出现排队态。 */
|
|
3612
|
+
var revealed = false;
|
|
3613
|
+
function markWaiting() {
|
|
3614
|
+
if (!revealed && !pend.gone) {
|
|
3615
|
+
revealed = true;
|
|
3616
|
+
pend.hide = false;
|
|
3617
|
+
renderSessions(LAST_SESSIONS);
|
|
3618
|
+
}
|
|
3619
|
+
if (typeof info.queued === 'function') { try { info.queued(true); } catch (e) {} }
|
|
3620
|
+
}
|
|
3621
|
+
function cancelled() { return !!pend.cancelled; }
|
|
3622
|
+
/* 队里**前面真有人**就当场算「要等」:不必等一个探测往返,慢那一下用户就白等了。
|
|
3623
|
+
队首(前面没人)的情况交给 waitForSlot 的 onWait —— 探到该空间在跑才盖。
|
|
3624
|
+
⚠️ 判据是进队时的**在队人数** `q.waiters`,不能写成「q.tail 已经 resolve 过」——
|
|
3625
|
+
Promise 看不出这个;也不能用累计编号(编号只增不减,第二句之后永远 >1)。 */
|
|
3626
|
+
var ahead = q.waiters > 0;
|
|
3627
|
+
q.waiters++;
|
|
3628
|
+
if (ahead) markWaiting();
|
|
3629
|
+
var mine = q.tail.then(function () {
|
|
3630
|
+
if (cancelled()) return false;
|
|
3631
|
+
return waitForSlot(ws, q, cancelled, markWaiting).then(function () { return !cancelled(); });
|
|
3632
|
+
});
|
|
3633
|
+
q.tail = mine.catch(function () { return false; }); // 出错也不能让后面永久挂起
|
|
3634
|
+
return mine.then(finish, finish);
|
|
3635
|
+
function finish(go) {
|
|
3636
|
+
q.waiters = Math.max(0, q.waiters - 1);
|
|
3637
|
+
pend.gone = true;
|
|
3638
|
+
ASK_WAITING = ASK_WAITING.filter(function (x) { return x.id !== pend.id; });
|
|
3639
|
+
dropPending(pend.id);
|
|
3640
|
+
/* 轮到它了:摘掉气泡上的「排队中」(放行后就不该再挂着那句话)。
|
|
3641
|
+
⚠️ 与上面 queued(true) 成对 —— 组件的 beforeAsk 只管标/摘,时序由宿主说话。 */
|
|
3642
|
+
if (typeof info.queued === 'function') { try { info.queued(false); } catch (e) {} }
|
|
3643
|
+
return go !== false;
|
|
3644
|
+
}
|
|
3645
|
+
/* 取消入口挂在条目上:会话页的「中断」按钮在**这一轮还没下发**时就是打给它 ——
|
|
3646
|
+
组件那边拿不到 askId,control() 会回报「还没拿到轮次 id」并什么都不做,
|
|
3647
|
+
于是排队中的那一轮根本停不下来。见下面的 cancelPending。 */
|
|
3648
|
+
}
|
|
3649
|
+
/* 用户点了停止:把排队中的这一轮撤出队列。
|
|
3650
|
+
⚠️ 队首与队尾都要能撤 —— 队首正在 check(),它每一拍会看 cancelled 标记自己退出;
|
|
3651
|
+
队尾的更简单,轮到它时第一句就返回 false。撤完必须 saveQueue/purge,
|
|
3652
|
+
否则下次启动它又冒出来。 */
|
|
3653
|
+
function cancelPending(id) {
|
|
3654
|
+
var hit = null;
|
|
3655
|
+
PENDING.forEach(function (p) { if (p.id === id) hit = p; });
|
|
3656
|
+
if (!hit) return false;
|
|
3657
|
+
hit.cancelled = true;
|
|
3658
|
+
hit.gone = true;
|
|
3659
|
+
dropPending(id);
|
|
3660
|
+
renderSessions(LAST_SESSIONS);
|
|
3661
|
+
return true;
|
|
3662
|
+
}
|
|
3663
|
+
/* 「这一轮还在排队吗」——组件问宿主:真在排队就别去调 control(那条路没有 askId)。 */
|
|
3664
|
+
function pendingOf(prompt) {
|
|
3665
|
+
for (var i = 0; i < PENDING.length; i++) {
|
|
3666
|
+
if (!PENDING[i].gone && PENDING[i].prompt === prompt) return PENDING[i];
|
|
3667
|
+
}
|
|
3668
|
+
return null;
|
|
3669
|
+
}
|
|
3670
|
+
/* 组件点了打断,而这一轮**还没下发**(没有 askId):撤掉它自己的那条排队。
|
|
3671
|
+
只认 ASK_WAITING(gateAsk 亲手登记、还没 finish 的),不从 PENDING 里瞎猜 ——
|
|
3672
|
+
重启恢复出来的条目同样在 PENDING 里,但它们的所有者是 enqueueDispatch,
|
|
3673
|
+
撤了之后那条 Promise 还会把结果当成「已取消」走完,两边都记账容易漏发。
|
|
3674
|
+
倒序找:同一句 prompt 连发两次时,用户要停的显然是最后那条。 */
|
|
3675
|
+
function cancelWaitingAsk(prompt) {
|
|
3676
|
+
for (var i = ASK_WAITING.length - 1; i >= 0; i--) {
|
|
3677
|
+
var p = ASK_WAITING[i];
|
|
3678
|
+
if (p.gone) continue;
|
|
3679
|
+
if (prompt && p.prompt !== prompt) continue;
|
|
3680
|
+
return cancelPending(p.id);
|
|
3681
|
+
}
|
|
3682
|
+
return false;
|
|
3683
|
+
}
|
|
2005
3684
|
|
|
2006
|
-
/*
|
|
3685
|
+
/* ── 队列接续(重开 App 后把上次没轮到的排队任务接回去)──
|
|
3686
|
+
⚠️ 与「关掉 App 时正在跑的任务」同一口径:连接断了不等于取消。所以恢复出来的条目
|
|
3687
|
+
照常排队、轮到就用 transport 直接下发(**不挂会话页面**),事件仍落进会话日志,
|
|
3688
|
+
点开会话能回放。用户不必因为「手机锁屏了一下」就重新发一遍。
|
|
3689
|
+
⚠️ 只恢复发给**当前这台桌面**的条目(见 devKey):换了配对地址就别把旧任务打过去。 */
|
|
3690
|
+
var QUEUE_RESTORED = false;
|
|
3691
|
+
/* 「已连接」判据:**同源调试时 BASE 本来就是空串**(base='' 表示打当前 origin),
|
|
3692
|
+
所以不能只看 BASE —— 那样在浏览器里打开时整个接续逻辑会被静默跳过。 */
|
|
3693
|
+
function connected() { return !!(BASE || TOKEN); }
|
|
3694
|
+
function restoreQueue() {
|
|
3695
|
+
/* ⚠️ 必须等 DW 落定:空间键 wsKey(w)=w||DW,DW 是 /desk/health 回来的。
|
|
3696
|
+
抢在它之前恢复,队首排进 '' 这个键,而后续检查按 DW 算键 —— 两边对不上,
|
|
3697
|
+
串行门等于被绕过(正是这次要修的那种并行)。 */
|
|
3698
|
+
if (QUEUE_RESTORED || !connected() || !DW_READY) return;
|
|
3699
|
+
QUEUE_RESTORED = true;
|
|
3700
|
+
var list = loadQueue();
|
|
3701
|
+
if (!list.length) return;
|
|
3702
|
+
list.sort(function (a, b) { return (a.at || 0) - (b.at || 0); }); // 按当时进队顺序
|
|
3703
|
+
PENDING = list;
|
|
3704
|
+
renderSessions(LAST_SESSIONS);
|
|
3705
|
+
list.forEach(function (p) { enqueueDispatch(p); });
|
|
3706
|
+
}
|
|
3707
|
+
function enqueueDispatch(p) {
|
|
3708
|
+
var ws = wsKey(p.w);
|
|
3709
|
+
var q = serialQ(ws);
|
|
3710
|
+
/* 重新尝试(用户点「未能启动」那条):先把红字连原因一起摘掉,回到正常的排队态。 */
|
|
3711
|
+
if (p.stalled) { p.stalled = false; p.stalledReason = ''; renderSessions(LAST_SESSIONS); }
|
|
3712
|
+
var mine = q.tail.then(function () { return waitForSlot(ws, q); }).then(function () {
|
|
3713
|
+
/* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
|
|
3714
|
+
if (!connected()) return false;
|
|
3715
|
+
return fireQueued(p);
|
|
3716
|
+
});
|
|
3717
|
+
q.tail = mine.catch(function () { return false; });
|
|
3718
|
+
return mine.then(function (sent) {
|
|
3719
|
+
if (!sent) return; // 没落盘:留在队列里(stalled),不删
|
|
3720
|
+
dropPending(p.id);
|
|
3721
|
+
/* 条目刚删掉、会话行还没被 5s 轮询拉到 —— 立刻刷一次,避免列表出现空档。 */
|
|
3722
|
+
if (typeof loadSessions === 'function') loadSessions(true);
|
|
3723
|
+
}, function () { /* 没发出去:保留,下次连上重试 */ });
|
|
3724
|
+
}
|
|
3725
|
+
/* 真正下发一条排队任务:只发、不收画面(事件进会话日志,点开会话可回放)。
|
|
3726
|
+
⚠️ **收到 open 不等于任务跑起来了** —— open 只说明连上了 `/desk/ask`,core 可能
|
|
3727
|
+
紧接着就起不来 / 被拒(引擎不存在、参数不对)。从前这里 onOpen 就判成功、队列条目
|
|
3728
|
+
当场删掉,那种秒挂的任务既没有会话行、也没有排队行,用户看到的是「发完就凭空消失」
|
|
3729
|
+
(2026-10-21:「团队模式会话没存档吗 刷新就没了」)。
|
|
3730
|
+
现在改判据:**会话登记表里真的看见这一条**才算下发成功(新任务看 t0 之后的新记录,
|
|
3731
|
+
追问看 session_id 一致)。确认之后立刻掐掉 SSE:服务端拿 context.WithoutCancel 跑 core,
|
|
3732
|
+
断连只意味着「没人看直播」,任务照跑到终态(见 service.go 的 handleAsk);不掐的话,
|
|
3733
|
+
排队的每一条都会在后台挂着长连接读自己的事件流,白耗流量。
|
|
3734
|
+
确认不到(超过 LAND_TIMEOUT_MS)→ 返回 false,条目留在队列里标成「未能启动 · 点按重试」。 */
|
|
3735
|
+
function fireQueued(p) {
|
|
3736
|
+
if (!window.MagicTransportHttp) return Promise.resolve(false);
|
|
3737
|
+
var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN, client: 'mobile' });
|
|
3738
|
+
var payload = { engine: p.engine, prompt: p.prompt };
|
|
3739
|
+
if (p.model) payload.model = p.model;
|
|
3740
|
+
if (p.w) payload.workspace = p.w;
|
|
3741
|
+
/* 带上原会话 id 才是「追问」;不带就是新起一段会话(见 addPending 里 session 的注释)。 */
|
|
3742
|
+
if (p.session) payload.session = p.session;
|
|
3743
|
+
if (p.permission) payload.permission = p.permission;
|
|
3744
|
+
/* 团队配置是在发送那一刻快照下来的(见 addPending):重发必须带上,
|
|
3745
|
+
否则这条排队任务会被当成普通单任务跑。 */
|
|
3746
|
+
if (p.team) payload.team = p.team;
|
|
3747
|
+
if (p.attachments && p.attachments.length) payload.attachments = p.attachments;
|
|
3748
|
+
return new Promise(function (resolve) {
|
|
3749
|
+
var done = false, handle = null, t0 = Date.now();
|
|
3750
|
+
/* why = 失败原因(人话)。以前判失败只留一个布尔,用户只能看到「未能启动」四个字,
|
|
3751
|
+
无从判断是路由配错、core 崩了还是隧道断了(2026-10-21:「未能启动 为什么」)。 */
|
|
3752
|
+
var why = '';
|
|
3753
|
+
function fin(ok) {
|
|
3754
|
+
if (done) return;
|
|
3755
|
+
done = true;
|
|
3756
|
+
try { if (ok && handle && handle.cancel) handle.cancel(); } catch (e) {}
|
|
3757
|
+
if (!ok) {
|
|
3758
|
+
/* 没跑起来:留在队列里、给出可见的「点按重试」,绝不静默删条目。 */
|
|
3759
|
+
p.stalled = true;
|
|
3760
|
+
p.stalledReason = why || '服务端没有返回原因';
|
|
3761
|
+
if (PENDING.indexOf(p) >= 0) renderSessions(LAST_SESSIONS);
|
|
3762
|
+
} else {
|
|
3763
|
+
p.stalledReason = '';
|
|
3764
|
+
}
|
|
3765
|
+
resolve(!!ok);
|
|
3766
|
+
}
|
|
3767
|
+
/* end 帧带 code + stderr(服务端 internal/client/service.go 的 send("end", …) 早就在发,
|
|
3768
|
+
是这里一直丢掉了)。core 的 usage error 会往 stderr 打一行 JSON
|
|
3769
|
+
({"engine":…,"error":"未知引擎:text→xxx"},见 internal/cli/team.go),
|
|
3770
|
+
优先取里面的 error/reason 当人话;解析不了就退回最后一行原文。
|
|
3771
|
+
比「退出码 2」对用户有用得多。 */
|
|
3772
|
+
function endReason(code, ev) {
|
|
3773
|
+
var lines = String((ev && ev.stderr) || '').split('\n')
|
|
3774
|
+
.map(function (x) { return x.trim(); }).filter(Boolean);
|
|
3775
|
+
for (var i = lines.length - 1; i >= 0; i--) {
|
|
3776
|
+
var t = lines[i];
|
|
3777
|
+
if (t.charAt(0) === '{') {
|
|
3778
|
+
try {
|
|
3779
|
+
var j = JSON.parse(t);
|
|
3780
|
+
if (j && (j.error || j.reason)) return String(j.error || j.reason);
|
|
3781
|
+
} catch (e) {}
|
|
3782
|
+
}
|
|
3783
|
+
return t;
|
|
3784
|
+
}
|
|
3785
|
+
return code ? 'core 退出码 ' + code : '';
|
|
3786
|
+
}
|
|
3787
|
+
var seenAt = 0; // 事件流收尾的时刻;0 = 还在跑
|
|
3788
|
+
var grace = 0; // 收尾后额外等待(onError 给 4s;只增不减,见下)
|
|
3789
|
+
/* seen:这一条是否已在登记表里。收尾之后还要再等 grace —— 事件流断掉不等于 core
|
|
3790
|
+
没起来(App 切后台 / 隧道瞬断时最容易误判成失败)。总窗封在 LAND_TIMEOUT_MS。 */
|
|
3791
|
+
function seenInRegistry(graceMs) {
|
|
3792
|
+
if (done) return;
|
|
3793
|
+
/* 只增不减:onError 之后再来的 onEnd(null) 不能把宽限期压回 0,否则刚许的 4s 白给。 */
|
|
3794
|
+
if (typeof graceMs === 'number' && graceMs > grace) grace = graceMs;
|
|
3795
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3796
|
+
function again() {
|
|
3797
|
+
if (done) return;
|
|
3798
|
+
var now = Date.now();
|
|
3799
|
+
if (seenAt && now - seenAt >= grace) { fin(false); return; }
|
|
3800
|
+
if (now - t0 >= LAND_TIMEOUT_MS) {
|
|
3801
|
+
why = why || '20 秒内没有出现在会话登记表(core 没起来或请求没到服务端)';
|
|
3802
|
+
fin(false);
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3805
|
+
setTimeout(function () { seenInRegistry(grace); }, PROBE_MS);
|
|
3806
|
+
}
|
|
3807
|
+
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
3808
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
3809
|
+
.then(function (list) {
|
|
3810
|
+
if (done) return;
|
|
3811
|
+
var seen = Array.isArray(list) && list.some(function (s) {
|
|
3812
|
+
if (wsOf(s) !== wsKey(p.w)) return false;
|
|
3813
|
+
if (p.session) return String(sidOf(s) || '') === String(p.session);
|
|
3814
|
+
return startedAfter(s, t0);
|
|
3815
|
+
});
|
|
3816
|
+
if (seen) fin(true); else again();
|
|
3817
|
+
})
|
|
3818
|
+
.catch(again);
|
|
3819
|
+
}
|
|
3820
|
+
try {
|
|
3821
|
+
handle = transport.ask(payload, {
|
|
3822
|
+
onOpen: function () { seenInRegistry(0); },
|
|
3823
|
+
/* 事件里一旦带出 session_id,说明 core 已经跑起来并登记了 —— 不必再查表。 */
|
|
3824
|
+
onEvent: function (ev) { if (ev && ev.session_id) fin(true); },
|
|
3825
|
+
/* code 非空 = 这一轮以失败收尾;若登记表里也没有它,就是没跑起来。
|
|
3826
|
+
原因取自 end 帧(stderr),不是猜的。 */
|
|
3827
|
+
onEnd: function (code, ev) {
|
|
3828
|
+
seenAt = seenAt || Date.now();
|
|
3829
|
+
if (code) why = endReason(code, ev) || why;
|
|
3830
|
+
/* code=0(正常收尾)却没查到记录:给 1.5s 让登记表落盘,别一收尾就判死。 */
|
|
3831
|
+
seenInRegistry(code ? 0 : 1500);
|
|
3832
|
+
},
|
|
3833
|
+
/* 网络层失败:记下真实原因(api() 已把 4xx/5xx 正文的 {"error":…} 拼进来),
|
|
3834
|
+
再给 4s 宽限继续查登记表 —— 已经起步的任务不该被断开连接判死。 */
|
|
3835
|
+
onError: function (e) {
|
|
3836
|
+
why = (e && (e.detail || e.message)) || '连接中断';
|
|
3837
|
+
seenAt = Date.now();
|
|
3838
|
+
seenInRegistry(4000);
|
|
3839
|
+
}
|
|
3840
|
+
});
|
|
3841
|
+
/* 兜底:整个确认窗内既没有 open 也没有事件。 */
|
|
3842
|
+
setTimeout(function () {
|
|
3843
|
+
if (done) return;
|
|
3844
|
+
why = why || '20 秒内没有等到服务端的任何响应';
|
|
3845
|
+
fin(false);
|
|
3846
|
+
}, LAND_TIMEOUT_MS);
|
|
3847
|
+
} catch (e) {
|
|
3848
|
+
why = (e && e.message) || '发送失败';
|
|
3849
|
+
fin(false);
|
|
3850
|
+
}
|
|
3851
|
+
});
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
/* 等待中任务的列表行(不可点开,纯状态展示)。
|
|
3855
|
+
带 session 的是**历史会话的追问**(串行门对追问一视同仁,见 gateAsk):
|
|
3856
|
+
副标题说清楚「追问」二字,否则用户会以为自己在原会话里排队,列表上却凭空多出
|
|
3857
|
+
一条「新任务」,看起来像发重了。 */
|
|
2007
3858
|
function pendingRow(p) {
|
|
2008
|
-
var b = document.createElement('div');
|
|
2009
|
-
|
|
3859
|
+
var b = document.createElement('div');
|
|
3860
|
+
b.className = 'srow srow--pending' + (p.stalled ? ' srow--stalled' : '');
|
|
3861
|
+
var ico = elSpan('srow__ico srow__ico--queued');
|
|
3862
|
+
/* 团队任务排队时也用双人图形,别先闪一下电脑再换(与 sessionIcon 同一规则)。 */
|
|
3863
|
+
ico.appendChild(svgUse(p.team ? '#i-team' : '#i-laptop', 20));
|
|
2010
3864
|
b.appendChild(ico);
|
|
2011
3865
|
var col = elSpan('srow__col');
|
|
2012
3866
|
col.appendChild(elSpan('srow__t', String(p.prompt || '新任务').slice(0, 40)));
|
|
2013
|
-
|
|
3867
|
+
var sub = elSpan('srow__s');
|
|
3868
|
+
/* stalled:上一次下发没在会话登记表里看见它(core 起不来 / 被拒)——
|
|
3869
|
+
条目留在列表里并给重试入口,绝不能因为「没发出去」就从界面上消失。
|
|
3870
|
+
后面拼上**真实原因**(end 帧 stderr / HTTP 正文 / 超时),否则用户只知道失败、
|
|
3871
|
+
不知道是路由配错还是服务没起(2026-10-21:「未能启动 为什么」)。 */
|
|
3872
|
+
var reason = p.stalled ? String(p.stalledReason || '').trim() : '';
|
|
3873
|
+
var text = p.stalled ? '未能启动' + (reason ? ':' + shortReason(reason, 40) : '') + ' · 点按重试'
|
|
3874
|
+
: (p.session ? '追问排队中 · ' : '等待中 · ') + '上一个完成后自动开始';
|
|
3875
|
+
sub.appendChild(elSpan('srow__stext', text));
|
|
3876
|
+
col.appendChild(sub);
|
|
2014
3877
|
b.appendChild(col);
|
|
2015
3878
|
b.appendChild(elSpan('srow__time', ''));
|
|
3879
|
+
if (p.stalled) {
|
|
3880
|
+
/* 行内只放得下截断版,完整原因挂 title:长按/悬停能看到 core 的原始报错。 */
|
|
3881
|
+
if (reason) b.title = reason;
|
|
3882
|
+
b.onclick = function () {
|
|
3883
|
+
if (b.dataset.busy === '1') return;
|
|
3884
|
+
b.dataset.busy = '1';
|
|
3885
|
+
enqueueDispatch(p);
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
2016
3888
|
return b;
|
|
2017
3889
|
}
|
|
2018
3890
|
|
|
3891
|
+
/* 单行原因:压掉换行/连续空白(core 的日志常带缩进),超长截断加省略号。 */
|
|
3892
|
+
function shortReason(s, max) {
|
|
3893
|
+
var one = String(s || '').replace(/\s+/g, ' ').trim();
|
|
3894
|
+
if (one.length <= max) return one;
|
|
3895
|
+
return one.slice(0, max - 1) + '…';
|
|
3896
|
+
}
|
|
3897
|
+
|
|
2019
3898
|
/* ── 通知:配置 + 真触发 ──
|
|
2020
3899
|
优先级:Capacitor LocalNotifications(App 内系统通知)→ Web Notification(浏览器已授权)→ 应用内 toast。
|
|
2021
3900
|
⚠️ App 关闭时 JS 不跑、触发不了;「任务完成 / 待确认」通知需要 App 在运行。 */
|
|
@@ -2094,7 +3973,8 @@ button.setrow{font-family:inherit}
|
|
|
2094
3973
|
fetch(BASE + '/desk/sessions', { headers: hdr }).then(function (r) { return r.ok ? r.json() : []; })
|
|
2095
3974
|
.then(function (list) {
|
|
2096
3975
|
(Array.isArray(list) ? list : []).forEach(function (s) {
|
|
2097
|
-
|
|
3976
|
+
/* 通知按**会话级** status 判(同一条会话各轮一个记录,只看 state 会拿旧轮误报)。 */
|
|
3977
|
+
var id = sidOf(s), st = String((s.status || s.state) || ''), prev = PREV_STATE[id];
|
|
2098
3978
|
if (!FIRST_POLL && prev !== undefined && prev !== st) {
|
|
2099
3979
|
if (st === 'done') notify('result', '任务完成', titleOf(s));
|
|
2100
3980
|
else if (st === 'waiting_approval') notify('confirm', '任务待确认', titleOf(s));
|
|
@@ -2105,21 +3985,25 @@ button.setrow{font-family:inherit}
|
|
|
2105
3985
|
}).catch(function () {});
|
|
2106
3986
|
}
|
|
2107
3987
|
|
|
2108
|
-
/* 工作空间选择:默认 / 根目录下 magic-* / 自定义 / 会话里出现过的,一行一个,外加「自定义路径…」。
|
|
3988
|
+
/* 工作空间选择:默认 / 根目录下 magic-* / 自定义 / 会话里出现过的,一行一个,外加「自定义路径…」。
|
|
3989
|
+
⚠️ 当前生效的那一条要打勾(hRow 的 selected):抽屉里没有任何选中标记时,
|
|
3990
|
+
点完「默认」看不出变化,用户无从判断到底选没选上(2026-10-21 报「无法选择默认」)。
|
|
3991
|
+
⚠️ 首项文案 = 「默认(不属于任何空间)」:默认的语义是**不归属任何空间目录**,
|
|
3992
|
+
不是「用某个默认目录」。写「不指定」容易被读成「没选」,写目录名更会被读成「选了那个目录」。 */
|
|
2109
3993
|
function openWorkspacePicker() {
|
|
2110
3994
|
var keys = collectSpaceKeys();
|
|
2111
3995
|
openHostSheet('选择工作空间', function (body) {
|
|
2112
|
-
hRow(body, '
|
|
3996
|
+
hRow(body, '默认(不属于任何空间)', null, function () { WS = ''; saveWs(); applyWs(); closeHostSheet(); paintRunSettings(); }, false, !WS);
|
|
2113
3997
|
keys.forEach(function (w) {
|
|
2114
3998
|
var isCustom = CUSTOM_SPACES.indexOf(w) >= 0;
|
|
2115
|
-
hRow(body, basename(w), isCustom ? '自定义' : null, function () { WS = w; saveWs(); applyWs(); closeHostSheet();
|
|
3999
|
+
hRow(body, basename(w), isCustom ? '自定义' : null, function () { WS = w; saveWs(); applyWs(); closeHostSheet(); paintRunSettings(); }, false, WS === w);
|
|
2116
4000
|
});
|
|
2117
4001
|
hRow(body, '自定义路径…', '+', function () {
|
|
2118
4002
|
var p = window.prompt('输入工作空间的绝对路径', SPACES_ROOT || DW || '');
|
|
2119
4003
|
if (p == null) return;
|
|
2120
4004
|
p = p.trim(); if (!p) return;
|
|
2121
4005
|
if (CUSTOM_SPACES.indexOf(p) < 0) { CUSTOM_SPACES.push(p); saveSpaces(); }
|
|
2122
|
-
WS = p; saveWs(); applyWs(); closeHostSheet(); loadSessions();
|
|
4006
|
+
WS = p; saveWs(); applyWs(); closeHostSheet(); loadSessions(); paintRunSettings();
|
|
2123
4007
|
});
|
|
2124
4008
|
});
|
|
2125
4009
|
}
|
|
@@ -2149,42 +4033,390 @@ button.setrow{font-family:inherit}
|
|
|
2149
4033
|
var e = document.getElementById('defaultEngine');
|
|
2150
4034
|
var m = document.getElementById('defaultModel');
|
|
2151
4035
|
if (e) e.textContent = RUN_PREF.engine || '自动(首个可用)';
|
|
2152
|
-
if (m) m.textContent = RUN_PREF.engine ? (RUN_PREF.model || '引擎默认') : '跟随引擎';
|
|
4036
|
+
if (m) m.textContent = RUN_PREF.engine ? (qaModelText(RUN_PREF.model) || '引擎默认') : '跟随引擎';
|
|
2153
4037
|
}
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
4038
|
+
|
|
4039
|
+
/* 团队模式配置(「我的」)══════════
|
|
4040
|
+
开关不在这里(按会话,在会话页右上角的运行设置里,与权限模式并排);这里配的是
|
|
4041
|
+
**每类子任务交给谁** —— 也就是「我的」那一份设备偏好。
|
|
4042
|
+
core 没有团队能力(老版本)时整张卡片藏起来:显示一组点了必然失败的配置项
|
|
4043
|
+
比不显示更糟。 */
|
|
4044
|
+
/* 「我的」只留一个入口行,右侧摘要给出「几项单独指定过」;明细全在子页面里。
|
|
4045
|
+
子页面是**计划 + 7 个角色**(用户 2026-10-22:「只需要计划+7类子任务即可」):
|
|
4046
|
+
计划行 + 角色行都由 paintTeamRoles 渲染,不再有并列的「N 个类别」那一层。 */
|
|
4047
|
+
function paintTeamPref() {
|
|
4048
|
+
var card = document.getElementById('teamCard');
|
|
4049
|
+
if (card) card.hidden = !teamSupported();
|
|
4050
|
+
/* 摘要与子页面同一口径:计划 + 7 个角色里单独配过引擎**或规则**的项数。
|
|
4051
|
+
旧版按类别配过的路由仍然生效(core 仍认),但不在界面上重复计数。 */
|
|
4052
|
+
var custom = 0;
|
|
4053
|
+
if (((TEAM.routes || {}).plan || {}).engine) custom++;
|
|
4054
|
+
teamRoleList().forEach(function (r) {
|
|
4055
|
+
var rt = (TEAM.routes || {})['role:' + r.name];
|
|
4056
|
+
if ((rt && rt.engine) || ((TEAM.rules || {})['role:' + r.name])) custom++;
|
|
2165
4057
|
});
|
|
4058
|
+
paintTeamRoles(teamRoleList());
|
|
4059
|
+
var sum = document.getElementById('teamSummary');
|
|
4060
|
+
if (sum) sum.textContent = custom ? (custom + ' 项已单独指定') : '跟随主引擎';
|
|
2166
4061
|
}
|
|
2167
|
-
|
|
2168
|
-
|
|
4062
|
+
/* 团队模式子页面 = 计划行 + 7 个角色行,全部进同一个 #teamRouteCard(只有一套路由配置)。
|
|
4063
|
+
每行常显「这类适合什么」(角色 = 规则 / 计划 = 拆解口径),右侧是引擎 ——
|
|
4064
|
+
用户要的是「描述好每一类适合的规则」,所以描述不藏进详情页。
|
|
4065
|
+
签名含行文案与引擎:变了才重建,避免 5s 轮询时闪。 */
|
|
4066
|
+
function paintTeamRoles(roles) {
|
|
4067
|
+
var card = document.getElementById('teamRouteCard');
|
|
4068
|
+
if (!card) return;
|
|
4069
|
+
var show = teamSupported();
|
|
4070
|
+
var plan = (TEAM.routes || {}).plan || {};
|
|
4071
|
+
var planSub = '拆解:' + ({ coarse: '少', standard: '标准', fine: '细' }[TEAM.split] || '标准')
|
|
4072
|
+
+ (TEAM.adaptive !== false ? ' · 自动补任务' : '')
|
|
4073
|
+
+ (TEAM.splitCode ? ' · 简单代码也拆' : '');
|
|
4074
|
+
var sig = (show ? '1' : '0') + '|plan|' + planSub + '|' + (plan.engine || '') + '|' + (plan.model || '') + '|'
|
|
4075
|
+
+ roles.map(function (r) {
|
|
4076
|
+
var rt = (TEAM.routes || {})['role:' + r.name] || {};
|
|
4077
|
+
return r.name + '|' + teamRoleDuty(r) + '|' + (rt.engine || '') + '|' + (rt.model || '');
|
|
4078
|
+
}).join(',');
|
|
4079
|
+
if (sig === TEAM_ROLE_SIG) return;
|
|
4080
|
+
TEAM_ROLE_SIG = sig;
|
|
4081
|
+
card.innerHTML = '';
|
|
4082
|
+
if (!show) return;
|
|
4083
|
+
addTeamRow(card, 'plan', '计划任务', planSub);
|
|
4084
|
+
roles.forEach(function (r) {
|
|
4085
|
+
addTeamRow(card, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name, teamRoleDuty(r));
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
/* 一行:名字 + 规则描述 + 右侧引擎。点开 = 引擎与规则(见 openTeamRoutePicker)。 */
|
|
4089
|
+
function addTeamRow(card, key, label, sub) {
|
|
4090
|
+
var rt = (TEAM.routes || {})[key] || {};
|
|
4091
|
+
var b = hEl('button', 'setrow');
|
|
4092
|
+
b.type = 'button';
|
|
4093
|
+
var main = hEl('span', 'setrow__main');
|
|
4094
|
+
main.appendChild(hEl('span', 'setrow__l', label));
|
|
4095
|
+
if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
|
|
4096
|
+
b.appendChild(main);
|
|
4097
|
+
b.appendChild(hEl('span', 'setrow__v',
|
|
4098
|
+
rt.engine ? (rt.engine + (rt.model ? ' · ' + qaModelText(rt.model) : '')) : '跟随主引擎'));
|
|
4099
|
+
b.appendChild(hEl('span', 'setrow__chev', '›'));
|
|
4100
|
+
b.onclick = function () {
|
|
4101
|
+
openTeamRoutePicker(key,
|
|
4102
|
+
String(key).indexOf('role:') === 0 ? (label + ':适合做什么、交给谁') : '计划任务:怎么拆、交给谁',
|
|
4103
|
+
teamRoleKindOf(key));
|
|
4104
|
+
};
|
|
4105
|
+
card.appendChild(b);
|
|
4106
|
+
}
|
|
4107
|
+
function teamRoleKindOf(key) {
|
|
4108
|
+
if (String(key).indexOf('role:') !== 0) return 'plan';
|
|
4109
|
+
var n = String(key).slice(5);
|
|
4110
|
+
var info = teamRoleList().filter(function (r) { return r.name === n; })[0];
|
|
4111
|
+
return (info && info.kind) || '';
|
|
4112
|
+
}
|
|
4113
|
+
/* 「这类任务适合什么」:用户改过的优先,没改过用 core 上报的职责(duty)。 */
|
|
4114
|
+
function teamRoleDuty(r) {
|
|
4115
|
+
var ov = (TEAM.rules || {})['role:' + r.name];
|
|
4116
|
+
return (ov && String(ov).trim()) || r.duty || '';
|
|
4117
|
+
}
|
|
4118
|
+
/* 点一行 → 抽屉:先「规则」,再「引擎 · 模型」,与「默认引擎」那套同一个交互。
|
|
4119
|
+
计划行:拆解粒度 + 简单代码 / 自动补任务(--team-split / --team-split-code /
|
|
4120
|
+
--team-adaptive);角色行:「这类任务适合什么」(--team-rule role:<name>=…)。
|
|
4121
|
+
首项 = 不单独指定 / 跟随主引擎。
|
|
4122
|
+
2026-10-21 口径(用户:「图像视频不是说需要生成 是擅长做 比如有一个带了视频 skill 的引擎」):
|
|
4123
|
+
这里问的是「这类活谁擅长」,所以不写「交给 core 自动路由」—— 说「自动」会让人以为
|
|
4124
|
+
不配也能跑,而实际语义是「跟主引擎一致」。 */
|
|
4125
|
+
function openTeamRoutePicker(key, title, kind) {
|
|
4126
|
+
var isRole = String(key).indexOf('role:') === 0;
|
|
4127
|
+
if (!kind) kind = key; // 计划键时 kind 就是键本身
|
|
4128
|
+
openHostSheet(title, function (body) {
|
|
2169
4129
|
var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
|
|
2170
4130
|
hint.style.textAlign = 'left';
|
|
2171
4131
|
body.appendChild(hint);
|
|
4132
|
+
/* 规则控件放在引擎清单**之前**:规则是这一行的主内容,引擎是「交给谁」。
|
|
4133
|
+
它们与探测无关,引擎清单拉失败也照样能改。分区标题必须显式给出 ——
|
|
4134
|
+
用户 2026-10-22:「入口在哪里 不能配置规则」:控件在,但没标题就读不出这里是配置区。 */
|
|
4135
|
+
body.appendChild(hEl('div', 'pick__cap pick__cap--team', '规则'));
|
|
4136
|
+
if (isRole) body.appendChild(buildTeamRuleEditor(key));
|
|
4137
|
+
else if (kind === 'plan') body.appendChild(buildTeamSplitControls());
|
|
2172
4138
|
fetchEngines().then(function (list) {
|
|
2173
4139
|
if (hint.parentNode) hint.remove();
|
|
2174
4140
|
var avail = list.filter(function (x) { return x.ok; });
|
|
2175
4141
|
if (!avail.length) {
|
|
2176
|
-
var d = hEl('div', 'history__hint',
|
|
2177
|
-
'没有可用引擎:先在这台机器上装一个(终端跑 magic-agent --engines 会给出每个引擎的一键安装命令)。');
|
|
4142
|
+
var d = hEl('div', 'history__hint', '没有可用引擎:先在桌面上装一个(magic-agent --engines 会给出安装命令)。');
|
|
2178
4143
|
d.style.textAlign = 'left';
|
|
2179
4144
|
body.appendChild(d);
|
|
2180
4145
|
return;
|
|
2181
4146
|
}
|
|
2182
|
-
|
|
4147
|
+
var stale = enginesStaleHint();
|
|
4148
|
+
if (stale) body.appendChild(stale);
|
|
4149
|
+
var cur = (TEAM.routes || {})[key] || {};
|
|
4150
|
+
var curEng = avail.filter(function (en) { return en.engine === cur.engine; })[0] || avail[0];
|
|
4151
|
+
/* 选谁不看「会不会生成」,看它擅不擅长这类活。这一行是给用户的判据,
|
|
4152
|
+
不是给 core 的配置(core 只认 engine / model)。
|
|
4153
|
+
⚠️ 必须建在 paint() **外面**:paint 每次切引擎都会重画两列,放里面会一行行堆起来
|
|
4154
|
+
(2026-10-21 实测:点两次引擎,抽屉底部就多出三段同样的说明)。 */
|
|
4155
|
+
var tipText;
|
|
4156
|
+
if (isRole) {
|
|
4157
|
+
var info = teamRoleList().filter(function (r) { return 'role:' + r.name === key; })[0];
|
|
4158
|
+
tipText = '这个角色内置的职责:' + ((info && info.duty) || '—')
|
|
4159
|
+
+ '。上面写的规则会替掉它,进计划提示词;这里留空 = 跟随主引擎。';
|
|
4160
|
+
} else {
|
|
4161
|
+
tipText = '计划阶段负责把任务拆成子任务,交给更擅长规划、上下文更长的引擎更稳。';
|
|
4162
|
+
}
|
|
4163
|
+
body.appendChild(hEl('div', 'pick__cap pick__cap--team', '交给谁'));
|
|
4164
|
+
var tip = hEl('div', 'history__hint', tipText);
|
|
4165
|
+
tip.style.textAlign = 'left';
|
|
4166
|
+
body.appendChild(tip);
|
|
4167
|
+
var wrap = hEl('div', 'pick__cols pick__cols--team');
|
|
4168
|
+
var left = hEl('div', 'pick__col');
|
|
4169
|
+
var right = hEl('div', 'pick__col pick__col--r');
|
|
4170
|
+
wrap.appendChild(left); wrap.appendChild(right); body.appendChild(wrap);
|
|
4171
|
+
function paint() {
|
|
4172
|
+
left.innerHTML = ''; right.innerHTML = '';
|
|
4173
|
+
var auto = hEl('button', 'pick__citem' + (!cur.engine ? ' is-on' : ''));
|
|
4174
|
+
auto.type = 'button';
|
|
4175
|
+
auto.appendChild(hEl('span', 'pick__cl', '跟随主引擎'));
|
|
4176
|
+
auto.appendChild(hEl('span', 'pick__cd', kind === 'plan' ? '计划阶段走主引擎' : '不单独指定,和主引擎一致'));
|
|
4177
|
+
auto.onclick = function () { setTeamRoute(key, '', ''); };
|
|
4178
|
+
left.appendChild(auto);
|
|
4179
|
+
|
|
4180
|
+
avail.forEach(function (en) {
|
|
4181
|
+
var b = hEl('button', 'pick__citem'
|
|
4182
|
+
+ (cur.engine === en.engine ? ' is-on' : '')
|
|
4183
|
+
+ (en.engine === curEng.engine && cur.engine !== en.engine ? ' is-cur' : ''));
|
|
4184
|
+
b.type = 'button';
|
|
4185
|
+
b.appendChild(hEl('span', 'pick__cl', en.engine));
|
|
4186
|
+
b.appendChild(hEl('span', 'pick__cd', (en.models || []).length ? ((en.models || []).length + ' 个模型') : '引擎默认'));
|
|
4187
|
+
b.onclick = function () { curEng = en; paint(); };
|
|
4188
|
+
left.appendChild(b);
|
|
4189
|
+
});
|
|
4190
|
+
|
|
4191
|
+
var d = hEl('button', 'pick__citem' + (cur.engine === curEng.engine && !cur.model ? ' is-on' : ''));
|
|
4192
|
+
d.type = 'button';
|
|
4193
|
+
d.appendChild(hEl('span', 'pick__cl', '引擎默认'));
|
|
4194
|
+
d.appendChild(hEl('span', 'pick__cd', '不指定模型,由引擎自己挑'));
|
|
4195
|
+
d.onclick = function () { setTeamRoute(key, curEng.engine, ''); };
|
|
4196
|
+
right.appendChild(d);
|
|
4197
|
+
|
|
4198
|
+
(curEng.models || []).forEach(function (mm) {
|
|
4199
|
+
var b = hEl('button', 'pick__citem' + (cur.engine === curEng.engine && cur.model === mm ? ' is-on' : ''));
|
|
4200
|
+
b.type = 'button';
|
|
4201
|
+
modelItem(b, curEng.engine, mm);
|
|
4202
|
+
b.onclick = function () { setTeamRoute(key, curEng.engine, mm); };
|
|
4203
|
+
right.appendChild(b);
|
|
4204
|
+
});
|
|
4205
|
+
}
|
|
4206
|
+
paint();
|
|
2183
4207
|
}, function (e) {
|
|
2184
4208
|
hint.textContent = '拉取引擎清单失败:' + ((e && e.message) || e);
|
|
2185
4209
|
});
|
|
2186
4210
|
});
|
|
2187
4211
|
}
|
|
4212
|
+
/* 角色行的「规则」:一句话写清这类任务适合什么。**默认值直接填内置职责**(用户
|
|
4213
|
+
2026-10-22:「也没看到默认规则啊」—— placeholder 在手机上几乎看不见),保存后覆盖它
|
|
4214
|
+
(core 的 --team-rule role:<name>=…);改回内置值等于没改,不落覆盖。 */
|
|
4215
|
+
function buildTeamRuleEditor(key) {
|
|
4216
|
+
var box = hEl('div', 'teamedit__box');
|
|
4217
|
+
var lab = hEl('div', 'history__hint', '这类任务适合什么(写进拆解提示词)');
|
|
4218
|
+
lab.style.textAlign = 'left';
|
|
4219
|
+
box.appendChild(lab);
|
|
4220
|
+
var sub = hEl('div', 'history__hint', '下面填的就是内置默认规则,可直接改;改完点保存才生效。');
|
|
4221
|
+
sub.style.textAlign = 'left';
|
|
4222
|
+
sub.style.padding = '0 12px 2px';
|
|
4223
|
+
sub.style.fontSize = '12px';
|
|
4224
|
+
box.appendChild(sub);
|
|
4225
|
+
var ta = hEl('textarea', 'teamedit');
|
|
4226
|
+
var name = String(key).slice(5);
|
|
4227
|
+
var info = teamRoleList().filter(function (r) { return r.name === name; })[0] || {};
|
|
4228
|
+
var builtin = String(info.duty || '').trim();
|
|
4229
|
+
ta.placeholder = builtin || '用一句话写清这类任务承接什么';
|
|
4230
|
+
ta.value = String((TEAM.rules || {})[key] || '').trim() || builtin;
|
|
4231
|
+
box.appendChild(ta);
|
|
4232
|
+
var acts = hEl('div', 'teamedit__acts');
|
|
4233
|
+
var save = hEl('button', 'btn btn--filled', '保存规则');
|
|
4234
|
+
save.type = 'button';
|
|
4235
|
+
save.onclick = function () {
|
|
4236
|
+
var v = String(ta.value || '').replace(/\s+/g, ' ').trim();
|
|
4237
|
+
/* 500 字上限与 core 同口径(core 会再判一次,这里是提前拦,省一次往返)。 */
|
|
4238
|
+
if (v.length > 500) { toast('规则最多 500 字'); return; }
|
|
4239
|
+
TEAM.rules = TEAM.rules || {};
|
|
4240
|
+
if (!v || v === builtin) delete TEAM.rules[key];
|
|
4241
|
+
else TEAM.rules[key] = v;
|
|
4242
|
+
saveTeamPref();
|
|
4243
|
+
toast((v && v !== builtin) ? '规则已保存' : '已使用内置规则');
|
|
4244
|
+
};
|
|
4245
|
+
acts.appendChild(save);
|
|
4246
|
+
var reset = hEl('button', 'btn btn--outlined', '恢复默认');
|
|
4247
|
+
reset.type = 'button';
|
|
4248
|
+
reset.onclick = function () {
|
|
4249
|
+
if (TEAM.rules) delete TEAM.rules[key];
|
|
4250
|
+
ta.value = builtin;
|
|
4251
|
+
saveTeamPref();
|
|
4252
|
+
toast('已恢复内置规则');
|
|
4253
|
+
};
|
|
4254
|
+
acts.appendChild(reset);
|
|
4255
|
+
box.appendChild(acts);
|
|
4256
|
+
return box;
|
|
4257
|
+
}
|
|
4258
|
+
/* 计划行的「规则」:拆解粒度 + 两个开关,与 core 的 --team-split / --team-split-code /
|
|
4259
|
+
--team-adaptive 一一对应。改完立刻落盘(配置每轮现读,见 mountConversation 的 teamConfig)。 */
|
|
4260
|
+
function buildTeamSplitControls() {
|
|
4261
|
+
var box = hEl('div', '');
|
|
4262
|
+
var lab = hEl('div', 'history__hint', '怎么拆:粒度决定最多拆几个子任务');
|
|
4263
|
+
lab.style.textAlign = 'left';
|
|
4264
|
+
box.appendChild(lab);
|
|
4265
|
+
var row = hEl('div', 'setrow');
|
|
4266
|
+
row.appendChild(hEl('span', 'setrow__l', '拆解粒度'));
|
|
4267
|
+
var seg = hEl('span', 'seg');
|
|
4268
|
+
[['coarse', '少'], ['standard', '标准'], ['fine', '细']].forEach(function (p) {
|
|
4269
|
+
var b = hEl('button', 'seg__b' + (TEAM.split === p[0] ? ' is-on' : ''), p[1]);
|
|
4270
|
+
b.type = 'button';
|
|
4271
|
+
b.onclick = function () {
|
|
4272
|
+
TEAM.split = p[0];
|
|
4273
|
+
saveTeamPref();
|
|
4274
|
+
markSeg(seg, p[0]);
|
|
4275
|
+
};
|
|
4276
|
+
seg.appendChild(b);
|
|
4277
|
+
});
|
|
4278
|
+
row.appendChild(seg);
|
|
4279
|
+
box.appendChild(row);
|
|
4280
|
+
box.appendChild(teamToggleRow('简单代码也拆',
|
|
4281
|
+
function () { return !!TEAM.splitCode; },
|
|
4282
|
+
function () { TEAM.splitCode = !TEAM.splitCode; }));
|
|
4283
|
+
box.appendChild(teamToggleRow('自动补任务',
|
|
4284
|
+
function () { return TEAM.adaptive !== false; },
|
|
4285
|
+
function () { TEAM.adaptive = (TEAM.adaptive === false); }));
|
|
4286
|
+
return box;
|
|
4287
|
+
}
|
|
4288
|
+
function teamToggleRow(label, isOn, toggle) {
|
|
4289
|
+
var row = hEl('div', 'setrow');
|
|
4290
|
+
row.appendChild(hEl('span', 'setrow__l', label));
|
|
4291
|
+
var t = hEl('button', 'tgl' + (isOn() ? ' is-on' : ''));
|
|
4292
|
+
t.type = 'button';
|
|
4293
|
+
t.onclick = function () { toggle(); saveTeamPref(); t.classList.toggle('is-on', isOn()); };
|
|
4294
|
+
row.appendChild(t);
|
|
4295
|
+
return row;
|
|
4296
|
+
}
|
|
4297
|
+
function setTeamRoute(key, engine, model) {
|
|
4298
|
+
TEAM.routes = TEAM.routes || {};
|
|
4299
|
+
if (!engine) delete TEAM.routes[key];
|
|
4300
|
+
else TEAM.routes[key] = model ? { engine: engine, model: model } : { engine: engine };
|
|
4301
|
+
saveTeamPref();
|
|
4302
|
+
applyTeamToLive();
|
|
4303
|
+
closeHostSheet();
|
|
4304
|
+
var r = (TEAM.routes || {})[key];
|
|
4305
|
+
var label = teamRouteLabel(key);
|
|
4306
|
+
toast(r ? (label + '交给 ' + r.engine + (r.model ? ' · ' + qaModelText(r.model) : ''))
|
|
4307
|
+
: (label + ':跟随主引擎'));
|
|
4308
|
+
}
|
|
4309
|
+
/* 配置改了要同步到活着的组件(组件每轮现读 teamConfig,改完下一轮就生效)。
|
|
4310
|
+
⚠️ 这里**不碰开关**:开关是每条会话自己的状态,改「哪类任务交给谁」不该顺手把
|
|
4311
|
+
当前会话的团队模式打开/关掉。 */
|
|
4312
|
+
function applyTeamToLive() {
|
|
4313
|
+
paintTeamPref();
|
|
4314
|
+
paintRunSettings();
|
|
4315
|
+
}
|
|
4316
|
+
/* 用户在**组件自己的**团队面板里改了开关 → 回写到**当前这条会话**。
|
|
4317
|
+
面板只是入口,权威状态在宿主的按会话表里(`TEAM.sessions[sid]`);
|
|
4318
|
+
不回传的话组件一重建,刚开的开关就没了。
|
|
4319
|
+
新会话还没有 session_id:先记在 TEAM.fresh,首帧带回 id 时由 bindFreshTeam 落到会话上。 */
|
|
4320
|
+
function onTeamToggleFromConv(on) {
|
|
4321
|
+
var sid = (conv && conv.state && conv.state.session) || '';
|
|
4322
|
+
if (sid) {
|
|
4323
|
+
TEAM.sessions[String(sid)] = !!on;
|
|
4324
|
+
TEAM.fresh = false;
|
|
4325
|
+
} else {
|
|
4326
|
+
TEAM.fresh = !!on;
|
|
4327
|
+
}
|
|
4328
|
+
saveTeamPref();
|
|
4329
|
+
paintRunSettings();
|
|
4330
|
+
}
|
|
4331
|
+
/* 一行模型(左名字、右倍率):名字过长只挤自己,倍率列不会跟着抖
|
|
4332
|
+
(`.pick__crow` 样式见 shared/conversation.css)。
|
|
4333
|
+
倍率本身不在宿主里再算一遍 —— 直接用内核的 qaModelCredit(它认 core 原样
|
|
4334
|
+
snake_case 与 PC 归一后的 camelCase,且**没数据就返回空串**,不编「×1」)。 */
|
|
4335
|
+
function modelItem(b, engine, id) {
|
|
4336
|
+
var row = hEl('span', 'pick__crow');
|
|
4337
|
+
row.appendChild(hEl('span', 'pick__cl', qaModelText(id)));
|
|
4338
|
+
var credit = qaModelCredit(ENGINES, engine, id);
|
|
4339
|
+
if (credit) row.appendChild(hEl('span', 'pick__cr', credit));
|
|
4340
|
+
b.appendChild(row);
|
|
4341
|
+
}
|
|
4342
|
+
/* 引擎清单:走 client 的 /desk/engines(= core --engines 的原样 JSON,含 models)。
|
|
4343
|
+
⚠️ 这个请求会真的起各引擎的只读子命令探测模型,慢(秒级),所以只在打开选择器时拉,
|
|
4344
|
+
「我的」页本身不请求 —— 显示的是已记住的值,打开选择器才现探测。 */
|
|
4345
|
+
/* 探测引擎要真起 CLI(单引擎模型探测上限 30s,插件侧 45s),所以这里给 50s 硬超时:
|
|
4346
|
+
没有它的话,请求一旦不返回(core 被祖先进程握管道杀掉、网络半断),弹层就永远
|
|
4347
|
+
停在「正在探测引擎与模型…」—— 2026-10-21 实测过。 */
|
|
4348
|
+
var ENGINES_TIMEOUT_MS = 50000;
|
|
4349
|
+
function fetchEngines() {
|
|
4350
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
4351
|
+
var opts = { headers: hdr };
|
|
4352
|
+
var ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
4353
|
+
var timer = null;
|
|
4354
|
+
if (ctrl) {
|
|
4355
|
+
opts.signal = ctrl.signal;
|
|
4356
|
+
timer = setTimeout(function () { ctrl.abort(); }, ENGINES_TIMEOUT_MS);
|
|
4357
|
+
}
|
|
4358
|
+
function stopTimer() { if (timer) { clearTimeout(timer); timer = null; } }
|
|
4359
|
+
return fetch(BASE + '/desk/engines?models=1', opts).then(function (r) {
|
|
4360
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
4361
|
+
/* 插件侧探测失败/超时但存有上次成功的清单时,会带这个头把缓存回给我们:
|
|
4362
|
+
值 = 缓存时间。看到它就说明「这份清单不是刚探测的」,选择器里要讲清楚。 */
|
|
4363
|
+
ENGINES_STALE_AT = r.headers.get('X-Magic-Engines-Stale') || '';
|
|
4364
|
+
return r.json();
|
|
4365
|
+
}).then(function (list) {
|
|
4366
|
+
stopTimer();
|
|
4367
|
+
ENGINES = (Array.isArray(list) ? list : []).filter(function (x) { return x && x.engine; });
|
|
4368
|
+
return ENGINES;
|
|
4369
|
+
}, function (e) {
|
|
4370
|
+
stopTimer();
|
|
4371
|
+
if (e && (e.name === 'AbortError' || e.code === 20)) {
|
|
4372
|
+
throw new Error('探测超时(' + Math.round(ENGINES_TIMEOUT_MS / 1000) + 's 内没返回,引擎 CLI 可能卡住了)');
|
|
4373
|
+
}
|
|
4374
|
+
throw e;
|
|
4375
|
+
});
|
|
4376
|
+
}
|
|
4377
|
+
/* 缓存提示(stale-while-revalidate:插件立刻回上次的清单,同时在后台重探)。无缓存返回 null。 */
|
|
4378
|
+
function enginesStaleHint() {
|
|
4379
|
+
if (!ENGINES_STALE_AT) return null;
|
|
4380
|
+
var d = new Date(ENGINES_STALE_AT);
|
|
4381
|
+
var when = isNaN(d.getTime()) ? ENGINES_STALE_AT
|
|
4382
|
+
: (d.getMonth() + 1) + '月' + d.getDate() + '日 '
|
|
4383
|
+
+ ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2);
|
|
4384
|
+
var el = hEl('div', 'history__hint',
|
|
4385
|
+
'这是 ' + when + ' 的探测结果,正在后台刷新(下次打开就是最新的)。');
|
|
4386
|
+
el.style.textAlign = 'left';
|
|
4387
|
+
return el;
|
|
4388
|
+
}
|
|
4389
|
+
function openDefaultEnginePicker() {
|
|
4390
|
+
openHostSheet('默认引擎 / 模型', function (body) {
|
|
4391
|
+
function load() {
|
|
4392
|
+
body.innerHTML = '';
|
|
4393
|
+
var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
|
|
4394
|
+
hint.style.textAlign = 'left';
|
|
4395
|
+
body.appendChild(hint);
|
|
4396
|
+
fetchEngines().then(function (list) {
|
|
4397
|
+
if (hint.parentNode) hint.remove();
|
|
4398
|
+
var stale = enginesStaleHint();
|
|
4399
|
+
if (stale) body.appendChild(stale);
|
|
4400
|
+
var avail = list.filter(function (x) { return x.ok; });
|
|
4401
|
+
if (!avail.length) {
|
|
4402
|
+
var d = hEl('div', 'history__hint',
|
|
4403
|
+
'没有可用引擎:先在这台机器上装一个(终端跑 magic-agent --engines 会给出每个引擎的一键安装命令)。');
|
|
4404
|
+
d.style.textAlign = 'left';
|
|
4405
|
+
body.appendChild(d);
|
|
4406
|
+
return;
|
|
4407
|
+
}
|
|
4408
|
+
buildEngineCascade(body, avail);
|
|
4409
|
+
}, function (e) {
|
|
4410
|
+
hint.textContent = '拉取引擎清单失败:' + ((e && e.message) || e);
|
|
4411
|
+
var retry = hEl('button', 'pick__citem', '重试');
|
|
4412
|
+
retry.type = 'button';
|
|
4413
|
+
retry.onclick = load;
|
|
4414
|
+
body.appendChild(retry);
|
|
4415
|
+
});
|
|
4416
|
+
}
|
|
4417
|
+
load();
|
|
4418
|
+
});
|
|
4419
|
+
}
|
|
2188
4420
|
/* 二列级联(左引擎 · 右该引擎的模型),与对话框里那套同构:
|
|
2189
4421
|
点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。 */
|
|
2190
4422
|
function buildEngineCascade(body, avail) {
|
|
@@ -2230,7 +4462,9 @@ button.setrow{font-family:inherit}
|
|
|
2230
4462
|
(cur.models || []).forEach(function (mm) {
|
|
2231
4463
|
var b = hEl('button', 'pick__citem' + (RUN_PREF.engine === cur.engine && RUN_PREF.model === mm ? ' is-on' : ''));
|
|
2232
4464
|
b.type = 'button';
|
|
2233
|
-
|
|
4465
|
+
/* 显示剥掉 `custom-local:` 前缀(`mm` 本身是完整 id,选中时原样存进 RUN_PREF),
|
|
4466
|
+
倍率挂同行右侧。 */
|
|
4467
|
+
modelItem(b, cur.engine, mm);
|
|
2234
4468
|
b.onclick = function () { pickRunPref(cur.engine, mm); };
|
|
2235
4469
|
right.appendChild(b);
|
|
2236
4470
|
});
|
|
@@ -2244,7 +4478,7 @@ button.setrow{font-family:inherit}
|
|
|
2244
4478
|
applyRunPrefToLive();
|
|
2245
4479
|
closeHostSheet();
|
|
2246
4480
|
toast(RUN_PREF.engine
|
|
2247
|
-
? ('默认引擎:' + RUN_PREF.engine + ' · ' + (RUN_PREF.model || '引擎默认模型'))
|
|
4481
|
+
? ('默认引擎:' + RUN_PREF.engine + ' · ' + (qaModelText(RUN_PREF.model) || '引擎默认模型'))
|
|
2248
4482
|
: '默认引擎:自动(首个可用)');
|
|
2249
4483
|
}
|
|
2250
4484
|
/* 改默认值只同步**还没发过任务的新会话**;历史会话保持它自己的引擎,
|
|
@@ -2302,21 +4536,47 @@ button.setrow{font-family:inherit}
|
|
|
2302
4536
|
connect(target);
|
|
2303
4537
|
};
|
|
2304
4538
|
addrEl.addEventListener('input', function () { showError(''); });
|
|
2305
|
-
on('btnClear', function () {
|
|
4539
|
+
on('btnClear', function () { clearConns(); renderRecent(); });
|
|
4540
|
+
/* 任务页标题 = 环境切换菜单(多连接)。已连着桌面时,从启动页进来也能退回去。 */
|
|
4541
|
+
on('btnEnvSwitch', openEnvMenu);
|
|
4542
|
+
on('btnLauncherBack', function () { if (BASE || TOKEN) showHistory(); });
|
|
2306
4543
|
/* 别处起的任务要能自己冒出来(列表页可见时静默轮询;见 startListPoll 的注释) */
|
|
2307
4544
|
startListPoll();
|
|
2308
4545
|
on('btnList', function () { showHistory(); });
|
|
4546
|
+
/* 任务页:页头右上角搜索 + 我的;搜索态平铺。 */
|
|
2309
4547
|
on('btnNewTask', function () { newConversation(); });
|
|
2310
|
-
on('
|
|
4548
|
+
on('btnTaskSearch', function () { setTaskSearch(true); });
|
|
4549
|
+
on('btnTaskSearchClose', function () { setTaskSearch(false); });
|
|
4550
|
+
on('btnTaskMe', showMine);
|
|
4551
|
+
taskSearchInput.addEventListener('input', function () {
|
|
4552
|
+
TASK_QUERY = String(taskSearchInput.value || '').trim().toLowerCase();
|
|
4553
|
+
renderSessions(LAST_SESSIONS);
|
|
4554
|
+
});
|
|
4555
|
+
/* 会话页右上角 → 运行设置**独立子页面**(不再是底部抽屉),返回回会话。 */
|
|
4556
|
+
on('btnSettings', showRunSettings);
|
|
4557
|
+
on('btnRunSettingsBack', function () { showSession(); });
|
|
4558
|
+
on('btnRsWorkspace', function () { openWorkspacePicker(); });
|
|
4559
|
+
/* 权限 / 团队沿用会话组件里那唯一一份选择器(浮层盖在本页之上);选完就地重画。 */
|
|
4560
|
+
on('btnRsPermission', function () { if (conv && conv.openPermissionPicker) conv.openPermissionPicker(); });
|
|
4561
|
+
on('btnRsTeam', function () { if (conv && conv.openTeamPicker) conv.openTeamPicker(); });
|
|
4562
|
+
/* 选择器由会话组件管理、没有回调通知宿主,所以用一次点击兜底重画:
|
|
4563
|
+
本页可见时任何一次点击(包括选择器里点中一行)之后重读状态。 */
|
|
4564
|
+
document.addEventListener('click', function () {
|
|
4565
|
+
if (!runSettingsPageEl.hidden) paintRunSettings();
|
|
4566
|
+
});
|
|
2311
4567
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
2312
4568
|
b.onclick = function () {
|
|
2313
4569
|
var t = b.getAttribute('data-tab');
|
|
2314
4570
|
if (t === 'tasks') showHistory();
|
|
2315
|
-
else if (t === '
|
|
4571
|
+
else if (t === 'reqs') showReqs();
|
|
2316
4572
|
else if (t === 'discover') showDiscover();
|
|
2317
4573
|
else if (t === 'mine') showMine();
|
|
2318
4574
|
};
|
|
2319
4575
|
});
|
|
4576
|
+
/* 中间那枚「+」= 快速新建会话(不是 tab,不换页签)。
|
|
4577
|
+
走 newConversation() 而**不传空间**:与「新建任务」大按钮同一条口径 ——
|
|
4578
|
+
归属只在用户显式选空间时确定,不能让上一次的 WS 悄悄粘过来。 */
|
|
4579
|
+
on('btnFabNew', function () { newConversation(); });
|
|
2320
4580
|
on('btnMineBack', function () { showHistory(); });
|
|
2321
4581
|
on('dscBrowser', function () { openBrowser(HOME_URL); });
|
|
2322
4582
|
on('dscApps', function () { openComingSheet('应用', '暂无可用应用。桌面端发布的应用会出现在这里。'); });
|
|
@@ -2335,6 +4595,10 @@ button.setrow{font-family:inherit}
|
|
|
2335
4595
|
on('btnNotify', openNotifySettings);
|
|
2336
4596
|
on('btnDefaultEngine', openDefaultEnginePicker);
|
|
2337
4597
|
on('btnDefaultModel', openDefaultEnginePicker);
|
|
4598
|
+
/* 团队模式配置(「我的」→ 子页面):计划 + 7 个角色,行由 paintTeamRoles 动态渲染,
|
|
4599
|
+
行的点击在渲染时绑定(见 addTeamRow / openTeamRoutePicker),这里只留入口与返回。 */
|
|
4600
|
+
on('btnTeamPage', showTeamPage);
|
|
4601
|
+
on('btnTeamBack', showMine);
|
|
2338
4602
|
on('btnLogout', disconnect);
|
|
2339
4603
|
on('btnUpgrade', reloadApp);
|
|
2340
4604
|
if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
|
|
@@ -2440,6 +4704,7 @@ button.setrow{font-family:inherit}
|
|
|
2440
4704
|
if (injectedToken || injectedBase) {
|
|
2441
4705
|
BASE = injectedBase; TOKEN = injectedToken;
|
|
2442
4706
|
showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
|
|
4707
|
+
restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
|
|
2443
4708
|
} else {
|
|
2444
4709
|
showLauncher();
|
|
2445
4710
|
}
|