magic-agent 1.0.111 → 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 +879 -270
- 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}
|
|
@@ -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}
|
|
@@ -462,6 +496,8 @@ button.setrow{font-family:inherit}
|
|
|
462
496
|
.setrow+.setrow{border-top:1px solid var(--border)}
|
|
463
497
|
.setrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink)}
|
|
464
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}
|
|
465
501
|
.setrow--danger{color:var(--error-text)}
|
|
466
502
|
.setrow--danger .setrow__l,.setrow--danger .setrow__v{color:var(--error-text)}
|
|
467
503
|
/* 环境编辑(我的 → 环境):名称 / 地址 / 令牌三栏。
|
|
@@ -472,6 +508,8 @@ button.setrow{font-family:inherit}
|
|
|
472
508
|
.envfield:focus{border-color:var(--primary-action)}
|
|
473
509
|
.envfield::placeholder{color:var(--faint)}
|
|
474
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)}
|
|
475
513
|
.envhint{margin-top:10px;font-size:12px;line-height:1.6;color:var(--faint)}
|
|
476
514
|
.envacts{display:flex;gap:8px;margin-top:16px}
|
|
477
515
|
.envacts .btn{flex:1;margin-top:0}
|
|
@@ -491,6 +529,14 @@ button.setrow{font-family:inherit}
|
|
|
491
529
|
.teamedit__acts{display:flex;gap:8px;margin-bottom:10px}
|
|
492
530
|
/* 团队行尾箭头:点开才有的配置必须看得出「这里能点」。 */
|
|
493
531
|
.setrow__chev{flex:0 0 auto;color:var(--faint);font-size:16px;line-height:1}
|
|
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%}
|
|
494
540
|
.seg{display:inline-flex;background:var(--subtle);border-radius:var(--r-full);padding:2px;gap:2px}
|
|
495
541
|
.seg__b{height:28px;padding:0 12px;border-radius:var(--r-full);font-size:13px;color:var(--muted)}
|
|
496
542
|
.seg__b.is-on{background:var(--raised);color:var(--ink);font-weight:600}
|
|
@@ -505,18 +551,20 @@ button.setrow{font-family:inherit}
|
|
|
505
551
|
从栏背滚过去,靠半透明 + 18px 模糊透出来。这样栏高不进布局,会话页的输入框也不用被它顶上去。
|
|
506
552
|
|
|
507
553
|
代价是**每一页自己留出底部安全距离**,否则最后一行会被压在栏下面:
|
|
508
|
-
· `.history__list` 已有 96px(够
|
|
554
|
+
· `.history__list` 已有 96px(够 56 + 34 safe-area,且给凸出的 + 留了一截)
|
|
509
555
|
· 会话页由 composer 的下内边距负责,见下面的 `#conv .composer`
|
|
510
556
|
· 启动页不显示底栏(showLauncher 里 hidden),无需留白
|
|
511
557
|
|
|
512
558
|
min-height 而不是 height:底栏还要吞 safe-area(34px),
|
|
513
559
|
border-box 下固定 height 会让内部 tab 被压到 26px 高。
|
|
514
|
-
**56
|
|
515
|
-
|
|
516
|
-
|
|
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,栏体也矮下来。 */
|
|
517
565
|
.tabbar{position:absolute;left:0;right:0;bottom:0;z-index:60;
|
|
518
566
|
display:flex;align-items:stretch;
|
|
519
|
-
min-height:56px;
|
|
567
|
+
min-height:calc(56px + env(safe-area-inset-bottom));
|
|
520
568
|
background:color-mix(in srgb,var(--raised) 86%,transparent);
|
|
521
569
|
-webkit-backdrop-filter:blur(18px) saturate(1.4);backdrop-filter:blur(18px) saturate(1.4);
|
|
522
570
|
box-shadow:0 -1px 0 var(--border),0 -14px 30px -26px var(--app-shadow);
|
|
@@ -548,15 +596,16 @@ button.setrow{font-family:inherit}
|
|
|
548
596
|
于是看起来像按钮自带发光轮廓。
|
|
549
597
|
|
|
550
598
|
实现:
|
|
551
|
-
① **洞**:`::before`
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
599
|
+
① **洞**:`::before` 是一块 74×74 的圆(半径 37 = 按钮 34 + 3 接触缝),圆心与按钮同心,
|
|
600
|
+
**再用 `clip-path` 切掉落在栏顶边线以上的部分** —— 这样槽的形状永远跟着按钮走,
|
|
601
|
+
按钮下沉多少,露出的就是那一段圆弧,不用为每种下沉量另算半圆尺寸。
|
|
602
|
+
填槽底 `--fab-well`,于是在底栏上边缘切出一道弧。
|
|
603
|
+
② **嵌**:按钮直径 68,圆心落在栏顶边线**下方 10px**(上露 24 / 下沉 44,距栏底还剩 12)。
|
|
604
|
+
不是齐边切一半(2026-10-22 用户:「不需要突出半圆 出来一部分即可 再下沉一些 注意保留间距」)。
|
|
556
605
|
③ **接触影**:`radial-gradient` 把暗环**锚定在 r=34(按钮半径)**上,
|
|
557
|
-
从 33.5px 起最暗、到 37px
|
|
606
|
+
从 33.5px 起最暗、到 37px 淡完,圆心同样与按钮同心。这是唯一能精确控制「暗在哪一圈」的写法 ——
|
|
558
607
|
`box-shadow: inset` 的暗边永远从元素边界向内,管不到元素外的那道槽。
|
|
559
|
-
几何全用相对量(`bottom:calc(100% -
|
|
608
|
+
几何全用相对量(`bottom:calc(100% - 44px)`、`top:-27px`),安全区变化时洞和按钮一起走。
|
|
560
609
|
|
|
561
610
|
**两页一致**:会话页不再把「+」收进栏内(2026-10-22 用户:「圆没有突出了 …两个效果要一致」)。
|
|
562
611
|
凸出的圆在列表页和对话页长得完全一样,只用 composer 的下内边距给它让出空间。 */
|
|
@@ -567,22 +616,21 @@ button.setrow{font-family:inherit}
|
|
|
567
616
|
接触影必须画在槽底**之上**,且暗环两端用硬锚点,
|
|
568
617
|
避免模糊半径把亮度峰值推到按钮外面(那正是前几版读成光晕的原因)。
|
|
569
618
|
|
|
570
|
-
**露出的槽宽只有 3px
|
|
571
|
-
正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。
|
|
572
|
-
洞的高也取 37 而不是更深:槽只要在栏顶边界内切出缺口,深了只是浪费。 */
|
|
619
|
+
**露出的槽宽只有 3px**(槽半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
|
|
620
|
+
正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。 */
|
|
573
621
|
.tabbar::before{content:"";position:absolute;z-index:1;pointer-events:none;
|
|
574
|
-
left:50%;top
|
|
575
|
-
width:74px;height:
|
|
576
|
-
|
|
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:切掉露在外面的那一截,只留栏内的弧 */
|
|
577
625
|
background:
|
|
578
|
-
radial-gradient(circle 37px at 50%
|
|
626
|
+
radial-gradient(circle 37px at 50% 50%,
|
|
579
627
|
var(--fab-notch) 33.5px,
|
|
580
628
|
var(--fab-notch) 34.5px,
|
|
581
629
|
color-mix(in srgb,var(--fab-notch) 35%,transparent) 36px,
|
|
582
630
|
transparent 37px),
|
|
583
631
|
var(--fab-well)}
|
|
584
632
|
.tabfab__b{position:absolute;z-index:2;
|
|
585
|
-
left:50%;bottom:calc(100% -
|
|
633
|
+
left:50%;bottom:calc(100% - 44px); /* 上露 24 / 下沉 44,距栏底 12 */
|
|
586
634
|
transform:translateX(-50%);
|
|
587
635
|
width:68px;height:68px;
|
|
588
636
|
display:flex;align-items:center;justify-content:center;
|
|
@@ -597,14 +645,14 @@ button.setrow{font-family:inherit}
|
|
|
597
645
|
.tabfab__b:active{transform:translateX(-50%) translateY(2px);
|
|
598
646
|
box-shadow:inset 0 -6px 12px -7px rgba(15,23,42,.56), inset 0 2px 3px -2px rgba(255,255,255,.26)}
|
|
599
647
|
.tabfab__b svg{display:block;stroke-width:2.4;transform:translateY(-1px)}
|
|
600
|
-
/* 会话页(2026-10-22
|
|
601
|
-
|
|
602
|
-
|
|
648
|
+
/* 会话页(2026-10-22 用户:「不要挤压对话框」「两个效果要一致」「圆可以更大」「再下沉一些 注意保留间距」):
|
|
649
|
+
底栏浮起后不占位,「+」在**两页都上露 24**(68 直径、圆心在栏顶下方 10),所以 composer 只需让出
|
|
650
|
+
「上露 24 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆弧上方 12px,不遮不压。
|
|
603
651
|
原来 composer 下留 60(= 上探 30 + 呼吸 30)再叠占位的 68 栏,屏幕底到输入框 162;
|
|
604
|
-
现在 56 +
|
|
605
|
-
56 必须与 .tabbar 的 min-height
|
|
652
|
+
现在 56 + 24 + 12 = 92,对话区多出 70px。
|
|
653
|
+
56 必须与 .tabbar 的 min-height 对齐、24 必须与 .tabfab__b 的下沉量对齐(改一处要一起改)。
|
|
606
654
|
**只叠一次**:safe-area 由这里一次性算进来,别再给 .composer 的基础 padding 加一遍。 */
|
|
607
|
-
#conv .composer{padding-bottom:calc(12px +
|
|
655
|
+
#conv .composer{padding-bottom:calc(12px + 24px + 56px + env(safe-area-inset-bottom))}
|
|
608
656
|
|
|
609
657
|
/* 组件挂载点:占满剩余高度 */
|
|
610
658
|
#conv{flex:1;min-height:0;display:flex}
|
|
@@ -852,8 +900,13 @@ button.setrow{font-family:inherit}
|
|
|
852
900
|
<svg width="18" height="18"><use href="#i-back"/></svg><span>返回</span>
|
|
853
901
|
</button>
|
|
854
902
|
<h1 class="launcher__title">magic-agent · 远程控制</h1>
|
|
855
|
-
<p class="launcher__desc"
|
|
903
|
+
<p class="launcher__desc">填一个名字和桌面地址,或扫码配对。连过的桌面会保存下来,可在任务页标题处切换。</p>
|
|
856
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>
|
|
857
910
|
<div class="field" id="field">
|
|
858
911
|
<label for="addr">桌面地址</label>
|
|
859
912
|
<textarea id="addr" rows="2" placeholder="http://192.168.1.5:8791/?token=…"
|
|
@@ -911,8 +964,6 @@ button.setrow{font-family:inherit}
|
|
|
911
964
|
<div class="history__list">
|
|
912
965
|
<div class="setcard">
|
|
913
966
|
<div class="setrow"><span class="setrow__l">设备</span><span class="setrow__v" id="mineDevice">—</span></div>
|
|
914
|
-
<!-- 环境(多连接):显式配置每台桌面的名字 / IP / 令牌,并在这里切换 -->
|
|
915
|
-
<button class="setrow" id="btnEnvPage"><span class="setrow__l">环境</span><span class="setrow__v" id="mineEnv">—</span></button>
|
|
916
967
|
<div class="setrow"><span class="setrow__l">主题</span>
|
|
917
968
|
<span class="seg" id="segTheme">
|
|
918
969
|
<button class="seg__b" data-v="system">系统</button>
|
|
@@ -949,12 +1000,15 @@ button.setrow{font-family:inherit}
|
|
|
949
1000
|
<button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
|
|
950
1001
|
</div>
|
|
951
1002
|
<div class="setcard">
|
|
952
|
-
<!--
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
<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>
|
|
958
1012
|
</div>
|
|
959
1013
|
<div class="setcard">
|
|
960
1014
|
<button class="setrow setrow--danger" id="btnLogout">断开连接</button>
|
|
@@ -966,40 +1020,18 @@ button.setrow{font-family:inherit}
|
|
|
966
1020
|
从「我的」那一行点进来。这里配的全都是**设备级偏好**(开关本身按会话,在会话页运行设置里)。
|
|
967
1021
|
**只有一套划分**:计划 + 7 个角色(writer / researcher / reviewer / coder / designer /
|
|
968
1022
|
director / builder),不再并列「6 个类别」那一层。
|
|
969
|
-
2026-10-22
|
|
970
|
-
|
|
971
|
-
|
|
1023
|
+
2026-10-22 用户:「两个触发就行了」—— 一行里**两个点击区**:
|
|
1024
|
+
左边(名字 + 规则描述)点开只改规则;右边(引擎值)点开只选引擎。
|
|
1025
|
+
不拆两张卡,也不把规则和引擎塞进同一个抽屉。 -->
|
|
972
1026
|
<div class="history" id="teamPage" hidden>
|
|
973
1027
|
<div class="history__top">
|
|
974
1028
|
<button class="topbar__icon" id="btnTeamBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
975
1029
|
<span class="history__title">团队模式</span>
|
|
976
1030
|
</div>
|
|
977
1031
|
<div class="history__list">
|
|
978
|
-
<div class="history__group"
|
|
1032
|
+
<div class="history__group">计划与 7 类子任务</div>
|
|
979
1033
|
<div class="setcard" id="teamRouteCard"></div>
|
|
980
|
-
<div class="
|
|
981
|
-
<div class="setcard" id="teamRuleCard"></div>
|
|
982
|
-
<div class="history__hint" style="text-align:left">两张卡分开配:上面只选引擎,下面只改规则。留空 = 跟随主引擎 / 使用内置规则。</div>
|
|
983
|
-
</div>
|
|
984
|
-
</div>
|
|
985
|
-
|
|
986
|
-
<!-- ══════════ 我的 → 环境(多连接:名字 / IP / 令牌)══════════
|
|
987
|
-
2026-10-22 用户:「环境没有地方配置 ip 和名字啊」。
|
|
988
|
-
环境不能只靠「连过才记得」:这里显式新建 / 改名 / 改地址 / 删,点一行就编辑。
|
|
989
|
-
地址填 IP:端口即可(不带 http:// 自动补),令牌可以写在地址的 ?token= 里,
|
|
990
|
-
也可以单独填下面那栏(显式令牌优先)。 -->
|
|
991
|
-
<div class="history" id="envPage" hidden>
|
|
992
|
-
<div class="history__top">
|
|
993
|
-
<button class="topbar__icon" id="btnEnvBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
994
|
-
<span class="history__title">环境</span>
|
|
995
|
-
</div>
|
|
996
|
-
<div class="history__list">
|
|
997
|
-
<div class="history__group">已配置的桌面</div>
|
|
998
|
-
<div class="setcard" id="envList"></div>
|
|
999
|
-
<div class="history__actions" style="padding:10px 0 0">
|
|
1000
|
-
<button class="newtask" id="btnEnvAdd" type="button"><svg width="18" height="18"><use href="#i-plus"/></svg><span>新建环境</span></button>
|
|
1001
|
-
</div>
|
|
1002
|
-
<div class="history__hint" style="text-align:left">环境 = 一台跑着 magic-client 的桌面。名字随你起(例如「公司 Mac」),地址填 IP:端口,例如 192.168.1.5:8791。保存后可在这里或任务页标题处切换。</div>
|
|
1034
|
+
<div class="history__hint" style="text-align:left">左边点规则,右边点引擎。规则留空 = 内置规则;引擎留空 = 跟随主引擎。</div>
|
|
1003
1035
|
</div>
|
|
1004
1036
|
</div>
|
|
1005
1037
|
|
|
@@ -1023,7 +1055,10 @@ button.setrow{font-family:inherit}
|
|
|
1023
1055
|
|
|
1024
1056
|
<!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
|
|
1025
1057
|
数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
|
|
1026
|
-
|
|
1058
|
+
**两级**(2026-10-22 用户定稿):
|
|
1059
|
+
一级 = 目录(空间 → 模块),**不画功能点** —— 一级只回答「这个空间有哪几块」;
|
|
1060
|
+
二级 = 点某个模块进去,看**该空间下所有模块**的需求列表(当前模块高亮)。
|
|
1061
|
+
点一行用既有的阅读器打开那篇 markdown。
|
|
1027
1062
|
⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
|
|
1028
1063
|
(用户 2026-10-22 定稿)。 -->
|
|
1029
1064
|
<div class="history" id="reqs" hidden>
|
|
@@ -1034,6 +1069,16 @@ button.setrow{font-family:inherit}
|
|
|
1034
1069
|
<div class="history__list" id="reqList"></div>
|
|
1035
1070
|
</div>
|
|
1036
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
|
+
|
|
1037
1082
|
<!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
|
|
1038
1083
|
<div class="history" id="discover" hidden>
|
|
1039
1084
|
<div class="history__top"><span class="history__title">发现</span></div>
|
|
@@ -1204,6 +1249,7 @@ button.setrow{font-family:inherit}
|
|
|
1204
1249
|
var subtitleEl = document.getElementById('subtitle');
|
|
1205
1250
|
var fieldEl = document.getElementById('field');
|
|
1206
1251
|
var addrEl = document.getElementById('addr');
|
|
1252
|
+
var connNameEl = document.getElementById('connName');
|
|
1207
1253
|
var errEl = document.getElementById('err');
|
|
1208
1254
|
var recentEl = document.getElementById('recent');
|
|
1209
1255
|
var recentListEl = document.getElementById('recentList');
|
|
@@ -1216,9 +1262,6 @@ button.setrow{font-family:inherit}
|
|
|
1216
1262
|
var taskSearchInput = document.getElementById('taskSearchInput');
|
|
1217
1263
|
var mineEl = document.getElementById('mine');
|
|
1218
1264
|
var mineDevice = document.getElementById('mineDevice');
|
|
1219
|
-
var mineEnv = document.getElementById('mineEnv');
|
|
1220
|
-
var envPageEl = document.getElementById('envPage');
|
|
1221
|
-
var envListEl = document.getElementById('envList');
|
|
1222
1265
|
var mineVersion = document.getElementById('mineVersion');
|
|
1223
1266
|
var segTheme = document.getElementById('segTheme');
|
|
1224
1267
|
var segFont = document.getElementById('segFont');
|
|
@@ -1227,6 +1270,9 @@ button.setrow{font-family:inherit}
|
|
|
1227
1270
|
var tabbarEl = document.getElementById('tabbar');
|
|
1228
1271
|
var reqsEl = document.getElementById('reqs');
|
|
1229
1272
|
var reqListEl = document.getElementById('reqList');
|
|
1273
|
+
var reqDetailEl = document.getElementById('reqDetail');
|
|
1274
|
+
var reqDetailTitle = document.getElementById('reqDetailTitle');
|
|
1275
|
+
var reqDetailListEl = document.getElementById('reqDetailList');
|
|
1230
1276
|
var discoverEl = document.getElementById('discover');
|
|
1231
1277
|
var teamPageEl = document.getElementById('teamPage');
|
|
1232
1278
|
var runSettingsPageEl = document.getElementById('runSettingsPage');
|
|
@@ -1259,7 +1305,7 @@ button.setrow{font-family:inherit}
|
|
|
1259
1305
|
/* 工作空间要跨重启记住(与引擎 / 模型同一口径):只放内存的话,App 重启后
|
|
1260
1306
|
「任务运行设置」里选的空间就回到默认,用户会以为没保存。 */
|
|
1261
1307
|
try { WS = localStorage.getItem('magic.ws') || ''; } catch (e) {}
|
|
1262
|
-
function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} }
|
|
1308
|
+
function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} schedulePushPrefs(); }
|
|
1263
1309
|
/* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
|
|
1264
1310
|
提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
|
|
1265
1311
|
var RUN_PREF = loadRunPref();
|
|
@@ -1366,6 +1412,7 @@ button.setrow{font-family:inherit}
|
|
|
1366
1412
|
try { localStorage.setItem('magic.team.split', TEAM.split || 'standard'); } catch (e) {}
|
|
1367
1413
|
try { localStorage.setItem('magic.team.splitcode', TEAM.splitCode ? '1' : '0'); } catch (e) {}
|
|
1368
1414
|
try { localStorage.setItem('magic.team.adaptive', TEAM.adaptive === false ? '0' : '1'); } catch (e) {}
|
|
1415
|
+
schedulePushPrefs();
|
|
1369
1416
|
paintTeamPref();
|
|
1370
1417
|
}
|
|
1371
1418
|
/* 这条会话的开关:本地记过就用本地;没记过 → 跟会话自己的运行模式(core 的 `mode:"team"`)。
|
|
@@ -1394,6 +1441,21 @@ button.setrow{font-family:inherit}
|
|
|
1394
1441
|
var LS_KEY = 'magic.remote.recent.v1'; // 旧版「最近连接」:只在首次升级时搬一次
|
|
1395
1442
|
var ACTIVE_ID = '';
|
|
1396
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
|
+
})();
|
|
1397
1459
|
|
|
1398
1460
|
function connHost(url) {
|
|
1399
1461
|
try { return new URL(url).host; }
|
|
@@ -1438,28 +1500,44 @@ button.setrow{font-family:inherit}
|
|
|
1438
1500
|
var next = {
|
|
1439
1501
|
id: id, url: t.url || '', token: t.token || '',
|
|
1440
1502
|
name: (t.name != null && t.name !== '') ? t.name : (old.name || ''),
|
|
1441
|
-
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 // 种出来的默认环境:改名/改地址后仍然认出它
|
|
1442
1505
|
};
|
|
1443
1506
|
list.unshift(next);
|
|
1444
1507
|
saveConns(list);
|
|
1445
1508
|
return next;
|
|
1446
1509
|
}
|
|
1447
|
-
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
|
+
}
|
|
1448
1518
|
function clearConns() {
|
|
1449
1519
|
saveConns([]);
|
|
1450
|
-
try {
|
|
1520
|
+
try {
|
|
1521
|
+
localStorage.removeItem(LS_KEY);
|
|
1522
|
+
localStorage.setItem('magic.remote.seedoff.v1', '1'); // 清空 = 明确不要默认环境
|
|
1523
|
+
} catch (e) {}
|
|
1451
1524
|
}
|
|
1452
1525
|
|
|
1453
1526
|
/* 当前连接:先按地址 + 令牌找;App 壳注入的连接还没落盘时给一条临时记录,
|
|
1454
|
-
这样标题与菜单里仍然看得见「现在连的是谁」。
|
|
1527
|
+
这样标题与菜单里仍然看得见「现在连的是谁」。
|
|
1528
|
+
⚠️ 壳里 BASE 可能是空串(同源):这时用 location.origin 当它的地址 ——
|
|
1529
|
+
否则这条「未命名」没有 URL,既存不下来也就没法编辑(2026-10-22 用户:
|
|
1530
|
+
「现在已有的未命名都编辑不了」)。 */
|
|
1531
|
+
function connUrlNow() { return BASE || (TOKEN ? location.origin : ''); }
|
|
1455
1532
|
function activeConn() {
|
|
1533
|
+
var url = connUrlNow();
|
|
1456
1534
|
var list = loadConns(), i;
|
|
1457
1535
|
for (i = 0; i < list.length; i++) {
|
|
1458
|
-
if (list[i].url ===
|
|
1536
|
+
if (list[i].url === url && String(list[i].token || '') === String(TOKEN || '')) return list[i];
|
|
1459
1537
|
}
|
|
1460
1538
|
for (i = 0; i < list.length; i++) if (ACTIVE_ID && list[i].id === ACTIVE_ID) return list[i];
|
|
1461
|
-
if (
|
|
1462
|
-
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: '' };
|
|
1463
1541
|
}
|
|
1464
1542
|
return null;
|
|
1465
1543
|
}
|
|
@@ -1468,7 +1546,219 @@ button.setrow{font-family:inherit}
|
|
|
1468
1546
|
if (!c) return '';
|
|
1469
1547
|
return c.name || c.app || c.host || connHost(c.url);
|
|
1470
1548
|
}
|
|
1471
|
-
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
|
+
}
|
|
1472
1762
|
|
|
1473
1763
|
/* 桌面 health 回来的应用名(app)补进当前连接:它比 host 更像「这台设备是谁」。 */
|
|
1474
1764
|
function noteActiveMeta(meta) {
|
|
@@ -1524,23 +1814,24 @@ button.setrow{font-family:inherit}
|
|
|
1524
1814
|
}
|
|
1525
1815
|
/* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
|
|
1526
1816
|
排队任务按令牌分别落库(见 QUEUE_KEY),旧环境那批留在原地,切回来再接着跑。 */
|
|
1817
|
+
/* 换环境 = 换一台桌面:会话 / 需求 / 我的设置全都属于那台机器。
|
|
1818
|
+
做法分两步:① 把当前这台的本机状态快照进它的槽,再把目标环境的槽写回来;
|
|
1819
|
+
② 记住「停在哪个环境」并整页重载 —— 重载后内存态从零初始化,按目标环境的
|
|
1820
|
+
连接与设置重建界面(会话、需求都是重载后从目标桌面现拉的)。
|
|
1821
|
+
⚠️ 不在这里手写「清哪些变量」:那种清单永远会漏(漏一个就带着上一台的数据)。 */
|
|
1527
1822
|
function switchEnv(c) {
|
|
1528
1823
|
if (!c) return;
|
|
1529
|
-
if (c.url ===
|
|
1824
|
+
if (c.url === connUrlNow() && String(c.token || '') === String(TOKEN || '')) {
|
|
1530
1825
|
toast('已在「' + (connLabel(c) || '当前环境') + '」');
|
|
1531
1826
|
return;
|
|
1532
1827
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
WS = '';
|
|
1541
|
-
saveWs();
|
|
1542
|
-
connect({ url: c.url, token: c.token, name: c.name, host: c.host || connHost(c.url) });
|
|
1543
|
-
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; }
|
|
1544
1835
|
}
|
|
1545
1836
|
function openEnvMenu() {
|
|
1546
1837
|
if (envMenuEl) { closeEnvMenu(); return; }
|
|
@@ -1557,7 +1848,7 @@ button.setrow{font-family:inherit}
|
|
|
1557
1848
|
var active = !!(cur && c.id === cur.id);
|
|
1558
1849
|
var b = document.createElement('button');
|
|
1559
1850
|
b.type = 'button';
|
|
1560
|
-
b.className = 'envmenu__item';
|
|
1851
|
+
b.className = 'envmenu__item' + (active ? ' is-active' : '');
|
|
1561
1852
|
var ico = document.createElement('span');
|
|
1562
1853
|
ico.className = 'envmenu__ico';
|
|
1563
1854
|
ico.appendChild(svgUse('#i-laptop', 20));
|
|
@@ -1575,29 +1866,29 @@ button.setrow{font-family:inherit}
|
|
|
1575
1866
|
var csub = (cname && cname !== chost) ? chost : '';
|
|
1576
1867
|
col.appendChild(elSpan('envmenu__s', (csub ? csub + ' · ' : '') + (active ? '当前连接' : '点击切换')));
|
|
1577
1868
|
b.appendChild(col);
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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);
|
|
1584
1877
|
b.onclick = function () { closeEnvMenu(); switchEnv(c); };
|
|
1585
1878
|
envMenuEl.appendChild(b);
|
|
1586
1879
|
});
|
|
1587
1880
|
envMenuEl.appendChild(elSpan('envmenu__sep', ''));
|
|
1588
|
-
/*
|
|
1589
|
-
|
|
1590
|
-
function menuAct(label, fn) {
|
|
1881
|
+
/* 只留一个「添加环境」:扫码在启动页,改名改链接走每行的铅笔图标。 */
|
|
1882
|
+
function menuAct(icon, label, fn) {
|
|
1591
1883
|
var b = document.createElement('button');
|
|
1592
1884
|
b.type = 'button';
|
|
1593
1885
|
b.className = 'envmenu__add';
|
|
1594
|
-
b.appendChild(svgUse(
|
|
1886
|
+
b.appendChild(svgUse(icon, 18));
|
|
1595
1887
|
b.appendChild(elSpan('', label));
|
|
1596
1888
|
b.onclick = function () { closeEnvMenu(); fn(); };
|
|
1597
1889
|
envMenuEl.appendChild(b);
|
|
1598
1890
|
}
|
|
1599
|
-
menuAct('
|
|
1600
|
-
menuAct('扫码或粘贴连接地址', function () { showLauncher(); });
|
|
1891
|
+
menuAct('#i-plus', '添加环境', function () { openEnvEditor(null); });
|
|
1601
1892
|
document.body.appendChild(envScrimEl);
|
|
1602
1893
|
document.body.appendChild(envMenuEl);
|
|
1603
1894
|
var btn = document.getElementById('btnEnvSwitch');
|
|
@@ -1608,53 +1899,25 @@ button.setrow{font-family:inherit}
|
|
|
1608
1899
|
btn.classList.add('is-open');
|
|
1609
1900
|
}
|
|
1610
1901
|
|
|
1611
|
-
/* ──
|
|
1612
|
-
2026-10-22
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
hideAll(); envPageEl.hidden = false; tabbarEl.hidden = true;
|
|
1616
|
-
ENV_BACK = from === 'tasks' ? 'tasks' : 'mine';
|
|
1617
|
-
renderEnvPage();
|
|
1618
|
-
}
|
|
1619
|
-
var ENV_BACK = 'mine'; // 环境页的返回目标:从任务页标题进来就回任务页,从「我的」进来就回「我的」
|
|
1620
|
-
function renderEnvPage() {
|
|
1621
|
-
if (!envListEl) return;
|
|
1622
|
-
envListEl.innerHTML = '';
|
|
1623
|
-
var list = loadConns();
|
|
1624
|
-
if (!list.length) {
|
|
1625
|
-
envListEl.appendChild(hEl('div', 'history__hint', '还没有配置环境。点下面的「新建环境」,填名字和 IP 即可。'));
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
var cur = activeConn();
|
|
1629
|
-
list.forEach(function (c) {
|
|
1630
|
-
var b = hEl('button', 'setrow');
|
|
1631
|
-
b.type = 'button';
|
|
1632
|
-
var main = hEl('span', 'setrow__main');
|
|
1633
|
-
main.appendChild(hEl('span', 'setrow__l', connLabel(c) || '未命名'));
|
|
1634
|
-
main.appendChild(hEl('span', 'setrow__sub',
|
|
1635
|
-
(c.host || connHost(c.url)) + (cur && c.id === cur.id ? ' · 当前连接' : '')));
|
|
1636
|
-
b.appendChild(main);
|
|
1637
|
-
b.appendChild(hEl('span', 'setrow__v', '编辑'));
|
|
1638
|
-
b.onclick = function () { openEnvEditor(c); };
|
|
1639
|
-
envListEl.appendChild(b);
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
1642
|
-
/* conn 为空 = 新建;否则编辑(名字、地址、令牌都能改,也能删)。 */
|
|
1902
|
+
/* ── 环境编辑(任务页标题菜单里的铅笔 / 添加环境):名字 / 链接 / 令牌 ──
|
|
1903
|
+
2026-10-22 用户:「我的中的环境配置就不需要了」—— 管理入口只留在标题菜单里,
|
|
1904
|
+
不再单独做一页。 */
|
|
1905
|
+
/* conn 为空 = 新建;否则编辑(名字、链接、令牌都能改,也能删)。 */
|
|
1643
1906
|
function openEnvEditor(conn) {
|
|
1644
1907
|
var isNew = !conn;
|
|
1645
|
-
openHostSheet(isNew ? '
|
|
1908
|
+
openHostSheet(isNew ? '添加环境' : '编辑环境', function (body) {
|
|
1646
1909
|
var nameEl = hEl('input', 'envfield');
|
|
1647
1910
|
nameEl.type = 'text';
|
|
1648
1911
|
nameEl.placeholder = '例如 公司 Mac';
|
|
1649
1912
|
nameEl.value = (conn && conn.name) || '';
|
|
1650
1913
|
var addrEl = hEl('input', 'envfield');
|
|
1651
1914
|
addrEl.type = 'text';
|
|
1652
|
-
|
|
1915
|
+
/* 「链接」= 桌面配对页给的那条地址,可整条粘进来(含 ?token=…)。 */
|
|
1916
|
+
addrEl.placeholder = 'http://192.168.1.5:8791/?token=…';
|
|
1653
1917
|
addrEl.setAttribute('autocapitalize', 'off');
|
|
1654
1918
|
addrEl.setAttribute('autocomplete', 'off');
|
|
1655
1919
|
addrEl.setAttribute('spellcheck', 'false');
|
|
1656
|
-
|
|
1657
|
-
addrEl.value = conn && conn.url ? String(conn.url).replace(/^http:\/\//i, '') : '';
|
|
1920
|
+
addrEl.value = (conn && conn.url) ? String(conn.url) : '';
|
|
1658
1921
|
var tokEl = hEl('input', 'envfield');
|
|
1659
1922
|
tokEl.type = 'text';
|
|
1660
1923
|
tokEl.placeholder = '可选,配对令牌';
|
|
@@ -1664,15 +1927,26 @@ button.setrow{font-family:inherit}
|
|
|
1664
1927
|
tokEl.value = (conn && conn.token) || '';
|
|
1665
1928
|
body.appendChild(hEl('label', 'envlabel', '名称'));
|
|
1666
1929
|
body.appendChild(nameEl);
|
|
1667
|
-
body.appendChild(hEl('label', 'envlabel', '
|
|
1930
|
+
body.appendChild(hEl('label', 'envlabel', '链接'));
|
|
1668
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);
|
|
1669
1943
|
body.appendChild(hEl('label', 'envlabel', '令牌'));
|
|
1670
1944
|
body.appendChild(tokEl);
|
|
1671
1945
|
var err = hEl('div', 'field__err');
|
|
1672
1946
|
err.hidden = true;
|
|
1673
1947
|
body.appendChild(err);
|
|
1674
1948
|
body.appendChild(hEl('div', 'envhint',
|
|
1675
|
-
'
|
|
1949
|
+
'链接从桌面配对页复制,可以带 ?token=…;令牌栏单独填时优先。'));
|
|
1676
1950
|
var acts = hEl('div', 'envacts');
|
|
1677
1951
|
var saveB = hEl('button', 'btn btn--filled', '保存');
|
|
1678
1952
|
var goB = hEl('button', 'btn btn--outlined', '保存并连接');
|
|
@@ -1690,7 +1964,7 @@ button.setrow{font-family:inherit}
|
|
|
1690
1964
|
err.hidden = true;
|
|
1691
1965
|
var raw = String(addrEl.value || '').trim();
|
|
1692
1966
|
var t = parseTarget(raw);
|
|
1693
|
-
if (!t) { err.textContent = '
|
|
1967
|
+
if (!t) { err.textContent = '请填链接,例如 http://192.168.1.5:8791/?token=…'; err.hidden = false; return null; }
|
|
1694
1968
|
var tok = String(tokEl.value || '').trim();
|
|
1695
1969
|
var hadParam = /[?&]token=/.test(raw);
|
|
1696
1970
|
if (tok) t.token = tok;
|
|
@@ -1702,7 +1976,7 @@ button.setrow{font-family:inherit}
|
|
|
1702
1976
|
return t;
|
|
1703
1977
|
}
|
|
1704
1978
|
function persist(t) {
|
|
1705
|
-
var wasActive = !!(conn && conn.url ===
|
|
1979
|
+
var wasActive = !!(conn && conn.url === connUrlNow() && String(conn.token || '') === String(TOKEN || ''));
|
|
1706
1980
|
var next = upsertConn(t);
|
|
1707
1981
|
/* 改了地址/令牌 = 换了一条记录:旧的那条要删掉,别留一条连不上的僵尸。 */
|
|
1708
1982
|
if (conn && conn.id !== next.id) removeConn(conn.id);
|
|
@@ -1719,9 +1993,7 @@ button.setrow{font-family:inherit}
|
|
|
1719
1993
|
switchEnv(next); // 改的正是当前这台:按新地址/令牌重连
|
|
1720
1994
|
return;
|
|
1721
1995
|
}
|
|
1722
|
-
renderEnvPage();
|
|
1723
1996
|
if (!historyEl.hidden) historyTitle.textContent = envLabel() || '任务';
|
|
1724
|
-
if (!mineEl.hidden && mineEnv) mineEnv.textContent = envLabel() || '—';
|
|
1725
1997
|
toast(isNew ? ('已添加「' + (next.name || next.host) + '」') : '已保存');
|
|
1726
1998
|
}
|
|
1727
1999
|
saveB.onclick = function () { var t = collect(); if (t) persist(t); };
|
|
@@ -1731,14 +2003,13 @@ button.setrow{font-family:inherit}
|
|
|
1731
2003
|
persist(t);
|
|
1732
2004
|
/* persist 内部遇到「改的是当前连接」已经自己重连了;这里只处理新增/改别的环境。 */
|
|
1733
2005
|
var c = findConn(connIdOf(t)) || findConn(connIdOf({ url: t.url, token: t.token }));
|
|
1734
|
-
if (c && !(c.url ===
|
|
2006
|
+
if (c && !(c.url === connUrlNow() && String(c.token || '') === String(TOKEN || ''))) switchEnv(c);
|
|
1735
2007
|
};
|
|
1736
2008
|
if (delB) {
|
|
1737
2009
|
delB.onclick = function () {
|
|
1738
2010
|
if (!window.confirm('删除环境「' + (connLabel(conn) || conn.host || '') + '」?')) return;
|
|
1739
2011
|
removeConn(conn.id);
|
|
1740
2012
|
closeHostSheet();
|
|
1741
|
-
renderEnvPage();
|
|
1742
2013
|
toast('已删除');
|
|
1743
2014
|
};
|
|
1744
2015
|
}
|
|
@@ -1773,7 +2044,7 @@ button.setrow{font-family:inherit}
|
|
|
1773
2044
|
}
|
|
1774
2045
|
|
|
1775
2046
|
/* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
|
|
1776
|
-
function hideAll() { launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; reqsEl.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; }
|
|
1777
2048
|
function setActive(name) {
|
|
1778
2049
|
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
1779
2050
|
b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
|
|
@@ -1789,6 +2060,14 @@ button.setrow{font-family:inherit}
|
|
|
1789
2060
|
hideAll(); historyEl.hidden = false; tabbarEl.hidden = false; setActive('tasks');
|
|
1790
2061
|
if (address != null) histSubtitle = address;
|
|
1791
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
|
+
}
|
|
1792
2071
|
loadSessions();
|
|
1793
2072
|
}
|
|
1794
2073
|
function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
|
|
@@ -1797,13 +2076,14 @@ button.setrow{font-family:inherit}
|
|
|
1797
2076
|
两者代价差三个数量级,绝不能混成一个动作。 */
|
|
1798
2077
|
function showReqs() {
|
|
1799
2078
|
hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
|
|
2079
|
+
/* 从二级退回来时不要还卡在详情态:回到底栏「需求」这一页 = 回到一级目录。 */
|
|
2080
|
+
REQ_DETAIL = null;
|
|
1800
2081
|
loadRequirements(false);
|
|
1801
2082
|
}
|
|
1802
2083
|
function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
|
|
1803
2084
|
function showMine() {
|
|
1804
2085
|
hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
|
|
1805
2086
|
mineDevice.textContent = histSubtitle || '本机';
|
|
1806
|
-
if (mineEnv) mineEnv.textContent = envLabel() || '—';
|
|
1807
2087
|
var ns = document.getElementById('notifyState');
|
|
1808
2088
|
if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
|
|
1809
2089
|
markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
|
|
@@ -1814,39 +2094,114 @@ button.setrow{font-family:inherit}
|
|
|
1814
2094
|
再补一次请求 —— 用户可能一连上就直接点「我的」,那时列表那次还没回来。 */
|
|
1815
2095
|
paintMineVersion();
|
|
1816
2096
|
refreshMineVersion();
|
|
2097
|
+
/* 顺带问一次「桌面端的包里带的是哪一版 APK」:有更新就把这一格显示成「升级 vX」。 */
|
|
2098
|
+
probeAppUpdate();
|
|
1817
2099
|
}
|
|
1818
2100
|
|
|
1819
|
-
/* ══════════
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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)→ 提示检查失败,不把「—」当成最新版本。 */
|
|
1826
2108
|
var SERVER_VERSION = '';
|
|
2109
|
+
var APK_LATEST = ''; // 桌面端 npm 包里带的 APK 版本(/desk/app);空 = 没包或还没查
|
|
2110
|
+
function shownVersion() { return APP_VERSION || SERVER_VERSION; }
|
|
1827
2111
|
function paintMineVersion() {
|
|
1828
2112
|
if (!mineVersion) return;
|
|
1829
|
-
|
|
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 () {});
|
|
1830
2135
|
}
|
|
1831
2136
|
function refreshMineVersion() {
|
|
1832
2137
|
if (!TOKEN) { paintMineVersion(); return; }
|
|
1833
2138
|
var hdr = { 'X-Magic-Token': TOKEN };
|
|
1834
|
-
var seq = CONN_SEQ; //
|
|
1835
|
-
|
|
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 })
|
|
1836
2145
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1837
|
-
.
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
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 + ',完成后点安装');
|
|
1847
2203
|
})
|
|
1848
|
-
.
|
|
1849
|
-
.catch(function () { paintMineVersion(); }); // 网络抖动:保留旧值,不把「—」又闪回来
|
|
2204
|
+
.catch(function () { paintMineVersion(); toast('检查更新失败,请确认桌面端已升级'); });
|
|
1850
2205
|
}
|
|
1851
2206
|
/* 「我的」→ 团队模式:整块配置进子页面(与插件列表同一套子页面范式)。 */
|
|
1852
2207
|
function showTeamPage() {
|
|
@@ -2408,6 +2763,9 @@ button.setrow{font-family:inherit}
|
|
|
2408
2763
|
CONN_SEQ++; // 上一台桌面还在飞的请求:回来时直接丢弃(见 loadSessions)
|
|
2409
2764
|
var c = upsertConn(target);
|
|
2410
2765
|
ACTIVE_ID = c.id;
|
|
2766
|
+
PREFS_PULLED = false; // 换了桌面:环境配置要重新从新桌面拉一次(见 pullPrefs)
|
|
2767
|
+
/* 记住「停在哪台」:下次打开(或切环境后重载)直接回到这里。 */
|
|
2768
|
+
try { localStorage.setItem(ACTIVE_ENV_KEY, c.url || target.url || ''); } catch (e) {}
|
|
2411
2769
|
showError('');
|
|
2412
2770
|
showHistory(c.host || '');
|
|
2413
2771
|
startPoll();
|
|
@@ -2419,8 +2777,10 @@ button.setrow{font-family:inherit}
|
|
|
2419
2777
|
CONN_SEQ++;
|
|
2420
2778
|
if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
|
|
2421
2779
|
convEl.innerHTML = '';
|
|
2780
|
+
saveEnvScope(envScopeId()); // 断开前把这台的本机状态留在它自己的槽里
|
|
2422
2781
|
BASE = ''; TOKEN = '';
|
|
2423
2782
|
ACTIVE_ID = '';
|
|
2783
|
+
try { localStorage.removeItem(ACTIVE_ENV_KEY); } catch (e) {} // 主动断开 = 下次回启动页
|
|
2424
2784
|
SERVER_VERSION = ''; // 版本号属于「那一条连接」:断开就清掉,避免下次连到别的机器还显示旧值
|
|
2425
2785
|
/* 排队任务**不跟着断连消失**(它们已经落库):只清内存里的等待态,下次连上由
|
|
2426
2786
|
restoreQueue 从磁盘接回来。串行门的 FIFO 也一并重置 —— 里面的 tail 是挂在一条
|
|
@@ -2429,7 +2789,8 @@ button.setrow{font-family:inherit}
|
|
|
2429
2789
|
ASK_WAITING = [];
|
|
2430
2790
|
SERIAL = {};
|
|
2431
2791
|
QUEUE_RESTORED = false;
|
|
2432
|
-
|
|
2792
|
+
/* 断开后回任务页的空态(不再回启动页):添加/切换环境都在标题菜单里。 */
|
|
2793
|
+
showHistory(null);
|
|
2433
2794
|
}
|
|
2434
2795
|
|
|
2435
2796
|
/* ══════════ 历史会话列表(GET /desk/sessions,字段同 core 的 --sessions)══════════ */
|
|
@@ -2499,16 +2860,26 @@ button.setrow{font-family:inherit}
|
|
|
2499
2860
|
TITLES = a[1] || {};
|
|
2500
2861
|
DW = (a[2] && a[2].defaultWorkspace) || '';
|
|
2501
2862
|
DW_READY = true;
|
|
2863
|
+
/* 环境配置以桌面端为准:连上后拉一次(见 pullPrefs)。只拉一次,
|
|
2864
|
+
后面本地的改动由 schedulePushPrefs 推回去。 */
|
|
2865
|
+
if (!PREFS_PULLED) pullPrefs();
|
|
2502
2866
|
SPACES_ROOT = (a[2] && a[2].workspacesRoot) || '';
|
|
2503
|
-
/* core
|
|
2504
|
-
|
|
2505
|
-
if (a[2] && a[2].version) SERVER_VERSION = a[2].version;
|
|
2867
|
+
/* core 版本不要从这里取:/desk/health.version 是插件启动时的缓存,可能是旧值
|
|
2868
|
+
(见 refreshMineVersion 的注释)。版本行打开时走 /desk/contract 拿实时值。 */
|
|
2506
2869
|
if (!mineEl.hidden) paintMineVersion();
|
|
2507
2870
|
/* core 能力位(feature.*):团队开关要不要显示就看它。连上桌面后才有,
|
|
2508
2871
|
所以「我的」那张卡片与运行设置里的那一行都要在这里刷一次。 */
|
|
2509
2872
|
CORE_FEATURES = ((a[2] && a[2].features) || []);
|
|
2510
2873
|
/* 连接的身份(app):连着哪台设备就记到哪条连接上,环境菜单里当类型标签用。 */
|
|
2511
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
|
+
}
|
|
2512
2883
|
/* 团队角色清单(2026-10-22):core 上报什么就画什么,客户端不硬编码角色名。
|
|
2513
2884
|
老 core 没有 teamRoles → 空数组 → paintTeamPref 用内置表兜底。 */
|
|
2514
2885
|
TEAM_ROLES = ((a[2] && a[2].teamRoles) || []).filter(function (r) {
|
|
@@ -2546,7 +2917,7 @@ button.setrow{font-family:inherit}
|
|
|
2546
2917
|
var TASK_QUERY = '';
|
|
2547
2918
|
var PINNED = [];
|
|
2548
2919
|
try { PINNED = JSON.parse(localStorage.getItem('magic.pinned') || '[]') || []; } catch (e) { PINNED = []; }
|
|
2549
|
-
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(); }
|
|
2550
2921
|
var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
|
|
2551
2922
|
var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
|
|
2552
2923
|
var DW_READY = false; // DW 是否已从 health 落定(空间键归一依赖它,见 restoreQueue)
|
|
@@ -2554,7 +2925,7 @@ button.setrow{font-family:inherit}
|
|
|
2554
2925
|
var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
|
|
2555
2926
|
var CUSTOM_SPACES = [];
|
|
2556
2927
|
try { CUSTOM_SPACES = JSON.parse(localStorage.getItem('magic.spaces') || '[]') || []; } catch (e) { CUSTOM_SPACES = []; }
|
|
2557
|
-
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(); }
|
|
2558
2929
|
/* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
|
|
2559
2930
|
⚠️ 会话的 workspace 为空时**不要**拿 DW 顶上(原写法 `s.workspace || DW`):
|
|
2560
2931
|
那样「默认(不指定)」与「默认目录名」会变成两行同名选项,一个落「任务」段、
|
|
@@ -2568,6 +2939,44 @@ button.setrow{font-family:inherit}
|
|
|
2568
2939
|
(LAST_SESSIONS || []).forEach(function (s) { add(s && s.workspace); });
|
|
2569
2940
|
return out;
|
|
2570
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
|
+
}
|
|
2571
2980
|
/* 会话标题:**以 core 给的 title 为准**(2026-10-15,用户「应该 magic-agent 统一归纳作为
|
|
2572
2981
|
会话标题」)。core 的 --sessions 已把「标题表覆盖 → prompt_head」合并成一个 title 字段,
|
|
2573
2982
|
所以这里不再自己拼优先级 —— 此前本文件是 `TITLES[id] || prompt_head`、PC 端是另一套
|
|
@@ -3209,6 +3618,10 @@ button.setrow{font-family:inherit}
|
|
|
3209
3618
|
var REQ_BUSY = false; // 「整理」进行中(防重复点击)
|
|
3210
3619
|
var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
|
|
3211
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(/\/+$/, ''); }
|
|
3212
3625
|
|
|
3213
3626
|
function reqFeatureRow(f) {
|
|
3214
3627
|
/* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
|
|
@@ -3243,17 +3656,27 @@ button.setrow{font-family:inherit}
|
|
|
3243
3656
|
return b;
|
|
3244
3657
|
}
|
|
3245
3658
|
|
|
3246
|
-
/*
|
|
3247
|
-
|
|
3248
|
-
|
|
3659
|
+
/* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
|
|
3660
|
+
一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
|
|
3661
|
+
结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
|
|
3662
|
+
idx = 排序后的位次(0 = 需求条数最多的那个)。 */
|
|
3663
|
+
function reqWorkspaceBlock(w, idx) {
|
|
3249
3664
|
var wrap = hEl('div', '');
|
|
3250
3665
|
var head = hEl('div', 'wsrow__head');
|
|
3251
3666
|
var key = w.id || w.dir || w.name || '';
|
|
3252
3667
|
var count = Number(w.feature_count) || 0;
|
|
3253
|
-
|
|
3668
|
+
/* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
|
|
3669
|
+
空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
|
|
3670
|
+
用户手动点过谁就按用户的来(REQ_OPEN 里有记录就不动它)。 */
|
|
3671
|
+
var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
|
|
3254
3672
|
var tog = document.createElement('button');
|
|
3255
|
-
tog.type = 'button'; tog.className = 'wsrow__open';
|
|
3673
|
+
tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
|
|
3256
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 + ' 条'));
|
|
3257
3680
|
/* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
|
|
3258
3681
|
if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
|
|
3259
3682
|
/* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
|
|
@@ -3281,55 +3704,141 @@ button.setrow{font-family:inherit}
|
|
|
3281
3704
|
err.style.color = 'var(--warning)';
|
|
3282
3705
|
wrap.appendChild(err);
|
|
3283
3706
|
}
|
|
3707
|
+
var shown = 0;
|
|
3284
3708
|
(w.modules || []).forEach(function (m) {
|
|
3285
|
-
/* 模块(大功能点)用一条小节标题,形状与「置顶 / 任务 / 空间」的 sechead 一致:
|
|
3286
|
-
名字 + 条数。未归类(name 为空)不标题、直接平铺 ——
|
|
3287
|
-
老数据是平铺目录,硬给它编个「未归类」反而像出了错。 */
|
|
3288
3709
|
var fs = (m.features || []);
|
|
3289
3710
|
if (!fs.length) return;
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
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);
|
|
3297
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
|
+
}
|
|
3298
3735
|
return wrap;
|
|
3299
3736
|
}
|
|
3300
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
|
+
|
|
3301
3785
|
function renderRequirements() {
|
|
3302
3786
|
if (!reqListEl) return;
|
|
3303
3787
|
var data = REQ_DATA;
|
|
3304
3788
|
if (!data) return;
|
|
3305
|
-
/*
|
|
3306
|
-
|
|
3789
|
+
/* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
|
|
3790
|
+
(用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
|
|
3791
|
+
也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
|
|
3792
|
+
这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
|
|
3793
|
+
拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
|
|
3307
3794
|
var all = (data && data.workspaces) || [];
|
|
3308
3795
|
var list = all.filter(function (w) {
|
|
3309
|
-
|
|
3310
|
-
|
|
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) || ''));
|
|
3311
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
|
+
}
|
|
3312
3829
|
var frag = document.createDocumentFragment();
|
|
3313
3830
|
if (!list.length) {
|
|
3314
|
-
/*
|
|
3315
|
-
后者要引导用户点「整理」,否则这一页看起来永远长不出东西。 */
|
|
3316
|
-
var pend = 0, uninit = 0;
|
|
3317
|
-
all.forEach(function (w) {
|
|
3318
|
-
pend += Number(w.pending) || 0;
|
|
3319
|
-
if (w.initialized === false) uninit++;
|
|
3320
|
-
});
|
|
3831
|
+
/* 一个空间都没有(会话全删了 / 还没聊过)。 */
|
|
3321
3832
|
frag.appendChild(hEl('div', 'history__hint',
|
|
3322
|
-
|
|
3323
|
-
? '
|
|
3324
|
-
:
|
|
3325
|
-
? '还没有需求。点空间右侧「整理」会先按空间目录生成一版需求基线;右上角「全部整理」一次处理所有空间。'
|
|
3326
|
-
: '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。')));
|
|
3833
|
+
REQ_ALIVE
|
|
3834
|
+
? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
|
|
3835
|
+
: '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
|
|
3327
3836
|
reqListEl.replaceChildren(frag);
|
|
3328
3837
|
return;
|
|
3329
3838
|
}
|
|
3330
|
-
/*
|
|
3331
|
-
|
|
3332
|
-
list.forEach(function (w) { frag.appendChild(reqWorkspaceBlock(w)); });
|
|
3839
|
+
/* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
|
|
3840
|
+
见 reqWorkspaceBlock 的 open 判定。 */
|
|
3841
|
+
list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
|
|
3333
3842
|
reqListEl.replaceChildren(frag);
|
|
3334
3843
|
/* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
|
|
3335
3844
|
if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
|
|
@@ -3339,7 +3848,7 @@ button.setrow{font-family:inherit}
|
|
|
3339
3848
|
if (!reqListEl) return;
|
|
3340
3849
|
if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
3341
3850
|
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
3342
|
-
fetch(BASE + '/desk/requirements', { headers: hdr })
|
|
3851
|
+
var pReq = fetch(BASE + '/desk/requirements', { headers: hdr })
|
|
3343
3852
|
.then(function (r) {
|
|
3344
3853
|
if (r.status === 501) {
|
|
3345
3854
|
/* 老 core 没有这个能力位:**说清楚**,不要给一页空白
|
|
@@ -3348,9 +3857,38 @@ button.setrow{font-family:inherit}
|
|
|
3348
3857
|
}
|
|
3349
3858
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3350
3859
|
return r.json();
|
|
3351
|
-
})
|
|
3352
|
-
|
|
3353
|
-
|
|
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: [] };
|
|
3354
3892
|
renderRequirements();
|
|
3355
3893
|
})
|
|
3356
3894
|
.catch(function (e) {
|
|
@@ -3389,7 +3927,8 @@ button.setrow{font-family:inherit}
|
|
|
3389
3927
|
}
|
|
3390
3928
|
|
|
3391
3929
|
/* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
|
|
3392
|
-
label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
|
|
3930
|
+
label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
|
|
3931
|
+
二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
|
|
3393
3932
|
function reqSetBusy(on, label) {
|
|
3394
3933
|
if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
|
|
3395
3934
|
else REQ_BUSY_LABEL = '全部整理';
|
|
@@ -3398,6 +3937,11 @@ button.setrow{font-family:inherit}
|
|
|
3398
3937
|
btn.disabled = !!on;
|
|
3399
3938
|
if (label !== undefined) btn.textContent = label;
|
|
3400
3939
|
}
|
|
3940
|
+
var dbtn = document.getElementById('btnReqDetailSync');
|
|
3941
|
+
if (dbtn) {
|
|
3942
|
+
dbtn.disabled = !!on;
|
|
3943
|
+
dbtn.textContent = on ? '整理中…' : '整理';
|
|
3944
|
+
}
|
|
3401
3945
|
Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
|
|
3402
3946
|
b.disabled = !!on;
|
|
3403
3947
|
b.textContent = on ? '整理中…' : '整理';
|
|
@@ -3444,6 +3988,9 @@ button.setrow{font-family:inherit}
|
|
|
3444
3988
|
var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
|
|
3445
3989
|
var key = w.dir || w.id || '';
|
|
3446
3990
|
if (!key || seen[key]) return false;
|
|
3991
|
+
/* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
|
|
3992
|
+
var dir = reqNormDir(w.dir);
|
|
3993
|
+
if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
|
|
3447
3994
|
seen[key] = true;
|
|
3448
3995
|
return true;
|
|
3449
3996
|
});
|
|
@@ -3496,6 +4043,13 @@ button.setrow{font-family:inherit}
|
|
|
3496
4043
|
step();
|
|
3497
4044
|
}
|
|
3498
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
|
+
});
|
|
3499
4053
|
|
|
3500
4054
|
function openHostSheet(title, build) {
|
|
3501
4055
|
closeHostSheet();
|
|
@@ -4225,6 +4779,7 @@ button.setrow{font-family:inherit}
|
|
|
4225
4779
|
localStorage.setItem('magic.engine', RUN_PREF.engine || '');
|
|
4226
4780
|
localStorage.setItem('magic.model', RUN_PREF.model || '');
|
|
4227
4781
|
} catch (e) {}
|
|
4782
|
+
schedulePushPrefs();
|
|
4228
4783
|
paintRunPref();
|
|
4229
4784
|
}
|
|
4230
4785
|
function paintRunPref() {
|
|
@@ -4257,14 +4812,13 @@ button.setrow{font-family:inherit}
|
|
|
4257
4812
|
var sum = document.getElementById('teamSummary');
|
|
4258
4813
|
if (sum) sum.textContent = custom ? (custom + ' 项已单独指定') : '跟随主引擎';
|
|
4259
4814
|
}
|
|
4260
|
-
/* 团队模式子页面 =
|
|
4261
|
-
|
|
4262
|
-
|
|
4815
|
+
/* 团队模式子页面 = **一张卡、每行两个触发点**(用户 2026-10-22:「两个触发就行了」):
|
|
4816
|
+
左边(名字 + 规则描述)→ 只改规则;右边(引擎值)→ 只选引擎。
|
|
4817
|
+
两块配置分属两个抽屉,但不必各占一整张卡。
|
|
4263
4818
|
签名含两侧文案与引擎:变了才重建,避免 5s 轮询时闪。 */
|
|
4264
4819
|
function paintTeamRoles(roles) {
|
|
4265
|
-
var
|
|
4266
|
-
|
|
4267
|
-
if (!routeCard || !ruleCard) return;
|
|
4820
|
+
var card = document.getElementById('teamRouteCard');
|
|
4821
|
+
if (!card) return;
|
|
4268
4822
|
var show = teamSupported();
|
|
4269
4823
|
var plan = (TEAM.routes || {}).plan || {};
|
|
4270
4824
|
var planSub = '拆解:' + ({ coarse: '少', standard: '标准', fine: '细' }[TEAM.split] || '标准')
|
|
@@ -4277,50 +4831,36 @@ button.setrow{font-family:inherit}
|
|
|
4277
4831
|
}).join(',');
|
|
4278
4832
|
if (sig === TEAM_ROLE_SIG) return;
|
|
4279
4833
|
TEAM_ROLE_SIG = sig;
|
|
4280
|
-
|
|
4281
|
-
ruleCard.innerHTML = '';
|
|
4834
|
+
card.innerHTML = '';
|
|
4282
4835
|
if (!show) return;
|
|
4283
|
-
|
|
4284
|
-
addTeamRouteRow(routeCard, 'plan', '计划任务');
|
|
4836
|
+
addTeamSplitRow(card, 'plan', '计划任务', planSub);
|
|
4285
4837
|
roles.forEach(function (r) {
|
|
4286
|
-
|
|
4287
|
-
});
|
|
4288
|
-
/* 下卡:只讲「规则」——计划是拆解口径,角色是「这类适合什么」。 */
|
|
4289
|
-
addTeamRuleRow(ruleCard, 'plan', '计划任务规则', planSub);
|
|
4290
|
-
roles.forEach(function (r) {
|
|
4291
|
-
var label = (TEAM_ROLE_LABELS[r.name] || r.name) + '规则';
|
|
4292
|
-
addTeamRuleRow(ruleCard, 'role:' + r.name, label, teamRoleDuty(r));
|
|
4838
|
+
addTeamSplitRow(card, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name, teamRoleDuty(r));
|
|
4293
4839
|
});
|
|
4294
4840
|
}
|
|
4295
|
-
/*
|
|
4296
|
-
|
|
4841
|
+
/* 一行两个点击区:左边改规则,右边选引擎。
|
|
4842
|
+
⚠️ 外层必须是 div —— button 不能套 button;`.setrow` 的边框/间距照常用在外层。 */
|
|
4843
|
+
function addTeamSplitRow(card, key, label, sub) {
|
|
4297
4844
|
var rt = (TEAM.routes || {})[key] || {};
|
|
4298
|
-
var
|
|
4299
|
-
|
|
4300
|
-
|
|
4845
|
+
var row = hEl('div', 'setrow setrow--split');
|
|
4846
|
+
var main = hEl('button', 'setrow__mainbtn');
|
|
4847
|
+
main.type = 'button';
|
|
4301
4848
|
main.appendChild(hEl('span', 'setrow__l', label));
|
|
4302
|
-
|
|
4303
|
-
|
|
4849
|
+
if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
|
|
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',
|
|
4304
4855
|
rt.engine ? (rt.engine + (rt.model ? ' · ' + qaModelText(rt.model) : '')) : '跟随主引擎'));
|
|
4305
|
-
|
|
4306
|
-
|
|
4856
|
+
v.appendChild(hEl('span', 'setrow__chev', '›'));
|
|
4857
|
+
v.onclick = function () {
|
|
4307
4858
|
openTeamEnginePicker(key,
|
|
4308
4859
|
String(key).indexOf('role:') === 0 ? (label + '交给谁') : '计划任务交给谁',
|
|
4309
4860
|
teamRoleKindOf(key));
|
|
4310
4861
|
};
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
/* 下卡的一行:名字 + 当前规则。点开 = 只改规则(不含引擎)。 */
|
|
4314
|
-
function addTeamRuleRow(card, key, label, sub) {
|
|
4315
|
-
var b = hEl('button', 'setrow');
|
|
4316
|
-
b.type = 'button';
|
|
4317
|
-
var main = hEl('span', 'setrow__main');
|
|
4318
|
-
main.appendChild(hEl('span', 'setrow__l', label));
|
|
4319
|
-
if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
|
|
4320
|
-
b.appendChild(main);
|
|
4321
|
-
b.appendChild(hEl('span', 'setrow__chev', '›'));
|
|
4322
|
-
b.onclick = function () { openTeamRuleSheet(key, label); };
|
|
4323
|
-
card.appendChild(b);
|
|
4862
|
+
row.appendChild(v);
|
|
4863
|
+
card.appendChild(row);
|
|
4324
4864
|
}
|
|
4325
4865
|
function teamRoleKindOf(key) {
|
|
4326
4866
|
if (String(key).indexOf('role:') !== 0) return 'plan';
|
|
@@ -4747,6 +5287,9 @@ button.setrow{font-family:inherit}
|
|
|
4747
5287
|
document.getElementById('btnConnect').onclick = function () {
|
|
4748
5288
|
var target = parseTarget(addrEl.value);
|
|
4749
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;
|
|
4750
5293
|
connect(target);
|
|
4751
5294
|
};
|
|
4752
5295
|
addrEl.addEventListener('input', function () { showError(''); });
|
|
@@ -4792,10 +5335,6 @@ button.setrow{font-family:inherit}
|
|
|
4792
5335
|
归属只在用户显式选空间时确定,不能让上一次的 WS 悄悄粘过来。 */
|
|
4793
5336
|
on('btnFabNew', function () { newConversation(); });
|
|
4794
5337
|
on('btnMineBack', function () { showHistory(); });
|
|
4795
|
-
/* 我的 → 环境(多连接):列表(点行编辑)+ 新建;返回回「我的」。 */
|
|
4796
|
-
on('btnEnvPage', function () { showEnvPage('mine'); });
|
|
4797
|
-
on('btnEnvBack', function () { if (ENV_BACK === 'tasks') showHistory(); else showMine(); });
|
|
4798
|
-
on('btnEnvAdd', function () { openEnvEditor(null); });
|
|
4799
5338
|
on('dscBrowser', function () { openBrowser(HOME_URL); });
|
|
4800
5339
|
on('dscApps', function () { openComingSheet('应用', '暂无可用应用。桌面端发布的应用会出现在这里。'); });
|
|
4801
5340
|
on('dscPlugins', function () { showPlugins(); });
|
|
@@ -4819,6 +5358,7 @@ button.setrow{font-family:inherit}
|
|
|
4819
5358
|
on('btnTeamBack', showMine);
|
|
4820
5359
|
on('btnLogout', disconnect);
|
|
4821
5360
|
on('btnUpgrade', reloadApp);
|
|
5361
|
+
on('btnMineVersion', checkAppUpdate);
|
|
4822
5362
|
if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
|
|
4823
5363
|
if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
|
|
4824
5364
|
if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
|
|
@@ -4880,12 +5420,13 @@ button.setrow{font-family:inherit}
|
|
|
4880
5420
|
scanEl.hidden = true;
|
|
4881
5421
|
}
|
|
4882
5422
|
document.getElementById('btnScanClose').onclick = stopScan;
|
|
4883
|
-
|
|
5423
|
+
/* 通用扫码:扫到就把原文交给回调(启动页用来直连,「添加环境」用来填链接)。 */
|
|
5424
|
+
function startScan(onResult, onError) {
|
|
5425
|
+
var fail = onError || function (m) { toast(m); };
|
|
4884
5426
|
if (!('BarcodeDetector' in window) || !navigator.mediaDevices) {
|
|
4885
|
-
|
|
5427
|
+
fail('当前环境不支持扫码,请把桌面给的地址粘贴进来。');
|
|
4886
5428
|
return;
|
|
4887
5429
|
}
|
|
4888
|
-
showError('');
|
|
4889
5430
|
scanEl.hidden = false;
|
|
4890
5431
|
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (stream) {
|
|
4891
5432
|
scanStream = stream;
|
|
@@ -4898,19 +5439,47 @@ button.setrow{font-family:inherit}
|
|
|
4898
5439
|
scanDetector.detect(scanVideo).then(function (codes) {
|
|
4899
5440
|
if (!codes || !codes.length) return;
|
|
4900
5441
|
stopScan();
|
|
4901
|
-
|
|
4902
|
-
var target = parseTarget(raw);
|
|
4903
|
-
if (target) connect(target);
|
|
4904
|
-
else showError('这个二维码不是 magic-agent 连接地址。');
|
|
5442
|
+
onResult(codes[0].rawValue || '');
|
|
4905
5443
|
}).catch(function () {});
|
|
4906
5444
|
}, 400);
|
|
4907
5445
|
}).catch(function () {
|
|
4908
5446
|
stopScan();
|
|
4909
|
-
|
|
5447
|
+
fail('无法启动相机,请改用粘贴地址的方式。');
|
|
4910
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); });
|
|
4911
5459
|
};
|
|
4912
5460
|
|
|
4913
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
|
+
}
|
|
4914
5483
|
|
|
4915
5484
|
/* ══════════ 首屏 ══════════
|
|
4916
5485
|
· 打包进 App(Capacitor):boot.js 注入 window.MAGIC_BASE / MAGIC_TOKEN → 直连会话;
|
|
@@ -4919,16 +5488,56 @@ button.setrow{font-family:inherit}
|
|
|
4919
5488
|
var injectedToken = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
|
|
4920
5489
|
var injectedBase = (window.MAGIC_BASE || '').replace(/\/+$/, '');
|
|
4921
5490
|
try {
|
|
4922
|
-
|
|
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)) {
|
|
4923
5516
|
BASE = injectedBase; TOKEN = injectedToken;
|
|
5517
|
+
/* 把当前这条(壳注入的)连接落成环境,名字默认「公司远程」——
|
|
5518
|
+
否则它只是一条改不了名、也留不住的「未命名」。 */
|
|
5519
|
+
seedCurrentConn();
|
|
4924
5520
|
showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
|
|
4925
5521
|
restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
|
|
4926
|
-
} else {
|
|
4927
|
-
|
|
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
|
+
}
|
|
4928
5537
|
}
|
|
4929
5538
|
} catch (e) {
|
|
4930
|
-
/*
|
|
4931
|
-
try {
|
|
5539
|
+
/* 任何初始化异常都退回任务页空态,绝不留白屏。 */
|
|
5540
|
+
try { showHistory(null); } catch (e2) { try { showLauncher(); } catch (e3) {} }
|
|
4932
5541
|
}
|
|
4933
5542
|
|
|
4934
5543
|
/* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════
|