magic-agent 1.0.110 → 2.1.5
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 +14 -0
- package/client-ui-mobile/index.html +1046 -240
- package/cmd/magic-client/main.go +1 -0
- package/internal/client/apk.go +135 -0
- package/internal/client/apk_test.go +99 -0
- package/internal/client/prefs.go +99 -0
- package/internal/client/service.go +191 -0
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +5 -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 +2 -1
|
@@ -206,6 +206,10 @@ textarea{font:inherit}
|
|
|
206
206
|
.field label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin-bottom:4px}
|
|
207
207
|
.field textarea{display:block;width:100%;border:0;outline:0;resize:none;background:none;
|
|
208
208
|
color:var(--ink);font:15px/20px var(--mono);min-height:40px}
|
|
209
|
+
/* 名称栏(2026-10-22 用户:「添加连接也没有输入名字的地方」):单行输入,与地址栏同样式 */
|
|
210
|
+
.field input{display:block;width:100%;height:40px;border:0;outline:0;background:none;
|
|
211
|
+
color:var(--ink);font-family:inherit;font-size:15px;line-height:20px;-webkit-appearance:none}
|
|
212
|
+
.field+.field{margin-top:12px}
|
|
209
213
|
.field__err{color:var(--error-text);font-size:12px;line-height:16px;margin-top:6px}
|
|
210
214
|
/* Button(filled)/ OutlinedButton:40dp 高 · 全圆角 */
|
|
211
215
|
.btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;height:40px;
|
|
@@ -361,6 +365,12 @@ textarea{font:inherit}
|
|
|
361
365
|
.wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
|
|
362
366
|
.wsrow__open:active{background:var(--subtle)}
|
|
363
367
|
.wsrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
368
|
+
/* 空间规模(几模块 · 几条):紧跟在空间名后面,中性色 —— 不是状态,别用徽标色。
|
|
369
|
+
需求页专属:空间名改成自适应宽度,meta 才会贴在名字后面;meta 的 margin-right:auto
|
|
370
|
+
把「待整理」徽标和箭头推到行尾。 */
|
|
371
|
+
.wsrow__open--req .wsrow__l{flex:0 1 auto}
|
|
372
|
+
.wsrow__open--req .wsrow__meta{margin-right:auto}
|
|
373
|
+
.wsrow__meta{flex:0 1 auto;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
364
374
|
/* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致) */
|
|
365
375
|
.wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
|
|
366
376
|
color:var(--queued);background:var(--tint-queued);white-space:nowrap}
|
|
@@ -377,6 +387,24 @@ textarea{font:inherit}
|
|
|
377
387
|
/* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
|
|
378
388
|
后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
|
|
379
389
|
.wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
|
|
390
|
+
|
|
391
|
+
/* ══════════ 需求页:两级(空间→模块 → 需求列表)══════════
|
|
392
|
+
一级是**目录**:空间是行,模块缩进挂在空间下(树形),功能点一律不画在这里 ——
|
|
393
|
+
需求一多,一级就会被功能点撑成长列表,什么结构都看不出来。
|
|
394
|
+
二级才是内容:点某个模块进去,看**该空间下所有模块**的需求列表。 */
|
|
395
|
+
.reqtree__mod{position:relative;display:flex;align-items:center;gap:8px;width:100%;
|
|
396
|
+
padding:9px 10px 9px 30px;background:none;border:0;text-align:left;color:var(--ink)}
|
|
397
|
+
.reqtree__mod:active{background:var(--subtle)}
|
|
398
|
+
/* 连接线:从空间名垂下来的细线 + 每个模块前的一小截横线 —— 归属关系靠线说话,不靠缩进猜 */
|
|
399
|
+
.reqtree__mod::before{content:"";position:absolute;left:14px;top:0;bottom:0;width:1px;background:var(--border)}
|
|
400
|
+
.reqtree__mod::after{content:"";position:absolute;left:14px;top:50%;width:10px;height:1px;background:var(--border)}
|
|
401
|
+
.reqtree__modname{flex:1;min-width:0;font-size:14px;font-weight:500;color:var(--ink);
|
|
402
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
403
|
+
.reqtree__modcount{flex:0 0 auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
404
|
+
.reqtree__modgo{flex:0 0 auto;color:var(--faint);display:flex;align-items:center}
|
|
405
|
+
/* 二级视图的分组小节:当前所在模块高亮,其余弱化 */
|
|
406
|
+
.reqdetail__sec--cur .sechead{color:var(--primary-action)}
|
|
407
|
+
.reqdetail__sec--cur .sechead__t{font-weight:800}
|
|
380
408
|
/* 长按会话行的浮动菜单 */
|
|
381
409
|
.rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
|
|
382
410
|
border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
|
|
@@ -385,7 +413,7 @@ textarea{font:inherit}
|
|
|
385
413
|
.rowmenu__item:active{background:var(--subtle)}
|
|
386
414
|
.rowmenu__item--danger{color:var(--error-text)}
|
|
387
415
|
.rowmenu__l{flex:1;min-width:0}
|
|
388
|
-
/* 环境切换菜单(挂在任务页标题下):已保存的连接 +
|
|
416
|
+
/* 环境切换菜单(挂在任务页标题下):已保存的连接 + 「环境设置」/「扫码或粘贴地址」。
|
|
389
417
|
与会话行长按菜单同一套浮层范式(scrim 挡住下层,点空白即关)。 */
|
|
390
418
|
.envmenu{position:fixed;z-index:1201;width:min(320px,calc(100vw - 24px));
|
|
391
419
|
max-height:min(70vh,560px);overflow-y:auto;
|
|
@@ -405,7 +433,13 @@ textarea{font:inherit}
|
|
|
405
433
|
font-size:11px;font-weight:600;line-height:17px;color:var(--primary-action);background:var(--subtle)}
|
|
406
434
|
.envmenu__s{font-size:12px;line-height:16px;color:var(--muted);
|
|
407
435
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
408
|
-
|
|
436
|
+
/* 当前连接用**底色**区分,不再挂一枚 ✓(2026-10-22 用户:「不需要选中这个标识 用底色区分」)。
|
|
437
|
+
底色要能压过同一行的 :active 态,所以写在它后面。 */
|
|
438
|
+
.envmenu__item.is-active{background:var(--subtle)}
|
|
439
|
+
/* 每行的修改入口:铅笔比「›」更明确是「编辑」,且不与整行的「切换」动作打架 */
|
|
440
|
+
.envmenu__edit{flex:0 0 auto;width:36px;height:36px;border-radius:50%;
|
|
441
|
+
display:flex;align-items:center;justify-content:center;color:var(--muted)}
|
|
442
|
+
.envmenu__edit:active{background:var(--subtle);color:var(--ink)}
|
|
409
443
|
.envmenu__sep{display:block;height:1px;margin:6px 4px;background:var(--border)}
|
|
410
444
|
.envmenu__add{display:flex;align-items:center;gap:10px;width:100%;padding:12px;border-radius:12px;
|
|
411
445
|
font-size:15px;font-weight:500;color:var(--primary-action);text-align:left}
|
|
@@ -417,8 +451,10 @@ textarea{font:inherit}
|
|
|
417
451
|
box-shadow:0 12px 30px -12px var(--app-shadow)}
|
|
418
452
|
button.setrow{font-family:inherit}
|
|
419
453
|
/* 默认引擎/模型选择器:左列「自动」与正在浏览的引擎不能都画成选中态(两个浅蓝块会让人
|
|
420
|
-
分不清哪个是已保存的默认)。is-on =
|
|
421
|
-
|
|
454
|
+
分不清哪个是已保存的默认)。is-on = 已保存(实底);is-cur = 右列此刻在浏览谁。
|
|
455
|
+
浏览态**不铺底**,只留一道左侧细条 + 主色文字 —— 有底色就会被读成「第二个选中项」。 */
|
|
456
|
+
.pick__citem.is-cur{box-shadow:inset 2px 0 0 var(--primary-action)}
|
|
457
|
+
.pick__citem.is-cur .pick__cl{color:var(--primary-action)}
|
|
422
458
|
|
|
423
459
|
/* ══════════ 发现:插件目录 ══════════
|
|
424
460
|
两项 magic-* 工程的 CLI(magic-shorts / magic-video)。数据来自桌面端 `/desk/plugins`:
|
|
@@ -460,8 +496,26 @@ button.setrow{font-family:inherit}
|
|
|
460
496
|
.setrow+.setrow{border-top:1px solid var(--border)}
|
|
461
497
|
.setrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink)}
|
|
462
498
|
.setrow__v{flex:0 0 auto;font-size:14px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
|
|
499
|
+
/* 有新版本时这一格变成可点的「升级」提示(2026-10-22 用户:「版本现在也没点击升级的地方」) */
|
|
500
|
+
.setrow__v.is-upgradable{color:var(--primary-action);font-weight:600}
|
|
463
501
|
.setrow--danger{color:var(--error-text)}
|
|
464
502
|
.setrow--danger .setrow__l,.setrow--danger .setrow__v{color:var(--error-text)}
|
|
503
|
+
/* 环境编辑(我的 → 环境):名称 / 地址 / 令牌三栏。
|
|
504
|
+
环境不能只靠扫码「连过才记得」—— 用户要显式配置 IP 和名字(2026-10-22)。 */
|
|
505
|
+
.envfield{display:block;width:100%;height:44px;margin-top:6px;padding:0 12px;border-radius:10px;
|
|
506
|
+
background:var(--subtle);border:1px solid transparent;color:var(--ink);font-size:15px;
|
|
507
|
+
-webkit-appearance:none;outline:none}
|
|
508
|
+
.envfield:focus{border-color:var(--primary-action)}
|
|
509
|
+
.envfield::placeholder{color:var(--faint)}
|
|
510
|
+
.envlabel{display:block;margin-top:14px;font-size:12px;font-weight:700;color:var(--muted)}
|
|
511
|
+
/* 链接栏下方的「扫码填入」:文字按钮,不抢「保存」的位置 */
|
|
512
|
+
.envscan{display:block;margin-top:8px;padding:6px 0;font-size:13px;font-weight:600;color:var(--primary-action)}
|
|
513
|
+
.envhint{margin-top:10px;font-size:12px;line-height:1.6;color:var(--faint)}
|
|
514
|
+
.envacts{display:flex;gap:8px;margin-top:16px}
|
|
515
|
+
.envacts .btn{flex:1;margin-top:0}
|
|
516
|
+
.envdel{margin-top:14px;width:100%;height:40px;border-radius:var(--r-full);
|
|
517
|
+
color:var(--error-text);font-size:14px;font-weight:600}
|
|
518
|
+
.envdel:active{background:var(--subtle)}
|
|
465
519
|
/* 两行式配置行(团队模式):左列是「名字 + 这条适合什么」,右列是引擎。
|
|
466
520
|
规则描述必须常显 —— 用户要的是「每类适合什么」一眼可见,而不是藏进详情页。 */
|
|
467
521
|
.setrow__main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
@@ -473,10 +527,16 @@ button.setrow{font-family:inherit}
|
|
|
473
527
|
font:13px/1.55 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;-webkit-appearance:none}
|
|
474
528
|
.teamedit::placeholder{color:var(--faint)}
|
|
475
529
|
.teamedit__acts{display:flex;gap:8px;margin-bottom:10px}
|
|
476
|
-
/*
|
|
477
|
-
.pick__cap--team{padding:10px 4px 4px;font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--muted)}
|
|
530
|
+
/* 团队行尾箭头:点开才有的配置必须看得出「这里能点」。 */
|
|
478
531
|
.setrow__chev{flex:0 0 auto;color:var(--faint);font-size:16px;line-height:1}
|
|
479
|
-
|
|
532
|
+
/* 团队行 = **一行两个点击区**(用户 2026-10-22:「两个触发就行了」):
|
|
533
|
+
左边名字 + 规则描述 → 只改规则;右边引擎值 → 只选引擎。外层是 div(button 不能套 button)。 */
|
|
534
|
+
.setrow--split{padding:0;gap:4px}
|
|
535
|
+
.setrow__mainbtn{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;
|
|
536
|
+
padding:14px 2px 14px 0;text-align:left}
|
|
537
|
+
.setrow__vbtn{flex:0 0 auto;display:flex;align-items:center;gap:4px;max-width:52%;
|
|
538
|
+
padding:14px 0 14px 10px;color:var(--muted);text-align:right}
|
|
539
|
+
.setrow__vbtn .setrow__v{max-width:100%}
|
|
480
540
|
.seg{display:inline-flex;background:var(--subtle);border-radius:var(--r-full);padding:2px;gap:2px}
|
|
481
541
|
.seg__b{height:28px;padding:0 12px;border-radius:var(--r-full);font-size:13px;color:var(--muted)}
|
|
482
542
|
.seg__b.is-on{background:var(--raised);color:var(--ink);font-weight:600}
|
|
@@ -491,32 +551,34 @@ button.setrow{font-family:inherit}
|
|
|
491
551
|
从栏背滚过去,靠半透明 + 18px 模糊透出来。这样栏高不进布局,会话页的输入框也不用被它顶上去。
|
|
492
552
|
|
|
493
553
|
代价是**每一页自己留出底部安全距离**,否则最后一行会被压在栏下面:
|
|
494
|
-
· `.history__list` 已有 96px(够
|
|
554
|
+
· `.history__list` 已有 96px(够 56 + 34 safe-area,且给凸出的 + 留了一截)
|
|
495
555
|
· 会话页由 composer 的下内边距负责,见下面的 `#conv .composer`
|
|
496
556
|
· 启动页不显示底栏(showLauncher 里 hidden),无需留白
|
|
497
557
|
|
|
498
558
|
min-height 而不是 height:底栏还要吞 safe-area(34px),
|
|
499
|
-
border-box 下固定 height 会让内部 tab 被压到 26px
|
|
500
|
-
**
|
|
501
|
-
|
|
559
|
+
border-box 下固定 height 会让内部 tab 被压到 26px 高。
|
|
560
|
+
**56 必须写成 `56 + safe-area`**(2026-10-22 实机:「手机上现在 tab 上下没间距」):
|
|
561
|
+
`*{box-sizing:border-box}` 下 min-height 含 padding,56 里先被 safe-area 吃掉 34,
|
|
562
|
+
内容只剩 22 —— 44×34 的选中浅底直接顶到了栏的上下边。桌面上 env()=0 看不出这个问题。
|
|
563
|
+
**56**(「底栏只保留图标不需要文字了」):去掉标签后只剩 25px 图标 + 44×34 浅底,
|
|
564
|
+
56 给到浅底上下各 11px,栏体也矮下来。 */
|
|
502
565
|
.tabbar{position:absolute;left:0;right:0;bottom:0;z-index:60;
|
|
503
566
|
display:flex;align-items:stretch;
|
|
504
|
-
min-height:
|
|
567
|
+
min-height:calc(56px + env(safe-area-inset-bottom));
|
|
505
568
|
background:color-mix(in srgb,var(--raised) 86%,transparent);
|
|
506
569
|
-webkit-backdrop-filter:blur(18px) saturate(1.4);backdrop-filter:blur(18px) saturate(1.4);
|
|
507
570
|
box-shadow:0 -1px 0 var(--border),0 -14px 30px -26px var(--app-shadow);
|
|
508
571
|
padding-bottom:env(safe-area-inset-bottom)}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
572
|
+
/* 只有图标:文案从底栏拿掉(2026-10-22 用户),语义交给 aria-label,不靠视觉标签。
|
|
573
|
+
命中区仍是 flex:1 × 56,比"图标 + 文字"那版更高更宽,点起来更稳。 */
|
|
574
|
+
.tab{flex:1;display:flex;align-items:center;justify-content:center;
|
|
575
|
+
color:var(--muted);transition:color .16s}
|
|
512
576
|
.tab.is-on{color:var(--primary-action)}
|
|
513
|
-
/* 选中态不只换颜色,还给一块
|
|
514
|
-
|
|
515
|
-
.tab .icw{display:grid;place-items:center;width:
|
|
577
|
+
/* 选中态不只换颜色,还给一块 44×34 的浅底(`--activity` 是现成的品牌色低透明令牌):
|
|
578
|
+
没有文字之后,颜色是唯一的语义线索,必须再补一个形状 —— 暗色下主色是浅蓝,光靠颜色区分太弱。 */
|
|
579
|
+
.tab .icw{display:grid;place-items:center;width:44px;height:34px;border-radius:11px;
|
|
516
580
|
transition:background .18s ease}
|
|
517
581
|
.tab.is-on .icw{background:var(--activity)}
|
|
518
|
-
.tab .lb{line-height:1.15;transition:font-weight .16s}
|
|
519
|
-
.tab.is-on .lb{font-weight:600}
|
|
520
582
|
.tab svg{display:block;transition:transform .16s}
|
|
521
583
|
.tab:active svg{transform:scale(.92)}
|
|
522
584
|
/* ── 「快速新建」:**半嵌入**底栏的圆形主按钮(2026-10-22 定稿)
|
|
@@ -534,42 +596,43 @@ button.setrow{font-family:inherit}
|
|
|
534
596
|
于是看起来像按钮自带发光轮廓。
|
|
535
597
|
|
|
536
598
|
实现:
|
|
537
|
-
① **洞**:`::before`
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
599
|
+
① **洞**:`::before` 是一块 74×74 的圆(半径 37 = 按钮 34 + 3 接触缝),圆心与按钮同心,
|
|
600
|
+
**再用 `clip-path` 切掉落在栏顶边线以上的部分** —— 这样槽的形状永远跟着按钮走,
|
|
601
|
+
按钮下沉多少,露出的就是那一段圆弧,不用为每种下沉量另算半圆尺寸。
|
|
602
|
+
填槽底 `--fab-well`,于是在底栏上边缘切出一道弧。
|
|
603
|
+
② **嵌**:按钮直径 68,圆心落在栏顶边线**下方 10px**(上露 24 / 下沉 44,距栏底还剩 12)。
|
|
604
|
+
不是齐边切一半(2026-10-22 用户:「不需要突出半圆 出来一部分即可 再下沉一些 注意保留间距」)。
|
|
605
|
+
③ **接触影**:`radial-gradient` 把暗环**锚定在 r=34(按钮半径)**上,
|
|
606
|
+
从 33.5px 起最暗、到 37px 淡完,圆心同样与按钮同心。这是唯一能精确控制「暗在哪一圈」的写法 ——
|
|
544
607
|
`box-shadow: inset` 的暗边永远从元素边界向内,管不到元素外的那道槽。
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
608
|
+
几何全用相对量(`bottom:calc(100% - 44px)`、`top:-27px`),安全区变化时洞和按钮一起走。
|
|
609
|
+
|
|
610
|
+
**两页一致**:会话页不再把「+」收进栏内(2026-10-22 用户:「圆没有突出了 …两个效果要一致」)。
|
|
611
|
+
凸出的圆在列表页和对话页长得完全一样,只用 composer 的下内边距给它让出空间。 */
|
|
612
|
+
.tabfab{flex:0 0 84px}
|
|
548
613
|
/* 洞 = 半圆 + 接触影。
|
|
549
614
|
槽底是 `--fab-well`(栏面稍暗一档的**同色系**),不是 `--canvas`:
|
|
550
615
|
用页面底色在暗色下会成黑箍,在浅色下又几乎看不见 —— 两头都不对。
|
|
551
616
|
接触影必须画在槽底**之上**,且暗环两端用硬锚点,
|
|
552
617
|
避免模糊半径把亮度峰值推到按钮外面(那正是前几版读成光晕的原因)。
|
|
553
618
|
|
|
554
|
-
**露出的槽宽只有 3px
|
|
555
|
-
|
|
556
|
-
3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。
|
|
557
|
-
洞的高也取 33 而不是更深:槽只要在栏顶边界内切出缺口,深了只是浪费。 */
|
|
619
|
+
**露出的槽宽只有 3px**(槽半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
|
|
620
|
+
正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。 */
|
|
558
621
|
.tabbar::before{content:"";position:absolute;z-index:1;pointer-events:none;
|
|
559
|
-
left:50%;top
|
|
560
|
-
width:
|
|
561
|
-
|
|
622
|
+
left:50%;top:-27px;transform:translateX(-50%);
|
|
623
|
+
width:74px;height:74px;border-radius:50%;
|
|
624
|
+
clip-path:inset(27px 0 0 0); /* 圆心在栏顶下方 10px:切掉露在外面的那一截,只留栏内的弧 */
|
|
562
625
|
background:
|
|
563
|
-
radial-gradient(circle
|
|
564
|
-
var(--fab-notch)
|
|
565
|
-
var(--fab-notch)
|
|
566
|
-
color-mix(in srgb,var(--fab-notch) 35%,transparent)
|
|
567
|
-
transparent
|
|
626
|
+
radial-gradient(circle 37px at 50% 50%,
|
|
627
|
+
var(--fab-notch) 33.5px,
|
|
628
|
+
var(--fab-notch) 34.5px,
|
|
629
|
+
color-mix(in srgb,var(--fab-notch) 35%,transparent) 36px,
|
|
630
|
+
transparent 37px),
|
|
568
631
|
var(--fab-well)}
|
|
569
632
|
.tabfab__b{position:absolute;z-index:2;
|
|
570
|
-
left:50%;bottom:calc(100% -
|
|
633
|
+
left:50%;bottom:calc(100% - 44px); /* 上露 24 / 下沉 44,距栏底 12 */
|
|
571
634
|
transform:translateX(-50%);
|
|
572
|
-
width:
|
|
635
|
+
width:68px;height:68px;
|
|
573
636
|
display:flex;align-items:center;justify-content:center;
|
|
574
637
|
color:var(--on-primary-action);
|
|
575
638
|
background:linear-gradient(178deg,rgba(255,255,255,.14),rgba(255,255,255,0) 60%),var(--primary-action);
|
|
@@ -582,25 +645,14 @@ button.setrow{font-family:inherit}
|
|
|
582
645
|
.tabfab__b:active{transform:translateX(-50%) translateY(2px);
|
|
583
646
|
box-shadow:inset 0 -6px 12px -7px rgba(15,23,42,.56), inset 0 2px 3px -2px rgba(255,255,255,.26)}
|
|
584
647
|
.tabfab__b svg{display:block;stroke-width:2.4;transform:translateY(-1px)}
|
|
585
|
-
/* 会话页(2026-10-22
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
648
|
+
/* 会话页(2026-10-22 用户:「不要挤压对话框」「两个效果要一致」「圆可以更大」「再下沉一些 注意保留间距」):
|
|
649
|
+
底栏浮起后不占位,「+」在**两页都上露 24**(68 直径、圆心在栏顶下方 10),所以 composer 只需让出
|
|
650
|
+
「上露 24 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆弧上方 12px,不遮不压。
|
|
651
|
+
原来 composer 下留 60(= 上探 30 + 呼吸 30)再叠占位的 68 栏,屏幕底到输入框 162;
|
|
652
|
+
现在 56 + 24 + 12 = 92,对话区多出 70px。
|
|
653
|
+
56 必须与 .tabbar 的 min-height 对齐、24 必须与 .tabfab__b 的下沉量对齐(改一处要一起改)。
|
|
590
654
|
**只叠一次**:safe-area 由这里一次性算进来,别再给 .composer 的基础 padding 加一遍。 */
|
|
591
|
-
#conv .composer{padding-bottom:calc(12px +
|
|
592
|
-
|
|
593
|
-
/* 会话页的「+」收进槽里(不上浮):
|
|
594
|
-
输入框离栏只剩 12px,骑在边线上的 + 会顶到输入框下沿;而且这一屏已经有 composer
|
|
595
|
-
左侧的附件「+」,两个浮起的圆会让"哪个是新建"含混。
|
|
596
|
-
只改**高度关系**,不改颜色 / 水平位置:圆仍钉在正中,从"骑在边线上"变成"坐在栏里"。
|
|
597
|
-
直径 60 → 56 是跟着 74 的栏高走的(上下各留 9px),不是"缩小来躲"。 */
|
|
598
|
-
body.is-conv .tabbar::before{display:none}
|
|
599
|
-
body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-bottom) + 9px);
|
|
600
|
-
box-shadow:
|
|
601
|
-
inset 0 -6px 11px -6px rgba(15,23,42,.42),
|
|
602
|
-
inset 0 2px 2px -2px rgba(255,255,255,.28),
|
|
603
|
-
0 6px 14px -8px rgba(15,23,42,.35)}
|
|
655
|
+
#conv .composer{padding-bottom:calc(12px + 24px + 56px + env(safe-area-inset-bottom))}
|
|
604
656
|
|
|
605
657
|
/* 组件挂载点:占满剩余高度 */
|
|
606
658
|
#conv{flex:1;min-height:0;display:flex}
|
|
@@ -848,8 +900,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
848
900
|
<svg width="18" height="18"><use href="#i-back"/></svg><span>返回</span>
|
|
849
901
|
</button>
|
|
850
902
|
<h1 class="launcher__title">magic-agent · 远程控制</h1>
|
|
851
|
-
<p class="launcher__desc"
|
|
903
|
+
<p class="launcher__desc">填一个名字和桌面地址,或扫码配对。连过的桌面会保存下来,可在任务页标题处切换。</p>
|
|
852
904
|
|
|
905
|
+
<div class="field" id="nameField">
|
|
906
|
+
<label for="connName">名称(可选)</label>
|
|
907
|
+
<input id="connName" type="text" placeholder="例如 公司 Mac"
|
|
908
|
+
autocomplete="off" autocapitalize="off" spellcheck="false">
|
|
909
|
+
</div>
|
|
853
910
|
<div class="field" id="field">
|
|
854
911
|
<label for="addr">桌面地址</label>
|
|
855
912
|
<textarea id="addr" rows="2" placeholder="http://192.168.1.5:8791/?token=…"
|
|
@@ -943,12 +1000,15 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
943
1000
|
<button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
|
|
944
1001
|
</div>
|
|
945
1002
|
<div class="setcard">
|
|
946
|
-
<!--
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
<button class="setrow" id="
|
|
1003
|
+
<!-- 版本号 + 升级(2026-10-22 用户:「移动端版本跟着npm走」+「在版本号的地方
|
|
1004
|
+
可以显示版本也可检测最新版本号并触发升级」)。
|
|
1005
|
+
显示的是 **APK 自己的版本**(构建时从 magic-agent/package.json 注入,闸门页
|
|
1006
|
+
通过 ?appv= 带进来);浏览器预览没有 appv 时才回落到桌面端 health.version。
|
|
1007
|
+
点这一行就去问桌面端内置的 APK 是哪一版,新了就直接触发下载安装。 -->
|
|
1008
|
+
<button class="setrow" id="btnMineVersion" type="button">
|
|
1009
|
+
<span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span>
|
|
1010
|
+
</button>
|
|
1011
|
+
<button class="setrow" id="btnUpgrade"><span class="setrow__l">重新加载界面</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
|
|
952
1012
|
</div>
|
|
953
1013
|
<div class="setcard">
|
|
954
1014
|
<button class="setrow setrow--danger" id="btnLogout">断开连接</button>
|
|
@@ -959,10 +1019,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
959
1019
|
<!-- ══════════ 我的 → 团队模式(计划 + 7 类子任务)══════════
|
|
960
1020
|
从「我的」那一行点进来。这里配的全都是**设备级偏好**(开关本身按会话,在会话页运行设置里)。
|
|
961
1021
|
**只有一套划分**:计划 + 7 个角色(writer / researcher / reviewer / coder / designer /
|
|
962
|
-
director / builder
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1022
|
+
director / builder),不再并列「6 个类别」那一层。
|
|
1023
|
+
2026-10-22 用户:「两个触发就行了」—— 一行里**两个点击区**:
|
|
1024
|
+
左边(名字 + 规则描述)点开只改规则;右边(引擎值)点开只选引擎。
|
|
1025
|
+
不拆两张卡,也不把规则和引擎塞进同一个抽屉。 -->
|
|
966
1026
|
<div class="history" id="teamPage" hidden>
|
|
967
1027
|
<div class="history__top">
|
|
968
1028
|
<button class="topbar__icon" id="btnTeamBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
@@ -971,7 +1031,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
971
1031
|
<div class="history__list">
|
|
972
1032
|
<div class="history__group">计划与 7 类子任务</div>
|
|
973
1033
|
<div class="setcard" id="teamRouteCard"></div>
|
|
974
|
-
<div class="history__hint" style="text-align:left"
|
|
1034
|
+
<div class="history__hint" style="text-align:left">左边点规则,右边点引擎。规则留空 = 内置规则;引擎留空 = 跟随主引擎。</div>
|
|
975
1035
|
</div>
|
|
976
1036
|
</div>
|
|
977
1037
|
|
|
@@ -995,7 +1055,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
995
1055
|
|
|
996
1056
|
<!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
|
|
997
1057
|
数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
|
|
998
|
-
|
|
1058
|
+
**两级**(2026-10-22 用户定稿):
|
|
1059
|
+
一级 = 目录(空间 → 模块),**不画功能点** —— 一级只回答「这个空间有哪几块」;
|
|
1060
|
+
二级 = 点某个模块进去,看**该空间下所有模块**的需求列表(当前模块高亮)。
|
|
1061
|
+
点一行用既有的阅读器打开那篇 markdown。
|
|
999
1062
|
⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
|
|
1000
1063
|
(用户 2026-10-22 定稿)。 -->
|
|
1001
1064
|
<div class="history" id="reqs" hidden>
|
|
@@ -1006,6 +1069,16 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1006
1069
|
<div class="history__list" id="reqList"></div>
|
|
1007
1070
|
</div>
|
|
1008
1071
|
|
|
1072
|
+
<!-- 需求的二级视图:某空间下所有模块的需求列表(从一级目录点模块进来) -->
|
|
1073
|
+
<div class="history" id="reqDetail" hidden>
|
|
1074
|
+
<div class="history__top">
|
|
1075
|
+
<button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
1076
|
+
<span class="history__title" id="reqDetailTitle">需求</span>
|
|
1077
|
+
<button class="history__disconnect" id="btnReqDetailSync" type="button">整理</button>
|
|
1078
|
+
</div>
|
|
1079
|
+
<div class="history__list" id="reqDetailList"></div>
|
|
1080
|
+
</div>
|
|
1081
|
+
|
|
1009
1082
|
<!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
|
|
1010
1083
|
<div class="history" id="discover" hidden>
|
|
1011
1084
|
<div class="history__top"><span class="history__title">发现</span></div>
|
|
@@ -1124,18 +1197,20 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1124
1197
|
「定时任务」那个 tab 已删(2026-10-22 用户:「定时任务tab先删除」)——
|
|
1125
1198
|
功能本身没删:会话菜单里的「定时任务」仍然能唤起,只是不再占一个底部入口
|
|
1126
1199
|
(那一页本来也只有一句「还没有这个接口」的说明,占着导航反而误导)。
|
|
1127
|
-
中间那枚「+」不是 tab:它没有 data-tab、不参与 setActive,点了就是新建会话。
|
|
1200
|
+
中间那枚「+」不是 tab:它没有 data-tab、不参与 setActive,点了就是新建会话。
|
|
1201
|
+
**只有图标、没有文字**(2026-10-22 用户:「底栏只保留图标不需要文字了」):
|
|
1202
|
+
语义靠 aria-label 承担;图标本身已用"清单 / 文档"的轮廓分家,缩到 25px 仍可辨。 -->
|
|
1128
1203
|
<nav class="tabbar" id="tabbar" hidden>
|
|
1129
|
-
<button class="tab" type="button" data-tab="tasks"><span class="icw"><svg width="
|
|
1204
|
+
<button class="tab" type="button" data-tab="tasks" aria-label="任务"><span class="icw"><svg width="25" height="25"><use href="#i-task"/></svg></span></button>
|
|
1130
1205
|
<!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
|
|
1131
1206
|
老 core 上点进来会由 loadRequirements 的 501 分支给出明确文案,
|
|
1132
1207
|
不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
|
|
1133
|
-
<button class="tab" type="button" data-tab="reqs"><span class="icw"><svg width="
|
|
1208
|
+
<button class="tab" type="button" data-tab="reqs" aria-label="需求"><span class="icw"><svg width="25" height="25"><use href="#i-req"/></svg></span></button>
|
|
1134
1209
|
<span class="tabfab">
|
|
1135
|
-
<button class="tabfab__b" type="button" id="btnFabNew" aria-label="新建会话"><svg width="
|
|
1210
|
+
<button class="tabfab__b" type="button" id="btnFabNew" aria-label="新建会话"><svg width="30" height="30"><use href="#i-plus"/></svg></button>
|
|
1136
1211
|
</span>
|
|
1137
|
-
<button class="tab" type="button" data-tab="discover"><span class="icw"><svg width="
|
|
1138
|
-
<button class="tab" type="button" data-tab="mine"><span class="icw"><svg width="
|
|
1212
|
+
<button class="tab" type="button" data-tab="discover" aria-label="发现"><span class="icw"><svg width="25" height="25"><use href="#i-compass"/></svg></span></button>
|
|
1213
|
+
<button class="tab" type="button" data-tab="mine" aria-label="我的"><span class="icw"><svg width="25" height="25"><use href="#i-user"/></svg></span></button>
|
|
1139
1214
|
</nav>
|
|
1140
1215
|
<!-- 第三方库归口 shared/vendor(与内核共用一份):markdown 渲染栈要用 marked + DOMPurify;
|
|
1141
1216
|
highlight.js 由内核按需加载,地址在下面给(内核自己不知道页面的相对根;
|
|
@@ -1174,6 +1249,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1174
1249
|
var subtitleEl = document.getElementById('subtitle');
|
|
1175
1250
|
var fieldEl = document.getElementById('field');
|
|
1176
1251
|
var addrEl = document.getElementById('addr');
|
|
1252
|
+
var connNameEl = document.getElementById('connName');
|
|
1177
1253
|
var errEl = document.getElementById('err');
|
|
1178
1254
|
var recentEl = document.getElementById('recent');
|
|
1179
1255
|
var recentListEl = document.getElementById('recentList');
|
|
@@ -1194,6 +1270,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1194
1270
|
var tabbarEl = document.getElementById('tabbar');
|
|
1195
1271
|
var reqsEl = document.getElementById('reqs');
|
|
1196
1272
|
var reqListEl = document.getElementById('reqList');
|
|
1273
|
+
var reqDetailEl = document.getElementById('reqDetail');
|
|
1274
|
+
var reqDetailTitle = document.getElementById('reqDetailTitle');
|
|
1275
|
+
var reqDetailListEl = document.getElementById('reqDetailList');
|
|
1197
1276
|
var discoverEl = document.getElementById('discover');
|
|
1198
1277
|
var teamPageEl = document.getElementById('teamPage');
|
|
1199
1278
|
var runSettingsPageEl = document.getElementById('runSettingsPage');
|
|
@@ -1226,7 +1305,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1226
1305
|
/* 工作空间要跨重启记住(与引擎 / 模型同一口径):只放内存的话,App 重启后
|
|
1227
1306
|
「任务运行设置」里选的空间就回到默认,用户会以为没保存。 */
|
|
1228
1307
|
try { WS = localStorage.getItem('magic.ws') || ''; } catch (e) {}
|
|
1229
|
-
function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} }
|
|
1308
|
+
function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} schedulePushPrefs(); }
|
|
1230
1309
|
/* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
|
|
1231
1310
|
提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
|
|
1232
1311
|
var RUN_PREF = loadRunPref();
|
|
@@ -1333,6 +1412,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1333
1412
|
try { localStorage.setItem('magic.team.split', TEAM.split || 'standard'); } catch (e) {}
|
|
1334
1413
|
try { localStorage.setItem('magic.team.splitcode', TEAM.splitCode ? '1' : '0'); } catch (e) {}
|
|
1335
1414
|
try { localStorage.setItem('magic.team.adaptive', TEAM.adaptive === false ? '0' : '1'); } catch (e) {}
|
|
1415
|
+
schedulePushPrefs();
|
|
1336
1416
|
paintTeamPref();
|
|
1337
1417
|
}
|
|
1338
1418
|
/* 这条会话的开关:本地记过就用本地;没记过 → 跟会话自己的运行模式(core 的 `mode:"team"`)。
|
|
@@ -1361,6 +1441,21 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1361
1441
|
var LS_KEY = 'magic.remote.recent.v1'; // 旧版「最近连接」:只在首次升级时搬一次
|
|
1362
1442
|
var ACTIVE_ID = '';
|
|
1363
1443
|
var CONN_SEQ = 0; // 每连一次 +1:上一台桌面迟到的响应不许画到这一台上面
|
|
1444
|
+
/* APK 自己的版本(2026-10-22):闸门页从构建时注入的版本号带进来(?appv=)。
|
|
1445
|
+
为什么接口里还要 SERVER_VERSION:浏览器预览 / 旧版闸门页没有 appv,此时只能显示
|
|
1446
|
+
连着的桌面端版本;两个都有时以 App 版本为准(用户问的「移动端版本」)。 */
|
|
1447
|
+
var APP_VERSION = (function () {
|
|
1448
|
+
try {
|
|
1449
|
+
var v = new URL(location.href).searchParams.get('appv');
|
|
1450
|
+
if (v && /^\d+\.\d+\.\d+$/.test(v)) return v;
|
|
1451
|
+
} catch (e) {}
|
|
1452
|
+
/* 兜底:本地 ui.html(桌面端没有 /m/ 时)由 boot.js 注入。 */
|
|
1453
|
+
try {
|
|
1454
|
+
var b = String(window.MAGIC_APP_VERSION || '');
|
|
1455
|
+
if (/^\d+\.\d+\.\d+$/.test(b)) return b;
|
|
1456
|
+
} catch (e) {}
|
|
1457
|
+
return '';
|
|
1458
|
+
})();
|
|
1364
1459
|
|
|
1365
1460
|
function connHost(url) {
|
|
1366
1461
|
try { return new URL(url).host; }
|
|
@@ -1405,28 +1500,44 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1405
1500
|
var next = {
|
|
1406
1501
|
id: id, url: t.url || '', token: t.token || '',
|
|
1407
1502
|
name: (t.name != null && t.name !== '') ? t.name : (old.name || ''),
|
|
1408
|
-
host: connHost(t.url), app: old.app || '', at: Date.now()
|
|
1503
|
+
host: connHost(t.url), app: old.app || '', at: Date.now(),
|
|
1504
|
+
seed: !!old.seed // 种出来的默认环境:改名/改地址后仍然认出它
|
|
1409
1505
|
};
|
|
1410
1506
|
list.unshift(next);
|
|
1411
1507
|
saveConns(list);
|
|
1412
1508
|
return next;
|
|
1413
1509
|
}
|
|
1414
|
-
function removeConn(id) {
|
|
1510
|
+
function removeConn(id) {
|
|
1511
|
+
var list = loadConns();
|
|
1512
|
+
var hit = null;
|
|
1513
|
+
list.forEach(function (c) { if (c && c.id === id) hit = c; });
|
|
1514
|
+
/* 用户主动删掉种出来的默认环境:记一笔,下次启动别再自动加回来。 */
|
|
1515
|
+
if (hit && hit.seed) { try { localStorage.setItem('magic.remote.seedoff.v1', '1'); } catch (e) {} }
|
|
1516
|
+
saveConns(list.filter(function (c) { return c.id !== id; }));
|
|
1517
|
+
}
|
|
1415
1518
|
function clearConns() {
|
|
1416
1519
|
saveConns([]);
|
|
1417
|
-
try {
|
|
1520
|
+
try {
|
|
1521
|
+
localStorage.removeItem(LS_KEY);
|
|
1522
|
+
localStorage.setItem('magic.remote.seedoff.v1', '1'); // 清空 = 明确不要默认环境
|
|
1523
|
+
} catch (e) {}
|
|
1418
1524
|
}
|
|
1419
1525
|
|
|
1420
1526
|
/* 当前连接:先按地址 + 令牌找;App 壳注入的连接还没落盘时给一条临时记录,
|
|
1421
|
-
这样标题与菜单里仍然看得见「现在连的是谁」。
|
|
1527
|
+
这样标题与菜单里仍然看得见「现在连的是谁」。
|
|
1528
|
+
⚠️ 壳里 BASE 可能是空串(同源):这时用 location.origin 当它的地址 ——
|
|
1529
|
+
否则这条「未命名」没有 URL,既存不下来也就没法编辑(2026-10-22 用户:
|
|
1530
|
+
「现在已有的未命名都编辑不了」)。 */
|
|
1531
|
+
function connUrlNow() { return BASE || (TOKEN ? location.origin : ''); }
|
|
1422
1532
|
function activeConn() {
|
|
1533
|
+
var url = connUrlNow();
|
|
1423
1534
|
var list = loadConns(), i;
|
|
1424
1535
|
for (i = 0; i < list.length; i++) {
|
|
1425
|
-
if (list[i].url ===
|
|
1536
|
+
if (list[i].url === url && String(list[i].token || '') === String(TOKEN || '')) return list[i];
|
|
1426
1537
|
}
|
|
1427
1538
|
for (i = 0; i < list.length; i++) if (ACTIVE_ID && list[i].id === ACTIVE_ID) return list[i];
|
|
1428
|
-
if (
|
|
1429
|
-
return { id: connIdOf({ url:
|
|
1539
|
+
if (url || TOKEN) {
|
|
1540
|
+
return { id: connIdOf({ url: url, token: TOKEN }), url: url, token: TOKEN, name: '', host: connHost(url), app: '' };
|
|
1430
1541
|
}
|
|
1431
1542
|
return null;
|
|
1432
1543
|
}
|
|
@@ -1435,7 +1546,219 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1435
1546
|
if (!c) return '';
|
|
1436
1547
|
return c.name || c.app || c.host || connHost(c.url);
|
|
1437
1548
|
}
|
|
1438
|
-
function envLabel() {
|
|
1549
|
+
function envLabel() {
|
|
1550
|
+
var c = activeConn();
|
|
1551
|
+
if (!c) return histSubtitle || '';
|
|
1552
|
+
/* 壳注入的同源连接(还没有名字):标题仍显示「本机」,别把 127.0.0.1:端口 顶到标题上。 */
|
|
1553
|
+
if (!c.name && !c.app && String(c.url) === String(location.origin)) return histSubtitle || '本机';
|
|
1554
|
+
return connLabel(c) || histSubtitle || '';
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/* ── 默认环境:把「当前正在用的这条连接」落成一条可编辑的环境 ──
|
|
1558
|
+
2026-10-22 用户:「再添加一个现在远程的地址,名字就叫公司远程」。
|
|
1559
|
+
壳注入的连接(URL 里的 ?token=)如果不落盘,列表里就只有一条「未命名」的临时记录,
|
|
1560
|
+
既改不了名也留不住。这里在首屏把它存成环境:本机回环叫「公司本地」,
|
|
1561
|
+
局域网地址叫「公司远程」,都可在铅笔里改。
|
|
1562
|
+
⚠️ App 每次启动会换令牌:按 seed 标记 + 地址更新那一条,不要每次多出一条。 */
|
|
1563
|
+
var SEED_NAME = '公司远程';
|
|
1564
|
+
var SEED_LOCAL = '公司本地';
|
|
1565
|
+
function isLoopUrl(u) {
|
|
1566
|
+
try {
|
|
1567
|
+
var h = String(u || '');
|
|
1568
|
+
return /^https?:\/\/(127\.|localhost|\[::1\]|0\.0\.0\.0)/i.test(h);
|
|
1569
|
+
} catch (e) { return false; }
|
|
1570
|
+
}
|
|
1571
|
+
function seedMark(rec) {
|
|
1572
|
+
var l2 = loadConns();
|
|
1573
|
+
for (var j = 0; j < l2.length; j++) if (l2[j].id === rec.id) l2[j].seed = true;
|
|
1574
|
+
saveConns(l2);
|
|
1575
|
+
}
|
|
1576
|
+
function seedCurrentConn() {
|
|
1577
|
+
var url = connUrlNow();
|
|
1578
|
+
if (!url || !TOKEN) return;
|
|
1579
|
+
try { if (localStorage.getItem('magic.remote.seedoff.v1') === '1') return; } catch (e) {}
|
|
1580
|
+
var name = isLoopUrl(url) ? SEED_LOCAL : SEED_NAME;
|
|
1581
|
+
var list = loadConns();
|
|
1582
|
+
for (var i = 0; i < list.length; i++) {
|
|
1583
|
+
var c = list[i];
|
|
1584
|
+
if (!c) continue;
|
|
1585
|
+
if (c.url === url && String(c.token || '') === String(TOKEN || '')) {
|
|
1586
|
+
/* 早先版本把回环连接也叫「公司远程」(同地址种子没带名字):按地址纠正,
|
|
1587
|
+
但不碰用户自己改过的名字。 */
|
|
1588
|
+
if (c.seed && isLoopUrl(url) && c.name === SEED_NAME) { c.name = SEED_LOCAL; saveConns(list); }
|
|
1589
|
+
return; // 已经有这条
|
|
1590
|
+
}
|
|
1591
|
+
if (c.seed && c.url === url) {
|
|
1592
|
+
c.token = TOKEN;
|
|
1593
|
+
c.id = connIdOf({ url: url, token: TOKEN });
|
|
1594
|
+
c.at = Date.now();
|
|
1595
|
+
saveConns(list);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
seedMark(upsertConn({ url: url, token: TOKEN, name: name }));
|
|
1600
|
+
}
|
|
1601
|
+
/* 桌面 health 上报的远程入口:优先公网(cloudflared,不在同一 Wi-Fi 也能连),
|
|
1602
|
+
没有再退回局域网。用户不用手抄地址。同地址只留一条;令牌跟着当前连接走。 */
|
|
1603
|
+
function seedRemoteConns(urls) {
|
|
1604
|
+
if (!urls || !urls.length || !TOKEN) return;
|
|
1605
|
+
try { if (localStorage.getItem('magic.remote.seedoff.v1') === '1') return; } catch (e) {}
|
|
1606
|
+
var url = String(urls[0] || '').replace(/\/+$/, '');
|
|
1607
|
+
if (!url) return;
|
|
1608
|
+
if (url === String(connUrlNow()).replace(/\/+$/, '')) return;
|
|
1609
|
+
var list = loadConns();
|
|
1610
|
+
/* 隧道换域名(cloudflared 每次重启都换):把上一枚**默认命名**的种子远程记录换到新地址。
|
|
1611
|
+
用户改过名字的不动 —— 那条是他自己配的环境。 */
|
|
1612
|
+
var kept = [];
|
|
1613
|
+
for (var k = 0; k < list.length; k++) {
|
|
1614
|
+
var o = list[k];
|
|
1615
|
+
if (o && o.seed && o.name === SEED_NAME && o.url !== url) continue;
|
|
1616
|
+
kept.push(o);
|
|
1617
|
+
}
|
|
1618
|
+
if (kept.length !== list.length) saveConns(kept);
|
|
1619
|
+
list = loadConns();
|
|
1620
|
+
for (var i = 0; i < list.length; i++) {
|
|
1621
|
+
var c = list[i];
|
|
1622
|
+
if (!c) continue;
|
|
1623
|
+
if (c.url === url && String(c.token || '') === String(TOKEN)) {
|
|
1624
|
+
if (c.seed && c.name === SEED_LOCAL) { c.name = SEED_NAME; saveConns(list); }
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
if (c.seed && c.url === url) {
|
|
1628
|
+
c.token = TOKEN;
|
|
1629
|
+
c.id = connIdOf({ url: url, token: TOKEN });
|
|
1630
|
+
c.at = Date.now();
|
|
1631
|
+
saveConns(list);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
seedMark(upsertConn({ url: url, token: TOKEN, name: SEED_NAME }));
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/* ── 设备现场(按环境隔离)──
|
|
1639
|
+
2026-10-22 用户:「切换环境后 所有东西都要跟着变 包括会话 需求 我的这些 是选择环境上的东西」。
|
|
1640
|
+
会话与需求本来就来自桌面端(切完重拉);这里解决的是**留在本机的那部分状态**:
|
|
1641
|
+
主题 / 字体 / 任务模式 / 默认引擎与模型 / 团队配置 / 当前空间 / 置顶 / 通知 / 浏览器收藏。
|
|
1642
|
+
做法:切环境时把这一组键快照到「按地址分桶」的槽里,再把目标环境的槽写回来,然后整页重载
|
|
1643
|
+
—— 内存态全部重新初始化,不留上一台机器的影子。
|
|
1644
|
+
⚠️ 分桶键用 host:port 而不是 url|token:token 会随 App 重启变,按 token 分桶等于每次都丢现场。 */
|
|
1645
|
+
var ENV_SCOPE_KEYS = [
|
|
1646
|
+
'magic.ws', 'magic.taskmode', 'magic.engine', 'magic.model',
|
|
1647
|
+
'magic.theme', 'magic.font', 'magic.haptic', 'magic.notify',
|
|
1648
|
+
'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
|
|
1649
|
+
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
1650
|
+
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
|
|
1651
|
+
];
|
|
1652
|
+
var ENV_SCOPE_PREFIX = 'magic.envscope.v1.';
|
|
1653
|
+
var ACTIVE_ENV_KEY = 'magic.remote.active.v1'; // 上次停在哪个环境(url);重载后据此恢复
|
|
1654
|
+
/* 正在切环境:此时全局键已经被 applyEnvScope 换成**目标环境**的值,
|
|
1655
|
+
pagehide(reload 会触发)绝对不能再把这份值写回「旧环境」的槽 —— 那会把旧现场冲成空。 */
|
|
1656
|
+
var ENV_SWITCHING = false;
|
|
1657
|
+
function envScopeHash(s) {
|
|
1658
|
+
var h = 5381, str = String(s || '');
|
|
1659
|
+
for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
|
|
1660
|
+
return h.toString(36);
|
|
1661
|
+
}
|
|
1662
|
+
function envScopeIdOf(conn) {
|
|
1663
|
+
var host = connHost((conn && conn.url) || connUrlNow());
|
|
1664
|
+
return host ? envScopeHash(host) : '';
|
|
1665
|
+
}
|
|
1666
|
+
function envScopeId() { return envScopeIdOf(activeConn()); }
|
|
1667
|
+
/* 把当前这组键存进 id 的槽(切走之前调用)。 */
|
|
1668
|
+
function saveEnvScope(id) {
|
|
1669
|
+
if (!id) return;
|
|
1670
|
+
var o = {};
|
|
1671
|
+
for (var i = 0; i < ENV_SCOPE_KEYS.length; i++) {
|
|
1672
|
+
var v = null;
|
|
1673
|
+
try { v = localStorage.getItem(ENV_SCOPE_KEYS[i]); } catch (e) {}
|
|
1674
|
+
if (v !== null) o[ENV_SCOPE_KEYS[i]] = v;
|
|
1675
|
+
}
|
|
1676
|
+
try { localStorage.setItem(ENV_SCOPE_PREFIX + id, JSON.stringify(o)); } catch (e) {}
|
|
1677
|
+
}
|
|
1678
|
+
/* 把 id 的槽写回全局键;槽里没有的键删掉 —— 那台机器没改过的项回到默认,不继承上一台。 */
|
|
1679
|
+
function applyEnvScope(id) {
|
|
1680
|
+
var o = {};
|
|
1681
|
+
if (id) {
|
|
1682
|
+
try { o = JSON.parse(localStorage.getItem(ENV_SCOPE_PREFIX + id) || '{}') || {}; } catch (e) { o = {}; }
|
|
1683
|
+
}
|
|
1684
|
+
for (var i = 0; i < ENV_SCOPE_KEYS.length; i++) {
|
|
1685
|
+
var k = ENV_SCOPE_KEYS[i];
|
|
1686
|
+
try {
|
|
1687
|
+
if (o[k] === undefined || o[k] === null) localStorage.removeItem(k);
|
|
1688
|
+
else localStorage.setItem(k, o[k]);
|
|
1689
|
+
} catch (e) {}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/* ── 环境配置的远端存储(2026-10-22 用户:「环境的配置应该在远程上 不是浏览器缓存
|
|
1694
|
+
否则同一个环境多个客户端配置还不一致」)──
|
|
1695
|
+
这台桌面怎么干活(默认引擎/模型、团队配置、空间选择、置顶)属于**环境**,
|
|
1696
|
+
不属于某台手机:两个客户端连同一台桌面必须看到同一套配置。
|
|
1697
|
+
所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
|
|
1698
|
+
· 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
|
|
1699
|
+
· 本地任何一处改了,防抖推回远端。
|
|
1700
|
+
纯设备偏好(主题/字体/触感/通知)**不同步** —— 那是手机自己的事。 */
|
|
1701
|
+
var PREF_SYNC_KEYS = [
|
|
1702
|
+
'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
|
|
1703
|
+
'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
|
|
1704
|
+
'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
|
|
1705
|
+
];
|
|
1706
|
+
var PREFS_PUSH_TIMER = 0, PREFS_PULLED = false, PREFS_PULLING = false;
|
|
1707
|
+
function collectPrefs() {
|
|
1708
|
+
var o = {};
|
|
1709
|
+
for (var i = 0; i < PREF_SYNC_KEYS.length; i++) {
|
|
1710
|
+
var v = null;
|
|
1711
|
+
try { v = localStorage.getItem(PREF_SYNC_KEYS[i]); } catch (e) {}
|
|
1712
|
+
if (v !== null) o[PREF_SYNC_KEYS[i]] = v;
|
|
1713
|
+
}
|
|
1714
|
+
return o;
|
|
1715
|
+
}
|
|
1716
|
+
function pushPrefs() {
|
|
1717
|
+
if (!TOKEN) return;
|
|
1718
|
+
try {
|
|
1719
|
+
fetch(BASE + '/desk/prefs', {
|
|
1720
|
+
method: 'PUT',
|
|
1721
|
+
headers: { 'X-Magic-Token': TOKEN, 'Content-Type': 'application/json' },
|
|
1722
|
+
body: JSON.stringify(collectPrefs())
|
|
1723
|
+
}).catch(function () {});
|
|
1724
|
+
} catch (e) {}
|
|
1725
|
+
}
|
|
1726
|
+
function schedulePushPrefs() {
|
|
1727
|
+
if (!TOKEN) return;
|
|
1728
|
+
if (PREFS_PUSH_TIMER) clearTimeout(PREFS_PUSH_TIMER);
|
|
1729
|
+
PREFS_PUSH_TIMER = setTimeout(function () { PREFS_PUSH_TIMER = 0; pushPrefs(); }, 700);
|
|
1730
|
+
}
|
|
1731
|
+
/* 拉一次远端配置。远端是空对象 = 这台桌面还没存过 → 把本地这份推上去(迁移),
|
|
1732
|
+
不反过来把本地清空 —— 老用户第一次升级时不能丢配置。 */
|
|
1733
|
+
function pullPrefs() {
|
|
1734
|
+
if (!TOKEN || PREFS_PULLING) return;
|
|
1735
|
+
PREFS_PULLING = true;
|
|
1736
|
+
var seq = CONN_SEQ;
|
|
1737
|
+
fetch(BASE + '/desk/prefs', { headers: { 'X-Magic-Token': TOKEN } })
|
|
1738
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1739
|
+
.then(function (v) {
|
|
1740
|
+
PREFS_PULLING = false;
|
|
1741
|
+
if (seq !== CONN_SEQ) return;
|
|
1742
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) { PREFS_PULLED = true; return; }
|
|
1743
|
+
if (!Object.keys(v).length) { PREFS_PULLED = true; pushPrefs(); return; }
|
|
1744
|
+
var changed = false;
|
|
1745
|
+
for (var i = 0; i < PREF_SYNC_KEYS.length; i++) {
|
|
1746
|
+
var k = PREF_SYNC_KEYS[i];
|
|
1747
|
+
var cur = null;
|
|
1748
|
+
try { cur = localStorage.getItem(k); } catch (e) {}
|
|
1749
|
+
var want = Object.prototype.hasOwnProperty.call(v, k) ? String(v[k]) : null;
|
|
1750
|
+
if (want === cur) continue;
|
|
1751
|
+
try {
|
|
1752
|
+
if (want === null) localStorage.removeItem(k);
|
|
1753
|
+
else localStorage.setItem(k, want);
|
|
1754
|
+
} catch (e) {}
|
|
1755
|
+
changed = true;
|
|
1756
|
+
}
|
|
1757
|
+
PREFS_PULLED = true;
|
|
1758
|
+
if (changed) { try { location.reload(); } catch (e) {} }
|
|
1759
|
+
})
|
|
1760
|
+
.catch(function () { PREFS_PULLING = false; PREFS_PULLED = true; });
|
|
1761
|
+
}
|
|
1439
1762
|
|
|
1440
1763
|
/* 桌面 health 回来的应用名(app)补进当前连接:它比 host 更像「这台设备是谁」。 */
|
|
1441
1764
|
function noteActiveMeta(meta) {
|
|
@@ -1491,23 +1814,24 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1491
1814
|
}
|
|
1492
1815
|
/* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
|
|
1493
1816
|
排队任务按令牌分别落库(见 QUEUE_KEY),旧环境那批留在原地,切回来再接着跑。 */
|
|
1817
|
+
/* 换环境 = 换一台桌面:会话 / 需求 / 我的设置全都属于那台机器。
|
|
1818
|
+
做法分两步:① 把当前这台的本机状态快照进它的槽,再把目标环境的槽写回来;
|
|
1819
|
+
② 记住「停在哪个环境」并整页重载 —— 重载后内存态从零初始化,按目标环境的
|
|
1820
|
+
连接与设置重建界面(会话、需求都是重载后从目标桌面现拉的)。
|
|
1821
|
+
⚠️ 不在这里手写「清哪些变量」:那种清单永远会漏(漏一个就带着上一台的数据)。 */
|
|
1494
1822
|
function switchEnv(c) {
|
|
1495
1823
|
if (!c) return;
|
|
1496
|
-
if (c.url ===
|
|
1824
|
+
if (c.url === connUrlNow() && String(c.token || '') === String(TOKEN || '')) {
|
|
1497
1825
|
toast('已在「' + (connLabel(c) || '当前环境') + '」');
|
|
1498
1826
|
return;
|
|
1499
1827
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
WS = '';
|
|
1508
|
-
saveWs();
|
|
1509
|
-
connect({ url: c.url, token: c.token, name: c.name, host: c.host || connHost(c.url) });
|
|
1510
|
-
toast('已切换到「' + (connLabel(c) || connHost(c.url)) + '」');
|
|
1828
|
+
ENV_SWITCHING = true;
|
|
1829
|
+
saveEnvScope(envScopeId()); // 旧环境的现场
|
|
1830
|
+
applyEnvScope(envScopeIdOf(c)); // 新环境的现场
|
|
1831
|
+
var rec = upsertConn(c); // 保证这条在册(临时记录也能落盘)
|
|
1832
|
+
try { localStorage.setItem(ACTIVE_ENV_KEY, rec.url || c.url || ''); } catch (e) {}
|
|
1833
|
+
toast('正在切换到「' + (connLabel(c) || connHost(c.url)) + '」');
|
|
1834
|
+
try { location.reload(); } catch (e) { location.href = location.href; }
|
|
1511
1835
|
}
|
|
1512
1836
|
function openEnvMenu() {
|
|
1513
1837
|
if (envMenuEl) { closeEnvMenu(); return; }
|
|
@@ -1524,7 +1848,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1524
1848
|
var active = !!(cur && c.id === cur.id);
|
|
1525
1849
|
var b = document.createElement('button');
|
|
1526
1850
|
b.type = 'button';
|
|
1527
|
-
b.className = 'envmenu__item';
|
|
1851
|
+
b.className = 'envmenu__item' + (active ? ' is-active' : '');
|
|
1528
1852
|
var ico = document.createElement('span');
|
|
1529
1853
|
ico.className = 'envmenu__ico';
|
|
1530
1854
|
ico.appendChild(svgUse('#i-laptop', 20));
|
|
@@ -1542,23 +1866,29 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1542
1866
|
var csub = (cname && cname !== chost) ? chost : '';
|
|
1543
1867
|
col.appendChild(elSpan('envmenu__s', (csub ? csub + ' · ' : '') + (active ? '当前连接' : '点击切换')));
|
|
1544
1868
|
b.appendChild(col);
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1869
|
+
/* 每行一枚「修改」:改名 / 改链接不用绕去「我的」。行本身仍是切换。 */
|
|
1870
|
+
var ed = document.createElement('span');
|
|
1871
|
+
ed.className = 'envmenu__edit';
|
|
1872
|
+
ed.setAttribute('role', 'button');
|
|
1873
|
+
ed.setAttribute('aria-label', '编辑环境');
|
|
1874
|
+
ed.appendChild(svgUse('#i-edit', 18));
|
|
1875
|
+
ed.onclick = function (e) { e.stopPropagation(); closeEnvMenu(); openEnvEditor(c); };
|
|
1876
|
+
b.appendChild(ed);
|
|
1551
1877
|
b.onclick = function () { closeEnvMenu(); switchEnv(c); };
|
|
1552
1878
|
envMenuEl.appendChild(b);
|
|
1553
1879
|
});
|
|
1554
1880
|
envMenuEl.appendChild(elSpan('envmenu__sep', ''));
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1881
|
+
/* 只留一个「添加环境」:扫码在启动页,改名改链接走每行的铅笔图标。 */
|
|
1882
|
+
function menuAct(icon, label, fn) {
|
|
1883
|
+
var b = document.createElement('button');
|
|
1884
|
+
b.type = 'button';
|
|
1885
|
+
b.className = 'envmenu__add';
|
|
1886
|
+
b.appendChild(svgUse(icon, 18));
|
|
1887
|
+
b.appendChild(elSpan('', label));
|
|
1888
|
+
b.onclick = function () { closeEnvMenu(); fn(); };
|
|
1889
|
+
envMenuEl.appendChild(b);
|
|
1890
|
+
}
|
|
1891
|
+
menuAct('#i-plus', '添加环境', function () { openEnvEditor(null); });
|
|
1562
1892
|
document.body.appendChild(envScrimEl);
|
|
1563
1893
|
document.body.appendChild(envMenuEl);
|
|
1564
1894
|
var btn = document.getElementById('btnEnvSwitch');
|
|
@@ -1569,6 +1899,124 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1569
1899
|
btn.classList.add('is-open');
|
|
1570
1900
|
}
|
|
1571
1901
|
|
|
1902
|
+
/* ── 环境编辑(任务页标题菜单里的铅笔 / 添加环境):名字 / 链接 / 令牌 ──
|
|
1903
|
+
2026-10-22 用户:「我的中的环境配置就不需要了」—— 管理入口只留在标题菜单里,
|
|
1904
|
+
不再单独做一页。 */
|
|
1905
|
+
/* conn 为空 = 新建;否则编辑(名字、链接、令牌都能改,也能删)。 */
|
|
1906
|
+
function openEnvEditor(conn) {
|
|
1907
|
+
var isNew = !conn;
|
|
1908
|
+
openHostSheet(isNew ? '添加环境' : '编辑环境', function (body) {
|
|
1909
|
+
var nameEl = hEl('input', 'envfield');
|
|
1910
|
+
nameEl.type = 'text';
|
|
1911
|
+
nameEl.placeholder = '例如 公司 Mac';
|
|
1912
|
+
nameEl.value = (conn && conn.name) || '';
|
|
1913
|
+
var addrEl = hEl('input', 'envfield');
|
|
1914
|
+
addrEl.type = 'text';
|
|
1915
|
+
/* 「链接」= 桌面配对页给的那条地址,可整条粘进来(含 ?token=…)。 */
|
|
1916
|
+
addrEl.placeholder = 'http://192.168.1.5:8791/?token=…';
|
|
1917
|
+
addrEl.setAttribute('autocapitalize', 'off');
|
|
1918
|
+
addrEl.setAttribute('autocomplete', 'off');
|
|
1919
|
+
addrEl.setAttribute('spellcheck', 'false');
|
|
1920
|
+
addrEl.value = (conn && conn.url) ? String(conn.url) : '';
|
|
1921
|
+
var tokEl = hEl('input', 'envfield');
|
|
1922
|
+
tokEl.type = 'text';
|
|
1923
|
+
tokEl.placeholder = '可选,配对令牌';
|
|
1924
|
+
tokEl.setAttribute('autocapitalize', 'off');
|
|
1925
|
+
tokEl.setAttribute('autocomplete', 'off');
|
|
1926
|
+
tokEl.setAttribute('spellcheck', 'false');
|
|
1927
|
+
tokEl.value = (conn && conn.token) || '';
|
|
1928
|
+
body.appendChild(hEl('label', 'envlabel', '名称'));
|
|
1929
|
+
body.appendChild(nameEl);
|
|
1930
|
+
body.appendChild(hEl('label', 'envlabel', '链接'));
|
|
1931
|
+
body.appendChild(addrEl);
|
|
1932
|
+
/* 扫码填入:启动页不再出现在主流程里之后,这里是唯一的扫码入口。 */
|
|
1933
|
+
var scanB = hEl('button', 'envscan', '扫码填入');
|
|
1934
|
+
scanB.type = 'button';
|
|
1935
|
+
scanB.onclick = function () {
|
|
1936
|
+
startScan(function (raw) {
|
|
1937
|
+
addrEl.value = raw;
|
|
1938
|
+
var t = parseTarget(raw);
|
|
1939
|
+
if (t && t.token) tokEl.value = t.token;
|
|
1940
|
+
}, function (m) { err.textContent = m; err.hidden = false; });
|
|
1941
|
+
};
|
|
1942
|
+
body.appendChild(scanB);
|
|
1943
|
+
body.appendChild(hEl('label', 'envlabel', '令牌'));
|
|
1944
|
+
body.appendChild(tokEl);
|
|
1945
|
+
var err = hEl('div', 'field__err');
|
|
1946
|
+
err.hidden = true;
|
|
1947
|
+
body.appendChild(err);
|
|
1948
|
+
body.appendChild(hEl('div', 'envhint',
|
|
1949
|
+
'链接从桌面配对页复制,可以带 ?token=…;令牌栏单独填时优先。'));
|
|
1950
|
+
var acts = hEl('div', 'envacts');
|
|
1951
|
+
var saveB = hEl('button', 'btn btn--filled', '保存');
|
|
1952
|
+
var goB = hEl('button', 'btn btn--outlined', '保存并连接');
|
|
1953
|
+
saveB.type = 'button'; goB.type = 'button';
|
|
1954
|
+
acts.appendChild(saveB); acts.appendChild(goB);
|
|
1955
|
+
body.appendChild(acts);
|
|
1956
|
+
var delB = null;
|
|
1957
|
+
if (!isNew) {
|
|
1958
|
+
delB = hEl('button', 'envdel', '删除这个环境');
|
|
1959
|
+
delB.type = 'button';
|
|
1960
|
+
body.appendChild(delB);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function collect() {
|
|
1964
|
+
err.hidden = true;
|
|
1965
|
+
var raw = String(addrEl.value || '').trim();
|
|
1966
|
+
var t = parseTarget(raw);
|
|
1967
|
+
if (!t) { err.textContent = '请填链接,例如 http://192.168.1.5:8791/?token=…'; err.hidden = false; return null; }
|
|
1968
|
+
var tok = String(tokEl.value || '').trim();
|
|
1969
|
+
var hadParam = /[?&]token=/.test(raw);
|
|
1970
|
+
if (tok) t.token = tok;
|
|
1971
|
+
else if (!hadParam) {
|
|
1972
|
+
/* 令牌栏没动过就沿用旧的;用户主动清空 = 就是要去掉令牌,不能又塞回去。 */
|
|
1973
|
+
t.token = (conn && tokEl.value === (conn.token || '')) ? (conn.token || '') : '';
|
|
1974
|
+
}
|
|
1975
|
+
t.name = String(nameEl.value || '').trim();
|
|
1976
|
+
return t;
|
|
1977
|
+
}
|
|
1978
|
+
function persist(t) {
|
|
1979
|
+
var wasActive = !!(conn && conn.url === connUrlNow() && String(conn.token || '') === String(TOKEN || ''));
|
|
1980
|
+
var next = upsertConn(t);
|
|
1981
|
+
/* 改了地址/令牌 = 换了一条记录:旧的那条要删掉,别留一条连不上的僵尸。 */
|
|
1982
|
+
if (conn && conn.id !== next.id) removeConn(conn.id);
|
|
1983
|
+
/* 名称允许清空,也允许改:upsertConn 会沿用旧名,这里按输入框的最终值覆盖。 */
|
|
1984
|
+
var list = loadConns();
|
|
1985
|
+
for (var i = 0; i < list.length; i++) {
|
|
1986
|
+
if (list[i].id !== next.id) continue;
|
|
1987
|
+
list[i].name = t.name;
|
|
1988
|
+
if (!list[i].app && conn && conn.app) list[i].app = conn.app;
|
|
1989
|
+
}
|
|
1990
|
+
saveConns(list);
|
|
1991
|
+
closeHostSheet();
|
|
1992
|
+
if (wasActive && (next.url !== BASE || String(next.token || '') !== String(TOKEN || ''))) {
|
|
1993
|
+
switchEnv(next); // 改的正是当前这台:按新地址/令牌重连
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (!historyEl.hidden) historyTitle.textContent = envLabel() || '任务';
|
|
1997
|
+
toast(isNew ? ('已添加「' + (next.name || next.host) + '」') : '已保存');
|
|
1998
|
+
}
|
|
1999
|
+
saveB.onclick = function () { var t = collect(); if (t) persist(t); };
|
|
2000
|
+
goB.onclick = function () {
|
|
2001
|
+
var t = collect();
|
|
2002
|
+
if (!t) return;
|
|
2003
|
+
persist(t);
|
|
2004
|
+
/* persist 内部遇到「改的是当前连接」已经自己重连了;这里只处理新增/改别的环境。 */
|
|
2005
|
+
var c = findConn(connIdOf(t)) || findConn(connIdOf({ url: t.url, token: t.token }));
|
|
2006
|
+
if (c && !(c.url === connUrlNow() && String(c.token || '') === String(TOKEN || ''))) switchEnv(c);
|
|
2007
|
+
};
|
|
2008
|
+
if (delB) {
|
|
2009
|
+
delB.onclick = function () {
|
|
2010
|
+
if (!window.confirm('删除环境「' + (connLabel(conn) || conn.host || '') + '」?')) return;
|
|
2011
|
+
removeConn(conn.id);
|
|
2012
|
+
closeHostSheet();
|
|
2013
|
+
toast('已删除');
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
setTimeout(function () { try { nameEl.focus(); } catch (e) {} }, 60);
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1572
2020
|
/* ══════════ 解析 + 校验连接地址 ══════════
|
|
1573
2021
|
与 ZCode RemoteControlUrlPolicy 同职责:只放行 http(s),解析出 base 与令牌。
|
|
1574
2022
|
ZCode 生产包写死 zcode.z.ai;magic-agent 是用户自托管(LAN IP 各不相同),
|
|
@@ -1596,8 +2044,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1596
2044
|
}
|
|
1597
2045
|
|
|
1598
2046
|
/* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
|
|
1599
|
-
|
|
1600
|
-
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; }
|
|
2047
|
+
function hideAll() { launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; reqsEl.hidden = true; reqDetailEl.hidden = true; discoverEl.hidden = true; teamPageEl.hidden = true; runSettingsPageEl.hidden = true; pluginsEl.hidden = true; pluginDetailEl.hidden = true; sessionEl.hidden = true; }
|
|
1601
2048
|
function setActive(name) {
|
|
1602
2049
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
1603
2050
|
b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
|
|
@@ -1613,14 +2060,24 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1613
2060
|
hideAll(); historyEl.hidden = false; tabbarEl.hidden = false; setActive('tasks');
|
|
1614
2061
|
if (address != null) histSubtitle = address;
|
|
1615
2062
|
historyTitle.textContent = envLabel() || '任务';
|
|
2063
|
+
/* 一条环境都没有(首次安装 / 刚断开):直接进任务页的空态。
|
|
2064
|
+
2026-10-22 用户:「这个界面不需要了 直接切换环境就行」—— 不再用独立的启动页挡在前面。 */
|
|
2065
|
+
if (!TOKEN && !BASE) {
|
|
2066
|
+
historyTitle.textContent = '未连接';
|
|
2067
|
+
historyList.innerHTML = '<div class="history__hint" style="text-align:left">还没有连接桌面端。'
|
|
2068
|
+
+ '点上面的标题 →「添加环境」,把桌面给的地址粘进来(也可以在里面扫码)。</div>';
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
1616
2071
|
loadSessions();
|
|
1617
2072
|
}
|
|
1618
|
-
function showSession(sub) { hideAll();
|
|
2073
|
+
function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
|
|
1619
2074
|
/* 需求页:进页面即拉一次清单(GET /desk/requirements,只读、不调模型)。
|
|
1620
2075
|
「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
|
|
1621
2076
|
两者代价差三个数量级,绝不能混成一个动作。 */
|
|
1622
2077
|
function showReqs() {
|
|
1623
2078
|
hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
|
|
2079
|
+
/* 从二级退回来时不要还卡在详情态:回到底栏「需求」这一页 = 回到一级目录。 */
|
|
2080
|
+
REQ_DETAIL = null;
|
|
1624
2081
|
loadRequirements(false);
|
|
1625
2082
|
}
|
|
1626
2083
|
function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
|
|
@@ -1637,39 +2094,114 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
1637
2094
|
再补一次请求 —— 用户可能一连上就直接点「我的」,那时列表那次还没回来。 */
|
|
1638
2095
|
paintMineVersion();
|
|
1639
2096
|
refreshMineVersion();
|
|
2097
|
+
/* 顺带问一次「桌面端的包里带的是哪一版 APK」:有更新就把这一格显示成「升级 vX」。 */
|
|
2098
|
+
probeAppUpdate();
|
|
1640
2099
|
}
|
|
1641
2100
|
|
|
1642
|
-
/* ══════════
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
2101
|
+
/* ══════════ 版本号 + 升级(「我的」)══════════
|
|
2102
|
+
显示:优先 APK 自己的版本(构建时从 magic-agent/package.json 注入,闸门页用 ?appv=
|
|
2103
|
+
带进来);浏览器预览没有 appv 时才回落到桌面端 `GET /desk/health` 的 `version`。
|
|
2104
|
+
升级:点这一行 → `GET /desk/app` 问桌面端「你包里带的 APK 是哪一版」。桌面端把
|
|
2105
|
+
随 npm 包发布的 release APK 放在 npm/apk/ 下,由 magic-client 直接递过来;新版本
|
|
2106
|
+
就触发下载(APK 壳里走原生 MagicUpdate.open 交给系统浏览器/安装器)。
|
|
2107
|
+
老插件没有 /desk/app(404)→ 提示检查失败,不把「—」当成最新版本。 */
|
|
1649
2108
|
var SERVER_VERSION = '';
|
|
2109
|
+
var APK_LATEST = ''; // 桌面端 npm 包里带的 APK 版本(/desk/app);空 = 没包或还没查
|
|
2110
|
+
function shownVersion() { return APP_VERSION || SERVER_VERSION; }
|
|
1650
2111
|
function paintMineVersion() {
|
|
1651
2112
|
if (!mineVersion) return;
|
|
1652
|
-
|
|
2113
|
+
var v = shownVersion();
|
|
2114
|
+
var txt = v ? ('v' + v) : '—';
|
|
2115
|
+
/* 有新版本就直接在这一格写「升级」,别让用户去猜这一行能不能点。 */
|
|
2116
|
+
if (v && APK_LATEST && cmpVersion(APK_LATEST, v) > 0) {
|
|
2117
|
+
mineVersion.textContent = txt + ' · 升级 v' + APK_LATEST;
|
|
2118
|
+
mineVersion.classList.add('is-upgradable');
|
|
2119
|
+
} else {
|
|
2120
|
+
mineVersion.textContent = txt;
|
|
2121
|
+
mineVersion.classList.remove('is-upgradable');
|
|
2122
|
+
}
|
|
2123
|
+
/* 两个版本不一致时把桌面端版本放进 title:排查「App 是新的、桌面还是旧的」用得上。 */
|
|
2124
|
+
mineVersion.title = (APP_VERSION && SERVER_VERSION && APP_VERSION !== SERVER_VERSION)
|
|
2125
|
+
? ('App v' + APP_VERSION + ' · 桌面 v' + SERVER_VERSION) : '';
|
|
2126
|
+
}
|
|
2127
|
+
/* 进入「我的」时静默问一次桌面端带的是哪一版 APK:有更新就把版本格变成「升级」。
|
|
2128
|
+
失败(老插件没有 /desk/app)就不动,不打扰。 */
|
|
2129
|
+
function probeAppUpdate() {
|
|
2130
|
+
if (!TOKEN) return;
|
|
2131
|
+
fetch(BASE + '/desk/app', { headers: { 'X-Magic-Token': TOKEN } })
|
|
2132
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
2133
|
+
.then(function (a) { APK_LATEST = (a && a.version) || ''; paintMineVersion(); })
|
|
2134
|
+
.catch(function () {});
|
|
1653
2135
|
}
|
|
1654
2136
|
function refreshMineVersion() {
|
|
1655
2137
|
if (!TOKEN) { paintMineVersion(); return; }
|
|
1656
2138
|
var hdr = { 'X-Magic-Token': TOKEN };
|
|
1657
|
-
var seq = CONN_SEQ; //
|
|
1658
|
-
|
|
2139
|
+
var seq = CONN_SEQ; // 切环境后旧桌面的响应不许写到新连接上
|
|
2140
|
+
/* ⚠️ /desk/health.version 是 magic-client **启动时**缓存的 core 版本:客户端比 core 旧
|
|
2141
|
+
(升级了 CLI 但没重启服务)时会一直报旧值(实测 v1.0.111 / 实际 2.1.3)。
|
|
2142
|
+
/desk/contract 每次都重新问 core,才是「此刻真正的 magic-agent 版本」。
|
|
2143
|
+
两个并行拿:contract 权威,拿不到(老插件)才退回 health;health 还带 app 身份。 */
|
|
2144
|
+
var pContract = fetch(BASE + '/desk/contract', { headers: hdr })
|
|
1659
2145
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1660
|
-
.
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
2146
|
+
.catch(function () { return null; });
|
|
2147
|
+
var pHealth = fetch(BASE + '/desk/health', { headers: hdr })
|
|
2148
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
2149
|
+
.catch(function () { return null; });
|
|
2150
|
+
Promise.all([pContract, pHealth]).then(function (a) {
|
|
2151
|
+
if (seq !== CONN_SEQ) return;
|
|
2152
|
+
var c = a[0], h = a[1];
|
|
2153
|
+
if (h && h.app) noteActiveMeta({ app: h.app });
|
|
2154
|
+
if (c && typeof c.version === 'string' && c.version) SERVER_VERSION = c.version;
|
|
2155
|
+
else if (h && typeof h.version === 'string' && h.version) SERVER_VERSION = h.version;
|
|
2156
|
+
paintMineVersion();
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
function cmpVersion(a, b) {
|
|
2160
|
+
var pa = String(a || '0').split('.'), pb = String(b || '0').split('.');
|
|
2161
|
+
for (var i = 0; i < 3; i++) {
|
|
2162
|
+
var x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
|
|
2163
|
+
if (x !== y) return x - y;
|
|
2164
|
+
}
|
|
2165
|
+
return 0;
|
|
2166
|
+
}
|
|
2167
|
+
/* 下载走系统能力:APK 壳里 MainActivity 挂了 MagicUpdate.open(Intent.ACTION_VIEW),
|
|
2168
|
+
浏览器预览退化为直接导航到下载地址(浏览器会下载,不会把页面留在 APK 上)。 */
|
|
2169
|
+
function openApkDownload(info) {
|
|
2170
|
+
var rel = (info && info.url) || '/desk/app.apk';
|
|
2171
|
+
var url;
|
|
2172
|
+
if (/^https?:/i.test(rel)) {
|
|
2173
|
+
url = rel;
|
|
2174
|
+
} else if (BASE) {
|
|
2175
|
+
url = BASE + rel;
|
|
2176
|
+
} else {
|
|
2177
|
+
/* 远程界面与 /desk/ 同源,BASE 是空串;交给原生 Intent 的必须是绝对地址。 */
|
|
2178
|
+
try { url = new URL(rel, location.href).toString(); } catch (e) { url = rel; }
|
|
2179
|
+
}
|
|
2180
|
+
url += (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
|
|
2181
|
+
try {
|
|
2182
|
+
if (window.MagicUpdate && typeof window.MagicUpdate.open === 'function') {
|
|
2183
|
+
window.MagicUpdate.open(url);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
} catch (e) {}
|
|
2187
|
+
location.href = url;
|
|
2188
|
+
}
|
|
2189
|
+
function checkAppUpdate() {
|
|
2190
|
+
var cur = shownVersion();
|
|
2191
|
+
if (!TOKEN) { toast('请先连接桌面端'); return; }
|
|
2192
|
+
if (!cur) { toast('版本还没拿到,等列表加载完再试'); return; }
|
|
2193
|
+
mineVersion.textContent = '检测中…';
|
|
2194
|
+
fetch(BASE + '/desk/app', { headers: { 'X-Magic-Token': TOKEN } })
|
|
2195
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
2196
|
+
.then(function (a) {
|
|
2197
|
+
paintMineVersion();
|
|
2198
|
+
if (!a || !a.version) { toast('桌面端没有内置安装包'); return; }
|
|
2199
|
+
if (cmpVersion(a.version, cur) <= 0) { toast('已是最新版本 v' + cur); return; }
|
|
2200
|
+
if (!confirm('发现新版本 v' + a.version + '(当前 v' + cur + '),现在下载升级?')) return;
|
|
2201
|
+
openApkDownload(a);
|
|
2202
|
+
toast('正在下载 v' + a.version + ',完成后点安装');
|
|
1670
2203
|
})
|
|
1671
|
-
.
|
|
1672
|
-
.catch(function () { paintMineVersion(); }); // 网络抖动:保留旧值,不把「—」又闪回来
|
|
2204
|
+
.catch(function () { paintMineVersion(); toast('检查更新失败,请确认桌面端已升级'); });
|
|
1673
2205
|
}
|
|
1674
2206
|
/* 「我的」→ 团队模式:整块配置进子页面(与插件列表同一套子页面范式)。 */
|
|
1675
2207
|
function showTeamPage() {
|
|
@@ -2231,6 +2763,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
2231
2763
|
CONN_SEQ++; // 上一台桌面还在飞的请求:回来时直接丢弃(见 loadSessions)
|
|
2232
2764
|
var c = upsertConn(target);
|
|
2233
2765
|
ACTIVE_ID = c.id;
|
|
2766
|
+
PREFS_PULLED = false; // 换了桌面:环境配置要重新从新桌面拉一次(见 pullPrefs)
|
|
2767
|
+
/* 记住「停在哪台」:下次打开(或切环境后重载)直接回到这里。 */
|
|
2768
|
+
try { localStorage.setItem(ACTIVE_ENV_KEY, c.url || target.url || ''); } catch (e) {}
|
|
2234
2769
|
showError('');
|
|
2235
2770
|
showHistory(c.host || '');
|
|
2236
2771
|
startPoll();
|
|
@@ -2242,8 +2777,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
2242
2777
|
CONN_SEQ++;
|
|
2243
2778
|
if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
|
|
2244
2779
|
convEl.innerHTML = '';
|
|
2780
|
+
saveEnvScope(envScopeId()); // 断开前把这台的本机状态留在它自己的槽里
|
|
2245
2781
|
BASE = ''; TOKEN = '';
|
|
2246
2782
|
ACTIVE_ID = '';
|
|
2783
|
+
try { localStorage.removeItem(ACTIVE_ENV_KEY); } catch (e) {} // 主动断开 = 下次回启动页
|
|
2247
2784
|
SERVER_VERSION = ''; // 版本号属于「那一条连接」:断开就清掉,避免下次连到别的机器还显示旧值
|
|
2248
2785
|
/* 排队任务**不跟着断连消失**(它们已经落库):只清内存里的等待态,下次连上由
|
|
2249
2786
|
restoreQueue 从磁盘接回来。串行门的 FIFO 也一并重置 —— 里面的 tail 是挂在一条
|
|
@@ -2252,7 +2789,8 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
2252
2789
|
ASK_WAITING = [];
|
|
2253
2790
|
SERIAL = {};
|
|
2254
2791
|
QUEUE_RESTORED = false;
|
|
2255
|
-
|
|
2792
|
+
/* 断开后回任务页的空态(不再回启动页):添加/切换环境都在标题菜单里。 */
|
|
2793
|
+
showHistory(null);
|
|
2256
2794
|
}
|
|
2257
2795
|
|
|
2258
2796
|
/* ══════════ 历史会话列表(GET /desk/sessions,字段同 core 的 --sessions)══════════ */
|
|
@@ -2322,16 +2860,26 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
2322
2860
|
TITLES = a[1] || {};
|
|
2323
2861
|
DW = (a[2] && a[2].defaultWorkspace) || '';
|
|
2324
2862
|
DW_READY = true;
|
|
2863
|
+
/* 环境配置以桌面端为准:连上后拉一次(见 pullPrefs)。只拉一次,
|
|
2864
|
+
后面本地的改动由 schedulePushPrefs 推回去。 */
|
|
2865
|
+
if (!PREFS_PULLED) pullPrefs();
|
|
2325
2866
|
SPACES_ROOT = (a[2] && a[2].workspacesRoot) || '';
|
|
2326
|
-
/* core
|
|
2327
|
-
|
|
2328
|
-
if (a[2] && a[2].version) SERVER_VERSION = a[2].version;
|
|
2867
|
+
/* core 版本不要从这里取:/desk/health.version 是插件启动时的缓存,可能是旧值
|
|
2868
|
+
(见 refreshMineVersion 的注释)。版本行打开时走 /desk/contract 拿实时值。 */
|
|
2329
2869
|
if (!mineEl.hidden) paintMineVersion();
|
|
2330
2870
|
/* core 能力位(feature.*):团队开关要不要显示就看它。连上桌面后才有,
|
|
2331
2871
|
所以「我的」那张卡片与运行设置里的那一行都要在这里刷一次。 */
|
|
2332
2872
|
CORE_FEATURES = ((a[2] && a[2].features) || []);
|
|
2333
2873
|
/* 连接的身份(app):连着哪台设备就记到哪条连接上,环境菜单里当类型标签用。 */
|
|
2334
2874
|
if (a[2] && a[2].app) noteActiveMeta({ app: a[2].app });
|
|
2875
|
+
/* 桌面绑了公网隧道时上报 publicUrl(cloudflared,不在同一 Wi-Fi 也能连),
|
|
2876
|
+
没有则退回局域网入口:自动补一条「公司远程」环境,用户不用手抄地址。 */
|
|
2877
|
+
if (a[2]) {
|
|
2878
|
+
var remotes = [];
|
|
2879
|
+
if (a[2].publicUrl) remotes.push(a[2].publicUrl);
|
|
2880
|
+
if (a[2].lanUrls && a[2].lanUrls.length) remotes = remotes.concat(a[2].lanUrls);
|
|
2881
|
+
if (remotes.length) seedRemoteConns(remotes);
|
|
2882
|
+
}
|
|
2335
2883
|
/* 团队角色清单(2026-10-22):core 上报什么就画什么,客户端不硬编码角色名。
|
|
2336
2884
|
老 core 没有 teamRoles → 空数组 → paintTeamPref 用内置表兜底。 */
|
|
2337
2885
|
TEAM_ROLES = ((a[2] && a[2].teamRoles) || []).filter(function (r) {
|
|
@@ -2369,7 +2917,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
2369
2917
|
var TASK_QUERY = '';
|
|
2370
2918
|
var PINNED = [];
|
|
2371
2919
|
try { PINNED = JSON.parse(localStorage.getItem('magic.pinned') || '[]') || []; } catch (e) { PINNED = []; }
|
|
2372
|
-
function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} }
|
|
2920
|
+
function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} schedulePushPrefs(); }
|
|
2373
2921
|
var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
|
|
2374
2922
|
var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
|
|
2375
2923
|
var DW_READY = false; // DW 是否已从 health 落定(空间键归一依赖它,见 restoreQueue)
|
|
@@ -2377,7 +2925,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
2377
2925
|
var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
|
|
2378
2926
|
var CUSTOM_SPACES = [];
|
|
2379
2927
|
try { CUSTOM_SPACES = JSON.parse(localStorage.getItem('magic.spaces') || '[]') || []; } catch (e) { CUSTOM_SPACES = []; }
|
|
2380
|
-
function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} }
|
|
2928
|
+
function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} schedulePushPrefs(); }
|
|
2381
2929
|
/* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
|
|
2382
2930
|
⚠️ 会话的 workspace 为空时**不要**拿 DW 顶上(原写法 `s.workspace || DW`):
|
|
2383
2931
|
那样「默认(不指定)」与「默认目录名」会变成两行同名选项,一个落「任务」段、
|
|
@@ -2391,6 +2939,44 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
2391
2939
|
(LAST_SESSIONS || []).forEach(function (s) { add(s && s.workspace); });
|
|
2392
2940
|
return out;
|
|
2393
2941
|
}
|
|
2942
|
+
/* 会话里哪些是**顶层会话**(不是别人的子会话)。
|
|
2943
|
+
团队模式会把子代理的会话挂到父会话下面去,它们的工作目录是
|
|
2944
|
+
/…/run-xxx/a1 这种**临时目录** —— 任务页不把它们单独列成空间
|
|
2945
|
+
(见 renderSessions 的 CHILD_OF 归类),需求页若照单全收就会多出
|
|
2946
|
+
一堆 a1/t1/tmp 之类的假空间(用户 2026-10-22:「没有空间的不需要」)。
|
|
2947
|
+
口径必须与任务页一致,所以这里按同一套父子关系判。 */
|
|
2948
|
+
function topLevelSessions(list) {
|
|
2949
|
+
var rows = list || [];
|
|
2950
|
+
var key2sid = {};
|
|
2951
|
+
rows.forEach(function (s) {
|
|
2952
|
+
var sid = sidOf(s);
|
|
2953
|
+
if (!sid) return;
|
|
2954
|
+
key2sid[sid] = sid;
|
|
2955
|
+
if (s.run_id) key2sid[s.run_id] = sid;
|
|
2956
|
+
if (s.session_id) key2sid[s.session_id] = sid;
|
|
2957
|
+
});
|
|
2958
|
+
return rows.filter(function (s) {
|
|
2959
|
+
var pk = key2sid[parentKeyOf(s)] || '';
|
|
2960
|
+
return !(pk && pk !== sidOf(s));
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
/* 一个目录算不算「空间」:得是**用户认得的工作区**。
|
|
2964
|
+
根目录 / 不是空间(哪怕历史上有会话在那儿跑过)—— 需求页显示它只会让人
|
|
2965
|
+
以为「/ 是个项目」(用户 2026-10-22:「没有空间的不需要」)。
|
|
2966
|
+
⚠️ 别把整个 /tmp 一刀切:/tmp/teamapi 是用户自己建的项目,是真空间
|
|
2967
|
+
(任务页也列着它)。团队运行目录另有专门的判法(见 isTeamRunDir)。 */
|
|
2968
|
+
function isRealSpaceDir(dir) {
|
|
2969
|
+
var d = reqNormDir(dir);
|
|
2970
|
+
if (!d) return false; // 根目录 /(归一后是空串)
|
|
2971
|
+
if (isTeamRunDir(d)) return false;
|
|
2972
|
+
return true;
|
|
2973
|
+
}
|
|
2974
|
+
/* 团队模式的运行目录:<空间>/.magic/team/run-xxx[/子代理目录]。
|
|
2975
|
+
这些是团队**这一轮**的工作区,跑完就没了 —— 不是用户意义上的空间。 */
|
|
2976
|
+
function isTeamRunDir(dir) {
|
|
2977
|
+
var d = reqNormDir(dir);
|
|
2978
|
+
return d.indexOf('/.magic/team/') >= 0;
|
|
2979
|
+
}
|
|
2394
2980
|
/* 会话标题:**以 core 给的 title 为准**(2026-10-15,用户「应该 magic-agent 统一归纳作为
|
|
2395
2981
|
会话标题」)。core 的 --sessions 已把「标题表覆盖 → prompt_head」合并成一个 title 字段,
|
|
2396
2982
|
所以这里不再自己拼优先级 —— 此前本文件是 `TITLES[id] || prompt_head`、PC 端是另一套
|
|
@@ -3032,6 +3618,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3032
3618
|
var REQ_BUSY = false; // 「整理」进行中(防重复点击)
|
|
3033
3619
|
var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
|
|
3034
3620
|
var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
|
|
3621
|
+
var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
|
|
3622
|
+
var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
|
|
3623
|
+
/* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。 */
|
|
3624
|
+
function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
|
|
3035
3625
|
|
|
3036
3626
|
function reqFeatureRow(f) {
|
|
3037
3627
|
/* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
|
|
@@ -3066,17 +3656,27 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3066
3656
|
return b;
|
|
3067
3657
|
}
|
|
3068
3658
|
|
|
3069
|
-
/*
|
|
3070
|
-
|
|
3071
|
-
|
|
3659
|
+
/* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
|
|
3660
|
+
一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
|
|
3661
|
+
结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
|
|
3662
|
+
idx = 排序后的位次(0 = 需求条数最多的那个)。 */
|
|
3663
|
+
function reqWorkspaceBlock(w, idx) {
|
|
3072
3664
|
var wrap = hEl('div', '');
|
|
3073
3665
|
var head = hEl('div', 'wsrow__head');
|
|
3074
3666
|
var key = w.id || w.dir || w.name || '';
|
|
3075
3667
|
var count = Number(w.feature_count) || 0;
|
|
3076
|
-
|
|
3668
|
+
/* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
|
|
3669
|
+
空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
|
|
3670
|
+
用户手动点过谁就按用户的来(REQ_OPEN 里有记录就不动它)。 */
|
|
3671
|
+
var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
|
|
3077
3672
|
var tog = document.createElement('button');
|
|
3078
|
-
tog.type = 'button'; tog.className = 'wsrow__open';
|
|
3673
|
+
tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
|
|
3079
3674
|
tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
|
|
3675
|
+
/* 模块数 + 功能点数:一行说清这个空间有多大,比只写空间名有用。
|
|
3676
|
+
用**中性色**(不是 wsrow__badge 那枚 queued 紫)—— 那是「等待中」的语义色,
|
|
3677
|
+
空间规模不是状态,套上会让人误以为这个空间在排队。 */
|
|
3678
|
+
var mods = (w.modules || []).filter(function (m) { return (m.features || []).length; });
|
|
3679
|
+
tog.appendChild(elSpan('wsrow__meta', mods.length + ' 模块 · ' + count + ' 条'));
|
|
3080
3680
|
/* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
|
|
3081
3681
|
if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
|
|
3082
3682
|
/* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
|
|
@@ -3104,55 +3704,141 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3104
3704
|
err.style.color = 'var(--warning)';
|
|
3105
3705
|
wrap.appendChild(err);
|
|
3106
3706
|
}
|
|
3707
|
+
var shown = 0;
|
|
3107
3708
|
(w.modules || []).forEach(function (m) {
|
|
3108
|
-
/* 模块(大功能点)用一条小节标题,形状与「置顶 / 任务 / 空间」的 sechead 一致:
|
|
3109
|
-
名字 + 条数。未归类(name 为空)不标题、直接平铺 ——
|
|
3110
|
-
老数据是平铺目录,硬给它编个「未归类」反而像出了错。 */
|
|
3111
3709
|
var fs = (m.features || []);
|
|
3112
3710
|
if (!fs.length) return;
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3711
|
+
shown += fs.length;
|
|
3712
|
+
/* 模块 = 目录里的一行(缩进 + 连接线归属到空间),点它进二级看需求列表。
|
|
3713
|
+
未归类(name 为空)的老数据没有模块名可写 —— 这种情况直接不画这一行,
|
|
3714
|
+
功能点仍能在二级里看到(core 把空 module 排在最后)。 */
|
|
3715
|
+
if (!m.name) return;
|
|
3716
|
+
var row = document.createElement('button');
|
|
3717
|
+
row.type = 'button'; row.className = 'reqtree__mod';
|
|
3718
|
+
row.appendChild(elSpan('reqtree__modname', m.name));
|
|
3719
|
+
row.appendChild(elSpan('reqtree__modcount', String(fs.length)));
|
|
3720
|
+
var go = elSpan('reqtree__modgo');
|
|
3721
|
+
go.appendChild(svgUse('#i-chev-r', 16));
|
|
3722
|
+
row.appendChild(go);
|
|
3723
|
+
row.onclick = function () { openReqDetail(w, m.name); };
|
|
3724
|
+
wrap.appendChild(row);
|
|
3120
3725
|
});
|
|
3726
|
+
/* 空间还在但还没有文档:别画成一片空白,给出这一组的下一步(整理)。 */
|
|
3727
|
+
if (!shown) {
|
|
3728
|
+
var tip = Number(w.pending) > 0
|
|
3729
|
+
? '有 ' + w.pending + ' 条对话待整理'
|
|
3730
|
+
: (w.initialized === false ? '还没有需求文档,点右侧「整理」按空间生成' : '还没有需求文档');
|
|
3731
|
+
var hint = hEl('div', 'history__hint', tip);
|
|
3732
|
+
hint.style.padding = '6px 12px 12px';
|
|
3733
|
+
wrap.appendChild(hint);
|
|
3734
|
+
}
|
|
3121
3735
|
return wrap;
|
|
3122
3736
|
}
|
|
3123
3737
|
|
|
3738
|
+
/* 二级:某空间下**所有模块**的需求列表(用户 2026-10-22 定稿)。
|
|
3739
|
+
进来的那个模块高亮,滚到它那儿;其余模块照常列出来 —— 需求之间有交叉,
|
|
3740
|
+
只看当前模块会漏掉相关需求。 */
|
|
3741
|
+
function reqDetailBlock(d) {
|
|
3742
|
+
var w = d.ws;
|
|
3743
|
+
var frag = document.createDocumentFragment();
|
|
3744
|
+
(w.modules || []).forEach(function (m) {
|
|
3745
|
+
var fs = (m.features || []);
|
|
3746
|
+
if (!fs.length) return;
|
|
3747
|
+
var sec = hEl('div', 'reqdetail__sec' + (m.name === d.mod ? ' reqdetail__sec--cur' : ''));
|
|
3748
|
+
var mh = hEl('div', 'sechead');
|
|
3749
|
+
mh.style.paddingLeft = '16px';
|
|
3750
|
+
mh.appendChild(elSpan('sechead__t', (m.name || '未归类') + ' (' + fs.length + ')'));
|
|
3751
|
+
sec.appendChild(mh);
|
|
3752
|
+
fs.forEach(function (f) { sec.appendChild(reqFeatureRow(f)); });
|
|
3753
|
+
frag.appendChild(sec);
|
|
3754
|
+
});
|
|
3755
|
+
if (!frag.childNodes.length) {
|
|
3756
|
+
frag.appendChild(hEl('div', 'history__hint', '这个空间还没有需求文档'));
|
|
3757
|
+
}
|
|
3758
|
+
return frag;
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
/* 进二级:记下是哪个空间的哪个模块,渲染后滚到那个模块。 */
|
|
3762
|
+
function openReqDetail(w, mod) {
|
|
3763
|
+
var key = w.id || w.dir || w.name || '';
|
|
3764
|
+
REQ_DETAIL = { key: key, mod: mod, ws: w };
|
|
3765
|
+
renderRequirements();
|
|
3766
|
+
/* 空间在两次渲染之间被删掉了(会话全删 → 空间联动消失):
|
|
3767
|
+
renderRequirements 会把 REQ_DETAIL 清空,这时别停在空白的二级页,退回目录。 */
|
|
3768
|
+
if (!REQ_DETAIL) { hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; return; }
|
|
3769
|
+
hideAll(); reqDetailEl.hidden = false; tabbarEl.hidden = true;
|
|
3770
|
+
if (reqDetailTitle) reqDetailTitle.textContent = (w.name || w.dir || w.id) + ' · ' + mod;
|
|
3771
|
+
/* 滚到当前模块:它高亮,但用户进来第一眼要看到的是自己点的那一块。 */
|
|
3772
|
+
if (reqDetailListEl) {
|
|
3773
|
+
var cur = reqDetailListEl.querySelector('.reqdetail__sec--cur');
|
|
3774
|
+
if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'start' });
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
/* 退二级回一级目录。 */
|
|
3779
|
+
function closeReqDetail() {
|
|
3780
|
+
REQ_DETAIL = null;
|
|
3781
|
+
hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false;
|
|
3782
|
+
renderRequirements();
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3124
3785
|
function renderRequirements() {
|
|
3125
3786
|
if (!reqListEl) return;
|
|
3126
3787
|
var data = REQ_DATA;
|
|
3127
3788
|
if (!data) return;
|
|
3128
|
-
/*
|
|
3129
|
-
|
|
3789
|
+
/* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
|
|
3790
|
+
(用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
|
|
3791
|
+
也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
|
|
3792
|
+
这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
|
|
3793
|
+
拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
|
|
3130
3794
|
var all = (data && data.workspaces) || [];
|
|
3131
3795
|
var list = all.filter(function (w) {
|
|
3132
|
-
|
|
3133
|
-
|
|
3796
|
+
var dir = reqNormDir(w && w.dir);
|
|
3797
|
+
/* 根目录 / 归一后是空串 —— 「!dir 就放行」的老写法会把它漏进来,
|
|
3798
|
+
需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
|
|
3799
|
+
这里先卡一道:不是真空间目录的直接不画。 */
|
|
3800
|
+
if (!isRealSpaceDir(dir)) return false;
|
|
3801
|
+
return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
|
|
3802
|
+
});
|
|
3803
|
+
/* 排序:**需求条数多的排前面**(用户 2026-10-22)。
|
|
3804
|
+
core 给的顺序是「最后归纳时间倒序」,但那是给「最近在动的」用的;
|
|
3805
|
+
需求页要回答的是「哪块需求最多」,条数多的才是用户要先看的那个。
|
|
3806
|
+
条数一样时按名字排 —— 同一条数下顺序要稳定,不能每次刷新都在跳。 */
|
|
3807
|
+
list.sort(function (a, b) {
|
|
3808
|
+
var ca = Number(a && a.feature_count) || 0;
|
|
3809
|
+
var cb = Number(b && b.feature_count) || 0;
|
|
3810
|
+
if (ca !== cb) return cb - ca;
|
|
3811
|
+
return String((a && a.name) || '').localeCompare(String((b && b.name) || ''));
|
|
3134
3812
|
});
|
|
3813
|
+
/* 二级视图:只画这个空间的需求列表。空间可能已被会话删除联动掉
|
|
3814
|
+
(list 里没有它)—— 那就退回一级目录,不要留一页空白。 */
|
|
3815
|
+
if (REQ_DETAIL) {
|
|
3816
|
+
var hit = null;
|
|
3817
|
+
for (var i = 0; i < list.length; i++) {
|
|
3818
|
+
if ((list[i].id || list[i].dir || list[i].name || '') === REQ_DETAIL.key) { hit = list[i]; break; }
|
|
3819
|
+
}
|
|
3820
|
+
if (!hit) { REQ_DETAIL = null; }
|
|
3821
|
+
else {
|
|
3822
|
+
REQ_DETAIL.ws = hit;
|
|
3823
|
+
if (reqDetailTitle) reqDetailTitle.textContent = (hit.name || hit.dir || hit.id) + ' · ' + REQ_DETAIL.mod;
|
|
3824
|
+
if (reqDetailListEl) reqDetailListEl.replaceChildren(reqDetailBlock(REQ_DETAIL));
|
|
3825
|
+
if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
|
|
3826
|
+
return;
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3135
3829
|
var frag = document.createDocumentFragment();
|
|
3136
3830
|
if (!list.length) {
|
|
3137
|
-
/*
|
|
3138
|
-
后者要引导用户点「整理」,否则这一页看起来永远长不出东西。 */
|
|
3139
|
-
var pend = 0, uninit = 0;
|
|
3140
|
-
all.forEach(function (w) {
|
|
3141
|
-
pend += Number(w.pending) || 0;
|
|
3142
|
-
if (w.initialized === false) uninit++;
|
|
3143
|
-
});
|
|
3831
|
+
/* 一个空间都没有(会话全删了 / 还没聊过)。 */
|
|
3144
3832
|
frag.appendChild(hEl('div', 'history__hint',
|
|
3145
|
-
|
|
3146
|
-
? '
|
|
3147
|
-
:
|
|
3148
|
-
? '还没有需求。点空间右侧「整理」会先按空间目录生成一版需求基线;右上角「全部整理」一次处理所有空间。'
|
|
3149
|
-
: '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。')));
|
|
3833
|
+
REQ_ALIVE
|
|
3834
|
+
? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
|
|
3835
|
+
: '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
|
|
3150
3836
|
reqListEl.replaceChildren(frag);
|
|
3151
3837
|
return;
|
|
3152
3838
|
}
|
|
3153
|
-
/*
|
|
3154
|
-
|
|
3155
|
-
list.forEach(function (w) { frag.appendChild(reqWorkspaceBlock(w)); });
|
|
3839
|
+
/* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
|
|
3840
|
+
见 reqWorkspaceBlock 的 open 判定。 */
|
|
3841
|
+
list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
|
|
3156
3842
|
reqListEl.replaceChildren(frag);
|
|
3157
3843
|
/* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
|
|
3158
3844
|
if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
|
|
@@ -3162,7 +3848,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3162
3848
|
if (!reqListEl) return;
|
|
3163
3849
|
if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
3164
3850
|
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3165
|
-
fetch(BASE + '/desk/requirements', { headers: hdr })
|
|
3851
|
+
var pReq = fetch(BASE + '/desk/requirements', { headers: hdr })
|
|
3166
3852
|
.then(function (r) {
|
|
3167
3853
|
if (r.status === 501) {
|
|
3168
3854
|
/* 老 core 没有这个能力位:**说清楚**,不要给一页空白
|
|
@@ -3171,9 +3857,38 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3171
3857
|
}
|
|
3172
3858
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3173
3859
|
return r.json();
|
|
3174
|
-
})
|
|
3175
|
-
|
|
3176
|
-
|
|
3860
|
+
});
|
|
3861
|
+
/* 会话清单与空间目录:用来判断「这个空间在任务页还在不在」。
|
|
3862
|
+
两者都允许失败 —— 拿不到就不筛空间(见 renderRequirements)。 */
|
|
3863
|
+
var pSess = fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
3864
|
+
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
|
3865
|
+
var pDirs = fetch(BASE + '/desk/dirs', { headers: hdr })
|
|
3866
|
+
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
|
3867
|
+
Promise.all([pReq, pSess, pDirs])
|
|
3868
|
+
.then(function (a) {
|
|
3869
|
+
var sess = a[1], dirs = (a[2] && a[2].dirs) || null;
|
|
3870
|
+
if (Array.isArray(sess) || Array.isArray(dirs)) {
|
|
3871
|
+
/* 与任务页**同一套空间键**(用户 2026-10-22:「4个空间需求里显示的不一样」
|
|
3872
|
+
「会话中全部删除后需求中的空间还在 也要联动删除」「没有空间的不需要」):
|
|
3873
|
+
① 根目录下的 magic-* ② 自定义空间 ③ **顶层会话**的工作目录。
|
|
3874
|
+
⚠️ ③ 只能取**顶层**会话的 workspace:团队子代理的会话挂在父会话下面,
|
|
3875
|
+
它们的工作目录是 /…/run-xxx/a1 这种临时目录 —— 任务页不把它们列成空间,
|
|
3876
|
+
需求页照收就会多出一堆 a1/t1/tmp 假空间。
|
|
3877
|
+
⚠️ 根目录 / 与 /tmp 这类系统目录不是空间,哪怕历史上有会话在那儿跑过。 */
|
|
3878
|
+
var alive = {};
|
|
3879
|
+
(ROOT_DIRS || []).forEach(function (d) { if (d && d.path) alive[reqNormDir(d.path)] = 1; });
|
|
3880
|
+
(dirs || []).forEach(function (d) {
|
|
3881
|
+
if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[reqNormDir(d.path)] = 1;
|
|
3882
|
+
});
|
|
3883
|
+
(CUSTOM_SPACES || []).forEach(function (p) { if (p) alive[reqNormDir(p)] = 1; });
|
|
3884
|
+
topLevelSessions(sess).forEach(function (s) {
|
|
3885
|
+
if (s && s.workspace && isRealSpaceDir(s.workspace)) alive[reqNormDir(s.workspace)] = 1;
|
|
3886
|
+
});
|
|
3887
|
+
REQ_ALIVE = alive;
|
|
3888
|
+
} else {
|
|
3889
|
+
REQ_ALIVE = null; // 清单没拿到:不筛,宁可多显示
|
|
3890
|
+
}
|
|
3891
|
+
REQ_DATA = a[0] || { workspaces: [] };
|
|
3177
3892
|
renderRequirements();
|
|
3178
3893
|
})
|
|
3179
3894
|
.catch(function (e) {
|
|
@@ -3212,7 +3927,8 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3212
3927
|
}
|
|
3213
3928
|
|
|
3214
3929
|
/* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
|
|
3215
|
-
label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
|
|
3930
|
+
label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
|
|
3931
|
+
二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
|
|
3216
3932
|
function reqSetBusy(on, label) {
|
|
3217
3933
|
if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
|
|
3218
3934
|
else REQ_BUSY_LABEL = '全部整理';
|
|
@@ -3221,6 +3937,11 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3221
3937
|
btn.disabled = !!on;
|
|
3222
3938
|
if (label !== undefined) btn.textContent = label;
|
|
3223
3939
|
}
|
|
3940
|
+
var dbtn = document.getElementById('btnReqDetailSync');
|
|
3941
|
+
if (dbtn) {
|
|
3942
|
+
dbtn.disabled = !!on;
|
|
3943
|
+
dbtn.textContent = on ? '整理中…' : '整理';
|
|
3944
|
+
}
|
|
3224
3945
|
Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
|
|
3225
3946
|
b.disabled = !!on;
|
|
3226
3947
|
b.textContent = on ? '整理中…' : '整理';
|
|
@@ -3267,6 +3988,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3267
3988
|
var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
|
|
3268
3989
|
var key = w.dir || w.id || '';
|
|
3269
3990
|
if (!key || seen[key]) return false;
|
|
3991
|
+
/* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
|
|
3992
|
+
var dir = reqNormDir(w.dir);
|
|
3993
|
+
if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
|
|
3270
3994
|
seen[key] = true;
|
|
3271
3995
|
return true;
|
|
3272
3996
|
});
|
|
@@ -3319,6 +4043,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
3319
4043
|
step();
|
|
3320
4044
|
}
|
|
3321
4045
|
on('btnReqSync', function () { consolidateAllRequirements(); });
|
|
4046
|
+
/* 二级视图的两个动作:返回一级目录 / 整理**当前这个空间**(不是全部空间 ——
|
|
4047
|
+
二级页的作用域就是这个空间,标成「全部整理」会让人以为还是全局动作)。 */
|
|
4048
|
+
on('btnReqDetailBack', function () { closeReqDetail(); });
|
|
4049
|
+
on('btnReqDetailSync', function () {
|
|
4050
|
+
if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
|
|
4051
|
+
consolidateRequirements(REQ_DETAIL.ws);
|
|
4052
|
+
});
|
|
3322
4053
|
|
|
3323
4054
|
function openHostSheet(title, build) {
|
|
3324
4055
|
closeHostSheet();
|
|
@@ -4048,6 +4779,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4048
4779
|
localStorage.setItem('magic.engine', RUN_PREF.engine || '');
|
|
4049
4780
|
localStorage.setItem('magic.model', RUN_PREF.model || '');
|
|
4050
4781
|
} catch (e) {}
|
|
4782
|
+
schedulePushPrefs();
|
|
4051
4783
|
paintRunPref();
|
|
4052
4784
|
}
|
|
4053
4785
|
function paintRunPref() {
|
|
@@ -4080,10 +4812,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4080
4812
|
var sum = document.getElementById('teamSummary');
|
|
4081
4813
|
if (sum) sum.textContent = custom ? (custom + ' 项已单独指定') : '跟随主引擎';
|
|
4082
4814
|
}
|
|
4083
|
-
/* 团队模式子页面 =
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4815
|
+
/* 团队模式子页面 = **一张卡、每行两个触发点**(用户 2026-10-22:「两个触发就行了」):
|
|
4816
|
+
左边(名字 + 规则描述)→ 只改规则;右边(引擎值)→ 只选引擎。
|
|
4817
|
+
两块配置分属两个抽屉,但不必各占一整张卡。
|
|
4818
|
+
签名含两侧文案与引擎:变了才重建,避免 5s 轮询时闪。 */
|
|
4087
4819
|
function paintTeamRoles(roles) {
|
|
4088
4820
|
var card = document.getElementById('teamRouteCard');
|
|
4089
4821
|
if (!card) return;
|
|
@@ -4101,29 +4833,34 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4101
4833
|
TEAM_ROLE_SIG = sig;
|
|
4102
4834
|
card.innerHTML = '';
|
|
4103
4835
|
if (!show) return;
|
|
4104
|
-
|
|
4836
|
+
addTeamSplitRow(card, 'plan', '计划任务', planSub);
|
|
4105
4837
|
roles.forEach(function (r) {
|
|
4106
|
-
|
|
4838
|
+
addTeamSplitRow(card, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name, teamRoleDuty(r));
|
|
4107
4839
|
});
|
|
4108
4840
|
}
|
|
4109
|
-
/*
|
|
4110
|
-
|
|
4841
|
+
/* 一行两个点击区:左边改规则,右边选引擎。
|
|
4842
|
+
⚠️ 外层必须是 div —— button 不能套 button;`.setrow` 的边框/间距照常用在外层。 */
|
|
4843
|
+
function addTeamSplitRow(card, key, label, sub) {
|
|
4111
4844
|
var rt = (TEAM.routes || {})[key] || {};
|
|
4112
|
-
var
|
|
4113
|
-
|
|
4114
|
-
|
|
4845
|
+
var row = hEl('div', 'setrow setrow--split');
|
|
4846
|
+
var main = hEl('button', 'setrow__mainbtn');
|
|
4847
|
+
main.type = 'button';
|
|
4115
4848
|
main.appendChild(hEl('span', 'setrow__l', label));
|
|
4116
4849
|
if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
|
|
4117
|
-
|
|
4118
|
-
|
|
4850
|
+
main.onclick = function () { openTeamRuleSheet(key, label + ':规则'); };
|
|
4851
|
+
row.appendChild(main);
|
|
4852
|
+
var v = hEl('button', 'setrow__vbtn');
|
|
4853
|
+
v.type = 'button';
|
|
4854
|
+
v.appendChild(hEl('span', 'setrow__v',
|
|
4119
4855
|
rt.engine ? (rt.engine + (rt.model ? ' · ' + qaModelText(rt.model) : '')) : '跟随主引擎'));
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
String(key).indexOf('role:') === 0 ? (label + '
|
|
4856
|
+
v.appendChild(hEl('span', 'setrow__chev', '›'));
|
|
4857
|
+
v.onclick = function () {
|
|
4858
|
+
openTeamEnginePicker(key,
|
|
4859
|
+
String(key).indexOf('role:') === 0 ? (label + '交给谁') : '计划任务交给谁',
|
|
4124
4860
|
teamRoleKindOf(key));
|
|
4125
4861
|
};
|
|
4126
|
-
|
|
4862
|
+
row.appendChild(v);
|
|
4863
|
+
card.appendChild(row);
|
|
4127
4864
|
}
|
|
4128
4865
|
function teamRoleKindOf(key) {
|
|
4129
4866
|
if (String(key).indexOf('role:') !== 0) return 'plan';
|
|
@@ -4136,26 +4873,19 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4136
4873
|
var ov = (TEAM.rules || {})['role:' + r.name];
|
|
4137
4874
|
return (ov && String(ov).trim()) || r.duty || '';
|
|
4138
4875
|
}
|
|
4139
|
-
/*
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
首项 = 不单独指定 / 跟随主引擎。
|
|
4876
|
+
/* 上卡点一行 → 抽屉:**只选引擎 · 模型**(与「默认引擎」那套同一个交互)。
|
|
4877
|
+
规则不在这里 —— 见下卡与 openTeamRuleSheet(用户 2026-10-22:「配引擎和配规则要分开配置」)。
|
|
4878
|
+
首项 = 跟随主引擎。
|
|
4143
4879
|
2026-10-21 口径(用户:「图像视频不是说需要生成 是擅长做 比如有一个带了视频 skill 的引擎」):
|
|
4144
4880
|
这里问的是「这类活谁擅长」,所以不写「交给 core 自动路由」—— 说「自动」会让人以为
|
|
4145
4881
|
不配也能跑,而实际语义是「跟主引擎一致」。 */
|
|
4146
|
-
function
|
|
4882
|
+
function openTeamEnginePicker(key, title, kind) {
|
|
4147
4883
|
var isRole = String(key).indexOf('role:') === 0;
|
|
4148
4884
|
if (!kind) kind = key; // 计划键时 kind 就是键本身
|
|
4149
4885
|
openHostSheet(title, function (body) {
|
|
4150
4886
|
var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
|
|
4151
4887
|
hint.style.textAlign = 'left';
|
|
4152
4888
|
body.appendChild(hint);
|
|
4153
|
-
/* 规则控件放在引擎清单**之前**:规则是这一行的主内容,引擎是「交给谁」。
|
|
4154
|
-
它们与探测无关,引擎清单拉失败也照样能改。分区标题必须显式给出 ——
|
|
4155
|
-
用户 2026-10-22:「入口在哪里 不能配置规则」:控件在,但没标题就读不出这里是配置区。 */
|
|
4156
|
-
body.appendChild(hEl('div', 'pick__cap pick__cap--team', '规则'));
|
|
4157
|
-
if (isRole) body.appendChild(buildTeamRuleEditor(key));
|
|
4158
|
-
else if (kind === 'plan') body.appendChild(buildTeamSplitControls());
|
|
4159
4889
|
fetchEngines().then(function (list) {
|
|
4160
4890
|
if (hint.parentNode) hint.remove();
|
|
4161
4891
|
var avail = list.filter(function (x) { return x.ok; });
|
|
@@ -4173,19 +4903,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4173
4903
|
不是给 core 的配置(core 只认 engine / model)。
|
|
4174
4904
|
⚠️ 必须建在 paint() **外面**:paint 每次切引擎都会重画两列,放里面会一行行堆起来
|
|
4175
4905
|
(2026-10-21 实测:点两次引擎,抽屉底部就多出三段同样的说明)。 */
|
|
4176
|
-
var tipText
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
tipText = '这个角色内置的职责:' + ((info && info.duty) || '—')
|
|
4180
|
-
+ '。上面写的规则会替掉它,进计划提示词;这里留空 = 跟随主引擎。';
|
|
4181
|
-
} else {
|
|
4182
|
-
tipText = '计划阶段负责把任务拆成子任务,交给更擅长规划、上下文更长的引擎更稳。';
|
|
4183
|
-
}
|
|
4184
|
-
body.appendChild(hEl('div', 'pick__cap pick__cap--team', '交给谁'));
|
|
4906
|
+
var tipText = isRole
|
|
4907
|
+
? '只选这一类活交给谁;「这类任务适合什么」在团队模式页的「规则」卡里改。'
|
|
4908
|
+
: '计划阶段负责把任务拆成子任务,交给更擅长规划、上下文更长的引擎更稳;拆解规则在「规则」卡里改。';
|
|
4185
4909
|
var tip = hEl('div', 'history__hint', tipText);
|
|
4186
4910
|
tip.style.textAlign = 'left';
|
|
4187
4911
|
body.appendChild(tip);
|
|
4188
|
-
var wrap = hEl('div', 'pick__cols
|
|
4912
|
+
var wrap = hEl('div', 'pick__cols');
|
|
4189
4913
|
var left = hEl('div', 'pick__col');
|
|
4190
4914
|
var right = hEl('div', 'pick__col pick__col--r');
|
|
4191
4915
|
wrap.appendChild(left); wrap.appendChild(right); body.appendChild(wrap);
|
|
@@ -4230,6 +4954,15 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4230
4954
|
});
|
|
4231
4955
|
});
|
|
4232
4956
|
}
|
|
4957
|
+
/* 下卡点一行 → 抽屉:**只改规则**(不含引擎)。计划 = 拆解粒度与两个开关;
|
|
4958
|
+
角色 = 「这类任务适合什么」。与上卡的引擎选择互不掺杂(用户 2026-10-22)。 */
|
|
4959
|
+
function openTeamRuleSheet(key, label) {
|
|
4960
|
+
var isRole = String(key).indexOf('role:') === 0;
|
|
4961
|
+
openHostSheet(label, function (body) {
|
|
4962
|
+
if (isRole) body.appendChild(buildTeamRuleEditor(key));
|
|
4963
|
+
else body.appendChild(buildTeamSplitControls());
|
|
4964
|
+
});
|
|
4965
|
+
}
|
|
4233
4966
|
/* 角色行的「规则」:一句话写清这类任务适合什么。**默认值直接填内置职责**(用户
|
|
4234
4967
|
2026-10-22:「也没看到默认规则啊」—— placeholder 在手机上几乎看不见),保存后覆盖它
|
|
4235
4968
|
(core 的 --team-rule role:<name>=…);改回内置值等于没改,不落覆盖。 */
|
|
@@ -4461,7 +5194,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4461
5194
|
|
|
4462
5195
|
avail.forEach(function (en) {
|
|
4463
5196
|
var n = (en.models || []).length;
|
|
4464
|
-
/* is-on = 已保存的默认(浅蓝实底);is-cur =
|
|
5197
|
+
/* is-on = 已保存的默认(浅蓝实底);is-cur = 右列此刻在浏览谁(左侧细条,无底色)——
|
|
4465
5198
|
两者分开,默认是「自动」时不会出现两个一样的选中块。 */
|
|
4466
5199
|
var b = hEl('button', 'pick__citem'
|
|
4467
5200
|
+ (RUN_PREF.engine === en.engine ? ' is-on' : '')
|
|
@@ -4554,6 +5287,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4554
5287
|
document.getElementById('btnConnect').onclick = function () {
|
|
4555
5288
|
var target = parseTarget(addrEl.value);
|
|
4556
5289
|
if (!target) { showError('请输入 magic-agent 桌面地址(形如 http://192.168.1.5:8791/?token=…)'); return; }
|
|
5290
|
+
/* 名称栏(2026-10-22):手填的优先于地址里的 ?name=,连上后就是环境名。 */
|
|
5291
|
+
var nm = connNameEl ? String(connNameEl.value || '').trim() : '';
|
|
5292
|
+
if (nm) target.name = nm;
|
|
4557
5293
|
connect(target);
|
|
4558
5294
|
};
|
|
4559
5295
|
addrEl.addEventListener('input', function () { showError(''); });
|
|
@@ -4617,11 +5353,12 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4617
5353
|
on('btnDefaultEngine', openDefaultEnginePicker);
|
|
4618
5354
|
on('btnDefaultModel', openDefaultEnginePicker);
|
|
4619
5355
|
/* 团队模式配置(「我的」→ 子页面):计划 + 7 个角色,行由 paintTeamRoles 动态渲染,
|
|
4620
|
-
行的点击在渲染时绑定(见
|
|
5356
|
+
行的点击在渲染时绑定(见 addTeamRouteRow / addTeamRuleRow),这里只留入口与返回。 */
|
|
4621
5357
|
on('btnTeamPage', showTeamPage);
|
|
4622
5358
|
on('btnTeamBack', showMine);
|
|
4623
5359
|
on('btnLogout', disconnect);
|
|
4624
5360
|
on('btnUpgrade', reloadApp);
|
|
5361
|
+
on('btnMineVersion', checkAppUpdate);
|
|
4625
5362
|
if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
|
|
4626
5363
|
if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
|
|
4627
5364
|
if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
|
|
@@ -4683,12 +5420,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4683
5420
|
scanEl.hidden = true;
|
|
4684
5421
|
}
|
|
4685
5422
|
document.getElementById('btnScanClose').onclick = stopScan;
|
|
4686
|
-
|
|
5423
|
+
/* 通用扫码:扫到就把原文交给回调(启动页用来直连,「添加环境」用来填链接)。 */
|
|
5424
|
+
function startScan(onResult, onError) {
|
|
5425
|
+
var fail = onError || function (m) { toast(m); };
|
|
4687
5426
|
if (!('BarcodeDetector' in window) || !navigator.mediaDevices) {
|
|
4688
|
-
|
|
5427
|
+
fail('当前环境不支持扫码,请把桌面给的地址粘贴进来。');
|
|
4689
5428
|
return;
|
|
4690
5429
|
}
|
|
4691
|
-
showError('');
|
|
4692
5430
|
scanEl.hidden = false;
|
|
4693
5431
|
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (stream) {
|
|
4694
5432
|
scanStream = stream;
|
|
@@ -4701,19 +5439,47 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4701
5439
|
scanDetector.detect(scanVideo).then(function (codes) {
|
|
4702
5440
|
if (!codes || !codes.length) return;
|
|
4703
5441
|
stopScan();
|
|
4704
|
-
|
|
4705
|
-
var target = parseTarget(raw);
|
|
4706
|
-
if (target) connect(target);
|
|
4707
|
-
else showError('这个二维码不是 magic-agent 连接地址。');
|
|
5442
|
+
onResult(codes[0].rawValue || '');
|
|
4708
5443
|
}).catch(function () {});
|
|
4709
5444
|
}, 400);
|
|
4710
5445
|
}).catch(function () {
|
|
4711
5446
|
stopScan();
|
|
4712
|
-
|
|
5447
|
+
fail('无法启动相机,请改用粘贴地址的方式。');
|
|
4713
5448
|
});
|
|
5449
|
+
}
|
|
5450
|
+
document.getElementById('btnScan').onclick = function () {
|
|
5451
|
+
showError('');
|
|
5452
|
+
startScan(function (raw) {
|
|
5453
|
+
var target = parseTarget(raw);
|
|
5454
|
+
if (!target) { showError('这个二维码不是 magic-agent 连接地址。'); return; }
|
|
5455
|
+
var nm = connNameEl ? String(connNameEl.value || '').trim() : '';
|
|
5456
|
+
if (nm) target.name = nm;
|
|
5457
|
+
connect(target);
|
|
5458
|
+
}, function (m) { showError(m); });
|
|
4714
5459
|
};
|
|
4715
5460
|
|
|
4716
5461
|
window.addEventListener('pagehide', stopScan);
|
|
5462
|
+
/* 关掉 App / 刷新之前也把这台的本机状态记进它自己的槽:
|
|
5463
|
+
用户改完设置不一定马上切环境,不记就会在下次启动时丢。 */
|
|
5464
|
+
window.addEventListener('pagehide', function () {
|
|
5465
|
+
if (!TOKEN || ENV_SWITCHING) return;
|
|
5466
|
+
try { saveEnvScope(envScopeId()); } catch (e) {}
|
|
5467
|
+
});
|
|
5468
|
+
|
|
5469
|
+
/* 壳注入的那条连接换令牌了(App 每次启动都会换):把存档里同地址的记录同步过去。
|
|
5470
|
+
否则「切回本机」会用一枚已经失效的旧令牌去连,表现成「这台连不上」。 */
|
|
5471
|
+
function syncInjectedConnToken(url, tok) {
|
|
5472
|
+
if (!url || !tok) return;
|
|
5473
|
+
var list = loadConns(), changed = false;
|
|
5474
|
+
for (var i = 0; i < list.length; i++) {
|
|
5475
|
+
var c = list[i];
|
|
5476
|
+
if (!c || c.url !== url || String(c.token || '') === String(tok)) continue;
|
|
5477
|
+
c.token = tok;
|
|
5478
|
+
c.id = connIdOf({ url: url, token: tok });
|
|
5479
|
+
changed = true;
|
|
5480
|
+
}
|
|
5481
|
+
if (changed) saveConns(list);
|
|
5482
|
+
}
|
|
4717
5483
|
|
|
4718
5484
|
/* ══════════ 首屏 ══════════
|
|
4719
5485
|
· 打包进 App(Capacitor):boot.js 注入 window.MAGIC_BASE / MAGIC_TOKEN → 直连会话;
|
|
@@ -4722,16 +5488,56 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
|
|
|
4722
5488
|
var injectedToken = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
|
|
4723
5489
|
var injectedBase = (window.MAGIC_BASE || '').replace(/\/+$/, '');
|
|
4724
5490
|
try {
|
|
4725
|
-
|
|
5491
|
+
/* 上次停在哪个环境(见 switchEnv):切过环境后整页重载,必须回到**那台**,
|
|
5492
|
+
否则壳注入的地址会把人拽回本机 —— 用户会看到「切了又跳回来」。 */
|
|
5493
|
+
var injUrl = injectedBase || (injectedToken ? location.origin : '');
|
|
5494
|
+
syncInjectedConnToken(injUrl, injectedToken);
|
|
5495
|
+
var resumed = false;
|
|
5496
|
+
try {
|
|
5497
|
+
var lastUrl = localStorage.getItem(ACTIVE_ENV_KEY) || '';
|
|
5498
|
+
if (lastUrl) {
|
|
5499
|
+
var saved = loadConns();
|
|
5500
|
+
for (var i = 0; i < saved.length; i++) {
|
|
5501
|
+
var sc = saved[i];
|
|
5502
|
+
if (!sc || sc.url !== lastUrl) continue;
|
|
5503
|
+
/* 同一地址(壳注入的这台)令牌以注入为准:App 每次启动会换令牌,存档里的旧令牌已失效。 */
|
|
5504
|
+
var tok = (lastUrl === injUrl && injectedToken)
|
|
5505
|
+
? injectedToken : (sc.token || injectedToken);
|
|
5506
|
+
if (!tok) break;
|
|
5507
|
+
BASE = sc.url; TOKEN = tok; ACTIVE_ID = sc.id;
|
|
5508
|
+
showHistory(sc.host || connHost(sc.url));
|
|
5509
|
+
restoreQueue();
|
|
5510
|
+
resumed = true;
|
|
5511
|
+
break;
|
|
5512
|
+
}
|
|
5513
|
+
}
|
|
5514
|
+
} catch (e) { resumed = false; }
|
|
5515
|
+
if (!resumed && (injectedToken || injectedBase)) {
|
|
4726
5516
|
BASE = injectedBase; TOKEN = injectedToken;
|
|
5517
|
+
/* 把当前这条(壳注入的)连接落成环境,名字默认「公司远程」——
|
|
5518
|
+
否则它只是一条改不了名、也留不住的「未命名」。 */
|
|
5519
|
+
seedCurrentConn();
|
|
4727
5520
|
showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
|
|
4728
5521
|
restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
|
|
4729
|
-
} else {
|
|
4730
|
-
|
|
5522
|
+
} else if (!resumed) {
|
|
5523
|
+
/* 没有注入、也没有可恢复的上次环境:有存过的环境就直接连最近用过的那条;
|
|
5524
|
+
一条都没有才进任务页空态 —— 不再用独立启动页挡在前面
|
|
5525
|
+
(2026-10-22 用户:「这个界面不需要了 直接切换环境就行」)。 */
|
|
5526
|
+
var all = loadConns().filter(function (c) { return c && c.url; });
|
|
5527
|
+
all.sort(function (a, b) { return (b.at || 0) - (a.at || 0); });
|
|
5528
|
+
var pick = all[0];
|
|
5529
|
+
if (pick) {
|
|
5530
|
+
BASE = pick.url; TOKEN = pick.token || ''; ACTIVE_ID = pick.id;
|
|
5531
|
+
try { localStorage.setItem(ACTIVE_ENV_KEY, pick.url); } catch (e) {}
|
|
5532
|
+
showHistory(pick.host || connHost(pick.url));
|
|
5533
|
+
restoreQueue();
|
|
5534
|
+
} else {
|
|
5535
|
+
showHistory(null);
|
|
5536
|
+
}
|
|
4731
5537
|
}
|
|
4732
5538
|
} catch (e) {
|
|
4733
|
-
/*
|
|
4734
|
-
try {
|
|
5539
|
+
/* 任何初始化异常都退回任务页空态,绝不留白屏。 */
|
|
5540
|
+
try { showHistory(null); } catch (e2) { try { showLauncher(); } catch (e3) {} }
|
|
4735
5541
|
}
|
|
4736
5542
|
|
|
4737
5543
|
/* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════
|