tt-help-cli-ycl 1.4.74 → 1.4.76

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.74",
3
+ "version": "1.4.76",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,3 +1,9 @@
1
+ // === 2026-08-07 变更 ===
2
+ // 1. renderWatchdogDashboard: 客户端按名称排序(在线优先,离线排最后)
3
+ // 2. renderClientCard / renderOfflineCard: 离线客户端红色标题+红色边框
4
+ // 3. 整体字体调大一号(12px→14px, 14px→16px, 10px→12px 等)
5
+ // 4. 新增 wdClearLlm() + 🧹 按钮(清除指定客户端 LLM 分析提示)
6
+ // 5. renderCmdRows 新增 llmAnalysis 参数,显示 🤖 AI 分析行
1
7
  const COLORS = [
2
8
  "#fe2c55",
3
9
  "#60a5fa",
@@ -3503,27 +3509,52 @@ function renderWatchdogDashboard(clients, clientCommands) {
3503
3509
  return;
3504
3510
  }
3505
3511
 
3512
+ // 排序:按名称升序,离线排最后
3513
+ var sortedClients = clients.slice().sort(function (a, b) {
3514
+ var nameA = (
3515
+ (a.config && a.config.userId) ||
3516
+ a.hostname ||
3517
+ ""
3518
+ ).toLowerCase();
3519
+ var nameB = (
3520
+ (b.config && b.config.userId) ||
3521
+ b.hostname ||
3522
+ ""
3523
+ ).toLowerCase();
3524
+ // 在线优先
3525
+ if (a.online !== b.online) return a.online ? -1 : 1;
3526
+ return nameA.localeCompare(nameB);
3527
+ });
3528
+
3506
3529
  // 构建 userId → 在线客户端 映射
3507
3530
  var userClients = {};
3508
- clients.forEach(function (c) {
3531
+ sortedClients.forEach(function (c) {
3509
3532
  var uid = (c.config && c.config.userId) || "";
3510
3533
  if (uid) userClients[uid] = c;
3511
3534
  });
3512
3535
 
3513
3536
  // 收集所有卡片:全部注册客户端 + 未上线但有命令配置的 userId
3514
3537
  var allCards = [];
3515
- clients.forEach(function (c) {
3538
+ sortedClients.forEach(function (c) {
3516
3539
  allCards.push(renderClientCard(c, clientCommands));
3517
3540
  });
3541
+ // 离线卡也按名称排序
3542
+ var offlineUids = [];
3518
3543
  for (var uid in clientCommands) {
3519
3544
  if (
3520
3545
  !userClients[uid] &&
3521
3546
  clientCommands[uid] &&
3522
3547
  clientCommands[uid].length > 0
3523
3548
  ) {
3524
- allCards.push(renderOfflineCard(uid, clientCommands[uid]));
3549
+ offlineUids.push(uid);
3525
3550
  }
3526
3551
  }
3552
+ offlineUids.sort(function (a, b) {
3553
+ return a.toLowerCase().localeCompare(b.toLowerCase());
3554
+ });
3555
+ offlineUids.forEach(function (uid) {
3556
+ allCards.push(renderOfflineCard(uid, clientCommands[uid]));
3557
+ });
3527
3558
 
3528
3559
  // 数据指纹:只有变化时才重建卡片 HTML
3529
3560
  var fingerprint = JSON.stringify(allCards);
@@ -3570,7 +3601,7 @@ function renderOfflineCard(userId, cmds) {
3570
3601
  var cmdCount = cmds ? cmds.length : 0;
3571
3602
  return styled(
3572
3603
  "div",
3573
- "background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:16px;opacity:0.9",
3604
+ "background:#1e1e2e;border:2px solid #f87171;border-radius:12px;padding:16px;opacity:0.9",
3574
3605
  styled(
3575
3606
  "div",
3576
3607
  "display:flex;justify-content:space-between;align-items:center;margin-bottom:8px",
@@ -3581,7 +3612,7 @@ function renderOfflineCard(userId, cmds) {
3581
3612
  "span",
3582
3613
  {
3583
3614
  style:
3584
- "color:#60a5fa;font-size:14px;font-weight:bold;cursor:pointer",
3615
+ "color:#f87171;font-size:16px;font-weight:bold;cursor:pointer",
3585
3616
  onclick: "wdShowConfigModal('" + esc(userId) + "')",
3586
3617
  title: "点击编辑客户端配置",
3587
3618
  },
@@ -3592,28 +3623,38 @@ function renderOfflineCard(userId, cmds) {
3592
3623
  {
3593
3624
  onclick: "wdShowConfigModal('" + esc(userId) + "')",
3594
3625
  style:
3595
- "padding:1px 5px;font-size:10px;background:transparent;color:#60a5fa;border:1px solid #333;border-radius:3px;cursor:pointer;margin-left:6px",
3626
+ "padding:1px 5px;font-size:12px;background:transparent;color:#f87171;border:1px solid #333;border-radius:3px;cursor:pointer;margin-left:6px",
3596
3627
  title: "编辑客户端配置",
3597
3628
  },
3598
3629
  "⚙️",
3599
3630
  ) +
3631
+ tag(
3632
+ "button",
3633
+ {
3634
+ onclick: "wdClearLlm('" + esc(userId) + "')",
3635
+ style:
3636
+ "padding:1px 5px;font-size:11px;background:transparent;color:#888;border:1px solid #333;border-radius:3px;cursor:pointer;margin-left:6px",
3637
+ title: "清除此客户端的 LLM 分析提示",
3638
+ },
3639
+ "🧹",
3640
+ ) +
3600
3641
  styled(
3601
3642
  "span",
3602
- "color:#555;font-size:11px;margin-left:8px",
3643
+ "color:#555;font-size:12px;margin-left:8px",
3603
3644
  "(未上线)",
3604
3645
  ),
3605
3646
  ) +
3606
3647
  styled(
3607
3648
  "div",
3608
3649
  "display:flex;gap:10px;align-items:center",
3609
- styled("span", "color:#f87171", "● 离线") +
3610
- styled("span", "color:#555;font-size:10px", "—") +
3650
+ styled("span", "color:#f87171;font-size:13px", "● 离线") +
3651
+ styled("span", "color:#555;font-size:12px", "—") +
3611
3652
  tag(
3612
3653
  "button",
3613
3654
  {
3614
3655
  onclick: "wdDeleteClient('" + esc(userId) + "')",
3615
3656
  style:
3616
- "padding:2px 8px;font-size:10px;background:#991b1b;color:#fff;border:none;border-radius:3px;cursor:pointer",
3657
+ "padding:2px 8px;font-size:12px;background:#991b1b;color:#fff;border:none;border-radius:3px;cursor:pointer",
3617
3658
  title: "删除此客户端配置",
3618
3659
  },
3619
3660
  "删除",
@@ -3622,7 +3663,7 @@ function renderOfflineCard(userId, cmds) {
3622
3663
  ) +
3623
3664
  styled(
3624
3665
  "div",
3625
- "color:#555;font-size:10px",
3666
+ "color:#555;font-size:12px",
3626
3667
  "该机器尚未连接看门狗,配置了 " + cmdCount + " 条命令",
3627
3668
  ) +
3628
3669
  renderCmdRows(cmds || [], userId, [], false),
@@ -3665,6 +3706,7 @@ function renderClientCard(c, clientCommands) {
3665
3706
  procs = c.processes || [];
3666
3707
  var userId = cfg.userId || "";
3667
3708
  var myCmds = userId && clientCommands[userId] ? clientCommands[userId] : [];
3709
+ var online = c.online;
3668
3710
 
3669
3711
  var osStr = [
3670
3712
  os.platform,
@@ -3683,11 +3725,15 @@ function renderClientCard(c, clientCommands) {
3683
3725
  (os.nodeVersion || "?") +
3684
3726
  " | 运行 " +
3685
3727
  (os.uptime != null ? Math.round(os.uptime / 3600) + "h" : "?");
3686
- var onlineBadge = c.online
3728
+ var onlineBadge = online
3687
3729
  ? styled("span", "color:#4ade80", "● 在线")
3688
3730
  : styled("span", "color:#f87171", "● 离线");
3689
3731
  var lastSeen = new Date(c.lastHeartbeat).toLocaleTimeString("zh-CN");
3690
3732
 
3733
+ // 离线时标题和边框变红
3734
+ var titleColor = online ? "#60a5fa" : "#f87171";
3735
+ var borderColor = online ? "#333" : "#f87171";
3736
+
3691
3737
  var header = styled(
3692
3738
  "div",
3693
3739
  "display:flex;justify-content:space-between;align-items:center;margin-bottom:8px",
@@ -3697,7 +3743,10 @@ function renderClientCard(c, clientCommands) {
3697
3743
  tag(
3698
3744
  "span",
3699
3745
  {
3700
- style: "color:#60a5fa;font-size:14px;font-weight:bold;cursor:pointer",
3746
+ style:
3747
+ "color:" +
3748
+ titleColor +
3749
+ ";font-size:16px;font-weight:bold;cursor:pointer",
3701
3750
  onclick: "wdShowConfigModal('" + esc(userId) + "')",
3702
3751
  title: "点击编辑客户端配置",
3703
3752
  },
@@ -3708,30 +3757,44 @@ function renderClientCard(c, clientCommands) {
3708
3757
  {
3709
3758
  onclick: "wdShowConfigModal('" + esc(userId) + "')",
3710
3759
  style:
3711
- "padding:1px 5px;font-size:10px;background:transparent;color:#60a5fa;border:1px solid #333;border-radius:3px;cursor:pointer;margin-left:6px",
3760
+ "padding:1px 5px;font-size:12px;background:transparent;color:" +
3761
+ titleColor +
3762
+ ";border:1px solid #333;border-radius:3px;cursor:pointer;margin-left:6px",
3712
3763
  title: "编辑客户端配置",
3713
3764
  },
3714
3765
  "⚙️",
3715
3766
  ) +
3767
+ (userId
3768
+ ? tag(
3769
+ "button",
3770
+ {
3771
+ onclick: "wdClearLlm('" + esc(userId) + "')",
3772
+ style:
3773
+ "padding:1px 5px;font-size:11px;background:transparent;color:#888;border:1px solid #333;border-radius:3px;cursor:pointer;margin-left:6px",
3774
+ title: "清除此客户端的 LLM 分析提示",
3775
+ },
3776
+ "🧹",
3777
+ )
3778
+ : "") +
3716
3779
  styled(
3717
3780
  "span",
3718
- "color:#555;font-size:10px;margin-left:8px",
3781
+ "color:#555;font-size:12px;margin-left:8px",
3719
3782
  c.hostname || "",
3720
3783
  ),
3721
3784
  ) +
3722
3785
  styled(
3723
3786
  "div",
3724
3787
  "display:flex;gap:10px;align-items:center",
3725
- styled("span", "color:#888;font-size:11px", c.ip) +
3788
+ styled("span", "color:#888;font-size:13px", c.ip) +
3726
3789
  onlineBadge +
3727
- styled("span", "color:#666;font-size:10px", lastSeen) +
3728
- (!c.online && userId
3790
+ styled("span", "color:#666;font-size:12px", lastSeen) +
3791
+ (!online && userId
3729
3792
  ? tag(
3730
3793
  "button",
3731
3794
  {
3732
3795
  onclick: "wdDeleteClient('" + esc(userId) + "')",
3733
3796
  style:
3734
- "padding:2px 8px;font-size:10px;background:#991b1b;color:#fff;border:none;border-radius:3px;cursor:pointer;margin-left:6px",
3797
+ "padding:2px 8px;font-size:12px;background:#991b1b;color:#fff;border:none;border-radius:3px;cursor:pointer;margin-left:6px",
3735
3798
  title: "删除此客户端配置",
3736
3799
  },
3737
3800
  "删除",
@@ -3742,11 +3805,13 @@ function renderClientCard(c, clientCommands) {
3742
3805
 
3743
3806
  return styled(
3744
3807
  "div",
3745
- "background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:16px",
3808
+ "background:#1e1e2e;border:2px solid " +
3809
+ borderColor +
3810
+ ";border-radius:12px;padding:16px",
3746
3811
  header +
3747
- styled("div", "color:#888;font-size:10px", osStr) +
3748
- styled("div", "color:#888;font-size:10px;margin-bottom:6px", infoLine) +
3749
- renderCmdRows(myCmds, userId, procs, c.online, c.llmAnalysis),
3812
+ styled("div", "color:#888;font-size:12px", osStr) +
3813
+ styled("div", "color:#888;font-size:12px;margin-bottom:6px", infoLine) +
3814
+ renderCmdRows(myCmds, userId, procs, online, c.llmAnalysis),
3750
3815
  );
3751
3816
  }
3752
3817
 
@@ -3761,7 +3826,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3761
3826
  "display:flex;justify-content:space-between;align-items:center;margin-bottom:6px",
3762
3827
  styled(
3763
3828
  "span",
3764
- "font-size:11px;color:#888",
3829
+ "font-size:13px;color:#888",
3765
3830
  "命令 (" + myCmds.length + "条)",
3766
3831
  ) +
3767
3832
  tag(
@@ -3769,7 +3834,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3769
3834
  {
3770
3835
  onclick: "wdShowAddModal('" + esc(userId) + "')",
3771
3836
  style:
3772
- "padding:4px 10px;background:#4ade80;color:#000;border:none;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600",
3837
+ "padding:4px 10px;background:#4ade80;color:#000;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:600",
3773
3838
  },
3774
3839
  "+ 添加",
3775
3840
  ),
@@ -3779,7 +3844,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3779
3844
  // 表头
3780
3845
  html += styled(
3781
3846
  "div",
3782
- "display:grid;grid-template-columns:1fr 50px 55px 55px 50px 80px;gap:4px;padding:4px 0;color:#666;font-size:9px;border-bottom:1px solid #222",
3847
+ "display:grid;grid-template-columns:1fr 50px 55px 55px 50px 80px;gap:4px;padding:4px 0;color:#666;font-size:11px;border-bottom:1px solid #222",
3783
3848
  "<span>命令行</span><span>PID</span><span>内存</span><span>运行</span><span>重启</span><span>操作</span>",
3784
3849
  );
3785
3850
 
@@ -3802,7 +3867,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3802
3867
  var prefix = "wd-" + esc(userId) + "-" + idx;
3803
3868
  html += styled(
3804
3869
  "div",
3805
- "display:grid;grid-template-columns:1fr 50px 55px 55px 50px 80px;gap:4px;padding:4px 0;align-items:center;font-size:10px;border-top:1px solid #1a1a2a",
3870
+ "display:grid;grid-template-columns:1fr 50px 55px 55px 50px 80px;gap:4px;padding:4px 0;align-items:center;font-size:12px;border-top:1px solid #1a1a2a",
3806
3871
  styled(
3807
3872
  "span",
3808
3873
  "color:" +
@@ -3818,7 +3883,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3818
3883
  (c.name
3819
3884
  ? styled(
3820
3885
  "span",
3821
- "color:#555;font-size:8px;margin-left:4px;font-family:monospace",
3886
+ "color:#555;font-size:10px;margin-left:4px;font-family:monospace",
3822
3887
  c.cmd.substring(0, 30) + (c.cmd.length > 30 ? "..." : ""),
3823
3888
  )
3824
3889
  : ""),
@@ -3856,7 +3921,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3856
3921
  onclick:
3857
3922
  "wdToggleClientCmd('" + esc(userId) + "'," + idx + ")",
3858
3923
  style:
3859
- "padding:1px 5px;font-size:9px;background:" +
3924
+ "padding:1px 5px;font-size:11px;background:" +
3860
3925
  (c.enabled ? "#f59e0b" : "#4ade80") +
3861
3926
  ";color:#000;border:none;border-radius:2px;cursor:pointer",
3862
3927
  title: c.enabled ? "停止" : "启动",
@@ -3869,7 +3934,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3869
3934
  {
3870
3935
  onclick: "wdShowEditModal('" + esc(userId) + "'," + idx + ")",
3871
3936
  style:
3872
- "padding:1px 5px;font-size:9px;background:#60a5fa;color:#fff;border:none;border-radius:2px;cursor:pointer",
3937
+ "padding:1px 5px;font-size:11px;background:#60a5fa;color:#fff;border:none;border-radius:2px;cursor:pointer",
3873
3938
  title: "编辑",
3874
3939
  },
3875
3940
  "✎",
@@ -3879,7 +3944,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3879
3944
  {
3880
3945
  onclick: "wdRemoveClientCmd('" + esc(userId) + "'," + idx + ")",
3881
3946
  style:
3882
- "padding:1px 5px;font-size:9px;background:#991b1b;color:#fff;border:none;border-radius:2px;cursor:pointer",
3947
+ "padding:1px 5px;font-size:11px;background:#991b1b;color:#fff;border:none;border-radius:2px;cursor:pointer",
3883
3948
  title: "删除",
3884
3949
  },
3885
3950
  "✕",
@@ -3902,7 +3967,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3902
3967
  : "";
3903
3968
  html += styled(
3904
3969
  "div",
3905
- "font-size:9px;color:#666;padding:1px 0 3px 8px;border-top:1px dashed #1a1a2a",
3970
+ "font-size:11px;color:#666;padding:1px 0 3px 8px;border-top:1px dashed #1a1a2a",
3906
3971
  styled("span", "color:" + sevColor, "🤖 " + ai.summary) + tip,
3907
3972
  );
3908
3973
  }
@@ -3915,7 +3980,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3915
3980
  var opre = "wd-" + esc(userId) + "-orphan-" + esc(String(oid));
3916
3981
  html += styled(
3917
3982
  "div",
3918
- "display:grid;grid-template-columns:1fr 50px 55px 55px 50px 80px;gap:4px;padding:4px 0;align-items:center;font-size:10px;border-top:1px solid #1a1a2a;opacity:0.5",
3983
+ "display:grid;grid-template-columns:1fr 50px 55px 55px 50px 80px;gap:4px;padding:4px 0;align-items:center;font-size:12px;border-top:1px solid #1a1a2a;opacity:0.5",
3919
3984
  styled(
3920
3985
  "span",
3921
3986
  "color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace",
@@ -3941,7 +4006,7 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3941
4006
  { style: "color:#555", "data-wd-key": opre + "-rst" },
3942
4007
  "0",
3943
4008
  ) +
3944
- styled("span", "color:#555;font-size:9px", "孤立"),
4009
+ styled("span", "color:#555;font-size:11px", "孤立"),
3945
4010
  );
3946
4011
  });
3947
4012
 
@@ -4173,6 +4238,32 @@ async function wdSaveClientCmds(userId, commands) {
4173
4238
  }
4174
4239
  }
4175
4240
 
4241
+ function wdClearLlm(userId) {
4242
+ if (
4243
+ !confirm(
4244
+ "清除 " +
4245
+ userId +
4246
+ " 的所有 LLM 分析提示?\n\n下次 agent 心跳或分析周期会自动重新生成。",
4247
+ )
4248
+ )
4249
+ return;
4250
+ wdShowSaving();
4251
+ fetch("/api/watchdog/clear-llm", {
4252
+ method: "POST",
4253
+ headers: { "Content-Type": "application/json" },
4254
+ body: JSON.stringify({ userId: userId }),
4255
+ })
4256
+ .then(function () {
4257
+ wdHideSaving();
4258
+ fetchWatchdogData();
4259
+ })
4260
+ .catch(function (e) {
4261
+ wdHideSaving();
4262
+ alert("清除失败: " + e.message);
4263
+ fetchWatchdogData();
4264
+ });
4265
+ }
4266
+
4176
4267
  function wdDeleteClient(userId) {
4177
4268
  if (
4178
4269
  !confirm(
@@ -9,6 +9,7 @@ body {
9
9
  background: #0f0f13;
10
10
  color: #e0e0e0;
11
11
  padding: 16px;
12
+ font-size: 14px;
12
13
  }
13
14
 
14
15
  .header {
@@ -65,13 +66,13 @@ body {
65
66
  }
66
67
 
67
68
  .stat-card .label {
68
- font-size: 12px;
69
+ font-size: 14px;
69
70
  color: #888;
70
71
  margin-bottom: 8px;
71
72
  }
72
73
 
73
74
  .stat-card .value {
74
- font-size: 28px;
75
+ font-size: 32px;
75
76
  font-weight: 700;
76
77
  }
77
78
 
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * 独立模块。每条命令是完整命令行字符串(如 "tt-help explore --location DE"),
5
5
  * 按 userId(~/.tt-help.json 的 userId 字段)分组存储。
6
+ *
7
+ * === 2026-08-07 变更 ===
8
+ * 1. 新增 llmCache 内存缓存,handleAnalyzeLogs 分析完成后写入缓存
9
+ * 2. getOnlineClients() 优先用缓存填充 llmAnalysis(不再依赖 agent 转发)
10
+ * 3. 新增 POST /api/watchdog/clear-llm 端点,手动清除指定客户端的 LLM 提示
6
11
  */
7
12
 
8
13
  import { readFileSync, writeFileSync, existsSync } from "fs";
@@ -17,6 +22,10 @@ const clients = new Map();
17
22
  const CLIENT_TIMEOUT_MS = 60 * 1000;
18
23
  const CLIENT_CLEANUP_MS = 24 * 60 * 60 * 1000; // 离线超过 24h 清理
19
24
 
25
+ // LLM 分析缓存:cmdId → { severity, summary, suggestions, cachedAt }
26
+ const llmCache = new Map();
27
+ const LLM_CACHE_TTL = 15 * 60 * 1000; // 15 分钟过期
28
+
20
29
  // ── 配置文件读写 ──
21
30
 
22
31
  function loadConfig() {
@@ -56,9 +65,24 @@ function getOnlineClients() {
56
65
  console.error(`[watchdog] 清理离线客户端: ${clientId}`);
57
66
  }
58
67
  }
68
+ // 清理过期缓存
69
+ for (const [cmdId, entry] of llmCache) {
70
+ if (now - entry.cachedAt > LLM_CACHE_TTL) llmCache.delete(cmdId);
71
+ }
59
72
  const result = [];
60
73
  for (const [clientId, info] of clients) {
61
74
  const online = now - info.lastHeartbeat < CLIENT_TIMEOUT_MS;
75
+ // 合并:agent 上报的 llmAnalysis + 服务端缓存(以服务端缓存为准,更新)
76
+ const processes = info.processes || [];
77
+ const cachedAnalysis = [];
78
+ for (const p of processes) {
79
+ if (p.cmdId && llmCache.has(p.cmdId)) {
80
+ cachedAnalysis.push(llmCache.get(p.cmdId));
81
+ }
82
+ }
83
+ // 优先用服务端缓存(更新),fallback 到 agent 上报的
84
+ const llmAnalysis =
85
+ cachedAnalysis.length > 0 ? cachedAnalysis : info.llmAnalysis || [];
62
86
  result.push({
63
87
  clientId,
64
88
  hostname: info.hostname || "unknown",
@@ -66,7 +90,8 @@ function getOnlineClients() {
66
90
  ttHelpVersion: info.ttHelpVersion || "unknown",
67
91
  osInfo: info.osInfo || {},
68
92
  config: info.config || {},
69
- processes: info.processes || [],
93
+ processes,
94
+ llmAnalysis,
70
95
  lastHeartbeat: info.lastHeartbeat,
71
96
  registeredAt: info.registeredAt,
72
97
  online,
@@ -286,6 +311,11 @@ async function handleAnalyzeLogs(params, body, res) {
286
311
  console.error(
287
312
  `[watchdog] LLM 分析完成: ${llmAnalysis.map((r) => r.severity).join(",")}`,
288
313
  );
314
+ // 写入服务端缓存,前端直接读取,不再依赖 agent 转发
315
+ const now = Date.now();
316
+ for (const item of llmAnalysis) {
317
+ llmCache.set(item.cmdId, { ...item, cachedAt: now });
318
+ }
289
319
  sendJSON(res, 200, { llmAnalysis });
290
320
  } catch (e) {
291
321
  console.error(`[watchdog] LLM 分析失败: ${e.message}`);
@@ -318,6 +348,35 @@ async function handleDeleteClient(params, body, res) {
318
348
  sendJSON(res, 200, { ok: true, removed });
319
349
  }
320
350
 
351
+ async function handleClearLlmAnalysis(params, body, res) {
352
+ const userId = body.userId;
353
+ if (!userId) {
354
+ sendJSON(res, 400, { error: "缺少 userId" });
355
+ return;
356
+ }
357
+ // 收集该 userId 下所有客户端进程的 cmdId,从缓存中清除
358
+ let cleared = 0;
359
+ for (const [clientId, info] of clients) {
360
+ const uid = (info.config && info.config.userId) || "";
361
+ if (uid !== userId) continue;
362
+ for (const p of info.processes || []) {
363
+ if (p.cmdId && llmCache.has(p.cmdId)) {
364
+ llmCache.delete(p.cmdId);
365
+ cleared++;
366
+ }
367
+ }
368
+ }
369
+ // 同时清除 agent 上报的 llmAnalysis(如果存在)
370
+ for (const [clientId, info] of clients) {
371
+ const uid = (info.config && info.config.userId) || "";
372
+ if (uid === userId && info.llmAnalysis) {
373
+ info.llmAnalysis = [];
374
+ }
375
+ }
376
+ console.error(`[watchdog] ${userId} LLM 分析已清除(${cleared}条缓存)`);
377
+ sendJSON(res, 200, { ok: true, cleared });
378
+ }
379
+
321
380
  // ── 公开 ──
322
381
 
323
382
  export function registerWatchdogRoutes(httpServer) {
@@ -379,5 +438,9 @@ export async function tryHandleWatchdogRoute(
379
438
  await handleDeleteClient(params, body, res);
380
439
  return true;
381
440
  }
441
+ if (method === "POST" && routePath === "/api/watchdog/clear-llm") {
442
+ await handleClearLlmAnalysis(params, body, res);
443
+ return true;
444
+ }
382
445
  return false;
383
446
  }
@@ -7,7 +7,12 @@
7
7
  * 3. 处理信号优雅退出
8
8
  */
9
9
 
10
- import { getSystemInfo, getClientConfig, getLocalIP } from "./reporter.js";
10
+ import {
11
+ getSystemInfo,
12
+ getClientConfig,
13
+ getLocalIP,
14
+ getClashHealth,
15
+ } from "./reporter.js";
11
16
  import { ProcessManager } from "./process-manager.js";
12
17
  import {
13
18
  getVersion,
@@ -16,12 +21,46 @@ import {
16
21
  startUpgradeChecker,
17
22
  } from "./upgrader.js";
18
23
  import { writeFileSync, existsSync, readFileSync } from "fs";
19
- import { join } from "path";
20
- import { homedir } from "os";
24
+ import { spawn } from "child_process";
25
+ import { join, dirname } from "path";
26
+ import { homedir, tmpdir } from "os";
27
+ import { fileURLToPath } from "url";
21
28
 
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
30
  const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
23
31
  const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
24
32
 
33
+ /** 自动发现 daemon.js 路径 */
34
+ function findDaemonPath() {
35
+ // 1. 从 ~/.tt-help.json 读取配置
36
+ try {
37
+ if (existsSync(TT_HELP_CONFIG)) {
38
+ const cfg = JSON.parse(readFileSync(TT_HELP_CONFIG, "utf-8"));
39
+ if (cfg.clashDaemonPath && existsSync(cfg.clashDaemonPath)) {
40
+ return cfg.clashDaemonPath;
41
+ }
42
+ }
43
+ } catch {}
44
+
45
+ // 2. 相对于 tiktok-cli 同级目录
46
+ const candidates = [
47
+ join(__dirname, "..", "..", "..", "clash-verge-explore", "daemon.js"),
48
+ join(__dirname, "..", "..", "..", "..", "clash-verge-explore", "daemon.js"),
49
+ join(process.cwd(), "..", "clash-verge-explore", "daemon.js"),
50
+ ];
51
+ for (const p of candidates) {
52
+ if (existsSync(p)) return p;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // daemon 路径(启动时解析一次)
58
+ let _daemonPath = null;
59
+ function getDaemonPath() {
60
+ if (_daemonPath === null) _daemonPath = findDaemonPath();
61
+ return _daemonPath;
62
+ }
63
+
25
64
  /** 将服务端下发的配置合并写回 ~/.tt-help.json */
26
65
  function applyClientConfig(newCfg) {
27
66
  try {
@@ -92,6 +131,11 @@ export class WatchdogAgent {
92
131
  this._heartbeatSeq = 0; // 心跳序号
93
132
  this._lastProcCount = -1; // 上次进程数
94
133
  this._lastCmdCount = -1; // 上次命令数
134
+ // clash daemon 集成
135
+ this.clashDaemon = null; // daemon 子进程引用
136
+ this.gracePeriodMs = 120_000; // 启动宽限期 2 分钟
137
+ this.startTime = 0; // 启动时间戳
138
+ this._graceEnded = false; // 宽限期是否已结束
95
139
  }
96
140
 
97
141
  /**
@@ -100,6 +144,7 @@ export class WatchdogAgent {
100
144
  async start() {
101
145
  if (this.running) return;
102
146
  this.running = true;
147
+ this.startTime = Date.now();
103
148
 
104
149
  console.error(
105
150
  `[watchdog] 启动 — 客户端: ${this.clientId}, 版本: ${this.ttHelpVersion}, 间隔: ${this.intervalMs / 1000}s`,
@@ -111,6 +156,9 @@ export class WatchdogAgent {
111
156
  // 注册信号处理
112
157
  this._setupSignalHandlers();
113
158
 
159
+ // 启动 clash daemon(保代理通道)
160
+ this._spawnClashDaemon();
161
+
114
162
  // 首次立即发送心跳
115
163
  await this._sendHeartbeat();
116
164
 
@@ -152,6 +200,8 @@ export class WatchdogAgent {
152
200
  this.upgradeChecker.stop();
153
201
  this.upgradeChecker = null;
154
202
  }
203
+ // 停止 clash daemon
204
+ this._killClashDaemon();
155
205
  this.processManager.stopAll();
156
206
  console.error("[watchdog] 看门狗已停止");
157
207
  }
@@ -178,6 +228,7 @@ export class WatchdogAgent {
178
228
  config: getClientConfig(),
179
229
  processes: this.processManager.getProcessesStatus(),
180
230
  llmAnalysis: this._llmResults,
231
+ clashHealth: getClashHealth(), // daemon 写入的代理健康状态
181
232
  };
182
233
  }
183
234
 
@@ -212,7 +263,7 @@ export class WatchdogAgent {
212
263
  // 同步命令
213
264
  if (Array.isArray(data.commands)) {
214
265
  const prev = this.processManager.runningCount;
215
- this.processManager.syncCommands(data.commands);
266
+ await this.processManager.syncCommands(data.commands);
216
267
  const now = this.processManager.runningCount;
217
268
  if (prev !== now) {
218
269
  console.error(
@@ -244,6 +295,29 @@ export class WatchdogAgent {
244
295
  console.error(
245
296
  `[watchdog] 心跳失败 (${this.consecutiveFailures}/${this.maxFailures}): ${e.message}`,
246
297
  );
298
+
299
+ // 启动宽限期:daemon 可能还在切节点,心跳失败不计入自杀计数
300
+ if (!this._graceEnded) {
301
+ const elapsed = Date.now() - this.startTime;
302
+ if (elapsed < this.gracePeriodMs) {
303
+ const remain = Math.round((this.gracePeriodMs - elapsed) / 1000);
304
+ // daemon 已报告健康 → 提前结束宽限期
305
+ const health = getClashHealth();
306
+ if (health && health.healthy) {
307
+ this._graceEnded = true;
308
+ console.error(`[watchdog] 代理已健康,宽限期提前结束`);
309
+ this.consecutiveFailures = 0;
310
+ return;
311
+ }
312
+ console.error(
313
+ `[watchdog] 宽限期内 (剩余${remain}s),等待 daemon 修复代理...`,
314
+ );
315
+ return;
316
+ }
317
+ this._graceEnded = true;
318
+ console.error(`[watchdog] 宽限期结束,恢复正常心跳失败计数`);
319
+ }
320
+
247
321
  if (this.consecutiveFailures >= this.maxFailures) {
248
322
  console.error("[watchdog] 连续心跳失败次数过多,看门狗退出");
249
323
  await this.stop();
@@ -315,6 +389,76 @@ export class WatchdogAgent {
315
389
  });
316
390
  }
317
391
 
392
+ /**
393
+ * 启动 clash daemon 子进程
394
+ * daemon 负责代理节点健康巡检和故障切换,watchdog 不阻塞等待
395
+ */
396
+ _spawnClashDaemon() {
397
+ const daemonPath = getDaemonPath();
398
+ if (!daemonPath) {
399
+ console.error("[watchdog] 未找到 clash daemon (daemon.js),跳过代理守护");
400
+ console.error(
401
+ "[watchdog] 提示: 在 ~/.tt-help.json 中设置 clashDaemonPath",
402
+ );
403
+ return;
404
+ }
405
+
406
+ console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
407
+ try {
408
+ const child = spawn(process.execPath, [daemonPath], {
409
+ stdio: ["ignore", "pipe", "pipe"],
410
+ detached: false,
411
+ });
412
+
413
+ child.stdout?.on("data", (d) => {
414
+ // daemon 日志通过 stderr 输出,stdout 静默
415
+ });
416
+
417
+ child.stderr?.on("data", (d) => {
418
+ const msg = d.toString().trim();
419
+ if (msg) {
420
+ console.error(`[clash-daemon] ${msg.substring(0, 300)}`);
421
+ }
422
+ });
423
+
424
+ child.on("exit", (code, signal) => {
425
+ console.error(
426
+ `[watchdog] clash daemon 退出 (code=${code}, signal=${signal})`,
427
+ );
428
+ this.clashDaemon = null;
429
+ });
430
+
431
+ child.on("error", (err) => {
432
+ console.error(`[watchdog] clash daemon 启动失败: ${err.message}`);
433
+ this.clashDaemon = null;
434
+ });
435
+
436
+ this.clashDaemon = child;
437
+ } catch (e) {
438
+ console.error(`[watchdog] clash daemon 启动异常: ${e.message}`);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 停止 clash daemon 子进程
444
+ */
445
+ _killClashDaemon() {
446
+ if (!this.clashDaemon) return;
447
+ try {
448
+ this.clashDaemon.kill("SIGTERM");
449
+ setTimeout(() => {
450
+ try {
451
+ if (this.clashDaemon && this.clashDaemon.exitCode === null) {
452
+ this.clashDaemon.kill("SIGKILL");
453
+ }
454
+ } catch {}
455
+ }, 3000);
456
+ } catch (e) {
457
+ console.error(`[watchdog] 停止 clash daemon 失败: ${e.message}`);
458
+ }
459
+ this.clashDaemon = null;
460
+ }
461
+
318
462
  /**
319
463
  * 信号处理
320
464
  */
@@ -16,9 +16,12 @@ import { writeFileSync, unlinkSync } from "fs";
16
16
  import { tmpdir } from "os";
17
17
  import { join } from "path";
18
18
 
19
+ // === 2026-08-07 变更 ===
20
+ // syncCommands 改为 async,批量启动时每个进程间隔 SPAWN_STAGGER_MS(30s),避免抢网络
19
21
  const MAX_RESTART = 3;
20
22
  const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
21
23
  const IS_WIN = process.platform === "win32";
24
+ const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
22
25
 
23
26
  // 从命令行中提取 CDP 端口列表(解析 --base-port 和 --port-count)
24
27
  function extractCdpPorts(cmdLine) {
@@ -385,9 +388,10 @@ export class ProcessManager extends EventEmitter {
385
388
  * 与期望命令列表同步:
386
389
  * - 启动期望列表中已启用但未运行的命令
387
390
  * - 停止正在运行但不在期望列表中的命令
391
+ * - 批量启动时每个进程间隔 SPAWN_STAGGER_MS,避免同时抢网络
388
392
  * @param {Array} expectedCmds - 服务端返回的命令列表,格式 [{cmd, maxUptimeSeconds, maxMemoryMB}]
389
393
  */
390
- syncCommands(expectedCmds) {
394
+ async syncCommands(expectedCmds) {
391
395
  if (!Array.isArray(expectedCmds)) return;
392
396
 
393
397
  // 标准化:使用服务端分配的 id,无 id 时用命令行截断兜底
@@ -428,12 +432,26 @@ export class ProcessManager extends EventEmitter {
428
432
  }
429
433
  }
430
434
 
431
- // 启动缺失的,并更新已有进程的阈值
435
+ // 收集需要新启动的命令
436
+ const toStart = normalized.filter((cmd) => !runningIds.has(cmd.id));
437
+
438
+ if (toStart.length > 1) {
439
+ console.error(
440
+ `[watchdog] 批量启动 ${toStart.length} 个命令(间隔 ${SPAWN_STAGGER_MS / 1000}s)...`,
441
+ );
442
+ }
443
+
444
+ // 启动缺失的,错峰延迟
445
+ for (let i = 0; i < toStart.length; i++) {
446
+ if (i > 0) {
447
+ await new Promise((r) => setTimeout(r, SPAWN_STAGGER_MS));
448
+ }
449
+ this.startCommand(toStart[i]);
450
+ }
451
+
452
+ // 更新已有进程的阈值(可能被管理员修改了)
432
453
  for (const cmd of normalized) {
433
- if (!runningIds.has(cmd.id)) {
434
- this.startCommand(cmd);
435
- } else {
436
- // 更新阈值(可能被管理员修改了)
454
+ if (runningIds.has(cmd.id)) {
437
455
  const info = this.processes.get(cmd.id);
438
456
  if (info) info.thresholds = cmd.thresholds;
439
457
  }
@@ -6,10 +6,11 @@
6
6
  import os from "os";
7
7
  import { existsSync, readFileSync } from "fs";
8
8
  import { join } from "path";
9
- import { homedir } from "os";
9
+ import { homedir, tmpdir } from "os";
10
10
 
11
11
  const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
12
12
  const CONFIG_PATH = join(homeDir, ".tt-help.json");
13
+ const CLASH_HEALTH_FILE = join(tmpdir(), "clash-health.json");
13
14
 
14
15
  /**
15
16
  * 获取系统信息
@@ -67,3 +68,25 @@ export function getLocalIP() {
67
68
  }
68
69
  return "127.0.0.1";
69
70
  }
71
+
72
+ /**
73
+ * 读取 clash daemon 写入的健康状态文件
74
+ * @returns {object|null} { node, healthy, httpCode, timeTotal, lastCheck, lastSwitch } 或 null
75
+ */
76
+ export function getClashHealth() {
77
+ try {
78
+ if (existsSync(CLASH_HEALTH_FILE)) {
79
+ const raw = readFileSync(CLASH_HEALTH_FILE, "utf-8");
80
+ const data = JSON.parse(raw);
81
+ // 检查数据是否过期(超过 2 分钟没更新视为 stale)
82
+ const age = Date.now() - new Date(data.lastCheck || 0).getTime();
83
+ if (age > 120_000) {
84
+ return { ...data, stale: true, ageSeconds: Math.round(age / 1000) };
85
+ }
86
+ return { ...data, stale: false };
87
+ }
88
+ } catch {
89
+ // 文件不存在或解析失败
90
+ }
91
+ return null;
92
+ }