tt-help-cli-ycl 1.4.21 → 1.4.23

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.
@@ -1623,3 +1623,157 @@ td.user-id:hover {
1623
1623
  pointer-events: none;
1624
1624
  z-index: 9999;
1625
1625
  }
1626
+
1627
+ /* ========== LLM 设置页 ========== */
1628
+ #settingsPage {
1629
+ display: none;
1630
+ }
1631
+
1632
+ #settingsPage.active {
1633
+ display: block;
1634
+ }
1635
+
1636
+ .settings-card {
1637
+ background: #1a1a24;
1638
+ border-radius: 12px;
1639
+ padding: 28px;
1640
+ max-width: 560px;
1641
+ margin: 0 auto;
1642
+ }
1643
+
1644
+ .settings-card h3 {
1645
+ font-size: 17px;
1646
+ color: #e0e0e0;
1647
+ margin-bottom: 6px;
1648
+ }
1649
+
1650
+ .settings-hint {
1651
+ font-size: 12px;
1652
+ color: #888;
1653
+ margin-bottom: 24px;
1654
+ }
1655
+
1656
+ .settings-form {
1657
+ display: flex;
1658
+ flex-direction: column;
1659
+ gap: 18px;
1660
+ }
1661
+
1662
+ .settings-field label {
1663
+ display: block;
1664
+ font-size: 13px;
1665
+ color: #aaa;
1666
+ margin-bottom: 6px;
1667
+ font-weight: 600;
1668
+ }
1669
+
1670
+ .settings-field input,
1671
+ .settings-field select {
1672
+ width: 100%;
1673
+ padding: 10px 14px;
1674
+ border: 1px solid #333;
1675
+ border-radius: 8px;
1676
+ background: #0f0f13;
1677
+ color: #e0e0e0;
1678
+ font-size: 14px;
1679
+ font-family: inherit;
1680
+ outline: none;
1681
+ transition: border-color 0.2s;
1682
+ }
1683
+
1684
+ .settings-field input:focus,
1685
+ .settings-field select:focus {
1686
+ border-color: #fe2c55;
1687
+ }
1688
+
1689
+ .settings-field input[readonly] {
1690
+ color: #888;
1691
+ cursor: default;
1692
+ }
1693
+
1694
+ .settings-field select {
1695
+ cursor: pointer;
1696
+ appearance: none;
1697
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
1698
+ background-repeat: no-repeat;
1699
+ background-position: right 12px center;
1700
+ padding-right: 36px;
1701
+ }
1702
+
1703
+ .settings-key-row {
1704
+ display: flex;
1705
+ gap: 6px;
1706
+ }
1707
+
1708
+ .settings-key-row input {
1709
+ flex: 1;
1710
+ }
1711
+
1712
+ .settings-key-row button {
1713
+ padding: 8px 12px;
1714
+ border: 1px solid #333;
1715
+ border-radius: 8px;
1716
+ background: #2a2a3a;
1717
+ color: #ccc;
1718
+ font-size: 16px;
1719
+ cursor: pointer;
1720
+ transition: background 0.2s;
1721
+ }
1722
+
1723
+ .settings-key-row button:hover {
1724
+ background: #333;
1725
+ }
1726
+
1727
+ .settings-note {
1728
+ display: block;
1729
+ font-size: 11px;
1730
+ color: #666;
1731
+ margin-top: 4px;
1732
+ }
1733
+
1734
+ .settings-status {
1735
+ padding: 10px 14px;
1736
+ border-radius: 8px;
1737
+ font-size: 13px;
1738
+ text-align: center;
1739
+ }
1740
+
1741
+ .settings-status.ok {
1742
+ background: rgba(74, 222, 128, 0.1);
1743
+ color: #4ade80;
1744
+ border: 1px solid rgba(74, 222, 128, 0.2);
1745
+ }
1746
+
1747
+ .settings-status.error {
1748
+ background: rgba(248, 113, 113, 0.1);
1749
+ color: #f87171;
1750
+ border: 1px solid rgba(248, 113, 113, 0.2);
1751
+ }
1752
+
1753
+ .settings-status.loading {
1754
+ background: rgba(250, 204, 21, 0.1);
1755
+ color: #facc15;
1756
+ border: 1px solid rgba(250, 204, 21, 0.2);
1757
+ }
1758
+
1759
+ .settings-form .btn-row {
1760
+ display: flex;
1761
+ justify-content: flex-end;
1762
+ margin-top: 4px;
1763
+ }
1764
+
1765
+ .settings-form .btn-submit {
1766
+ padding: 10px 28px;
1767
+ border: none;
1768
+ border-radius: 8px;
1769
+ background: #fe2c55;
1770
+ color: #fff;
1771
+ font-size: 14px;
1772
+ font-weight: 600;
1773
+ cursor: pointer;
1774
+ transition: background 0.2s;
1775
+ }
1776
+
1777
+ .settings-form .btn-submit:hover {
1778
+ background: #e61944;
1779
+ }
@@ -216,6 +216,16 @@ export function startWatchServer(
216
216
  return new Promise((_resolve, reject) => {
217
217
  const store = existingStore || createStore(dataAnchor, options);
218
218
 
219
+ // 初始化 LLM 动态配置(SQLite > 环境变量 > 默认值)
220
+ import("./llm-config.js").then((m) => m.loadLlmConfig());
221
+
222
+ // 启动时清理废弃的反思规则(避免 DB 膨胀)
223
+ import("./db-tags.js").then((m) => {
224
+ if (m.cleanupInactiveReflectionRules) {
225
+ m.cleanupInactiveReflectionRules();
226
+ }
227
+ });
228
+
219
229
  function logJob(action, detail) {
220
230
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
221
231
  const d = detail
@@ -983,6 +993,9 @@ export function startWatchServer(
983
993
  followerCount: u.followerCount || 0,
984
994
  topVideoPlayCount: u.topVideoPlayCount || null,
985
995
  topVideoHref: u.topVideoHref || null,
996
+ topVideoIsAd: u.topVideoIsAd ?? null,
997
+ recentEcVideos: u.recentEcVideos || null,
998
+ latestVideoTime: u.latestVideoTime || null,
986
999
  }));
987
1000
  sendJSON(res, 200, { total: targets.length, users });
988
1001
  }
@@ -1068,6 +1081,8 @@ export function startWatchServer(
1068
1081
  search: params.search || undefined,
1069
1082
  limit: parseInt(params.limit, 10),
1070
1083
  offset: parseInt(params.offset || "0", 10),
1084
+ sort: params.sort || undefined,
1085
+ sortOrder: params.sortOrder || "desc",
1071
1086
  },
1072
1087
  );
1073
1088
  // 确保每个用户对象包含 topVideoPlayCount、topVideoHref 和 topVideoIsAd
@@ -1253,6 +1268,55 @@ export function startWatchServer(
1253
1268
  return;
1254
1269
  }
1255
1270
 
1271
+ // ====== LLM 配置 API ======
1272
+ if (req.method === "GET" && routePath === "/api/llm-config") {
1273
+ try {
1274
+ const { getLlmConfig, LLM_PROVIDERS } =
1275
+ await import("./llm-config.js");
1276
+ sendJSON(res, 200, {
1277
+ ...getLlmConfig(),
1278
+ providers: LLM_PROVIDERS,
1279
+ });
1280
+ } catch (e) {
1281
+ sendJSON(res, 500, { error: e.message });
1282
+ }
1283
+ return;
1284
+ }
1285
+
1286
+ if (req.method === "POST" && routePath === "/api/llm-config") {
1287
+ try {
1288
+ const body = await readBody(req);
1289
+ const { updateLlmConfig, getLlmConfig, LLM_PROVIDERS } =
1290
+ await import("./llm-config.js");
1291
+ const result = updateLlmConfig({
1292
+ provider: body.provider,
1293
+ model: body.model,
1294
+ url: body.url,
1295
+ apiKey: body.apiKey,
1296
+ });
1297
+ sendJSON(res, 200, { ...result, providers: LLM_PROVIDERS });
1298
+ } catch (e) {
1299
+ sendJSON(res, 400, { error: e.message });
1300
+ }
1301
+ return;
1302
+ }
1303
+
1304
+ // GET /api/llm-test — 直接测试 LLM API 连通性
1305
+ if (req.method === "GET" && routePath === "/api/llm-test") {
1306
+ try {
1307
+ const { callLLM } = await import("./tag-service.js");
1308
+ const result = await callLLM("Say hello in exactly one word.", 0.1);
1309
+ sendJSON(res, 200, {
1310
+ ok: result.length > 0,
1311
+ reply: result || "(空)",
1312
+ replyLen: result.length,
1313
+ });
1314
+ } catch (e) {
1315
+ sendJSON(res, 500, { ok: false, error: e.message });
1316
+ }
1317
+ return;
1318
+ }
1319
+
1256
1320
  if (req.method === "POST" && routePath === "/api/attach-stuck/restore") {
1257
1321
  try {
1258
1322
  const body = await readBody(req);
@@ -1502,23 +1566,31 @@ export function startWatchServer(
1502
1566
  return;
1503
1567
  }
1504
1568
 
1505
- // GET /api/tags?status=&country=&limit=&offset=
1569
+ // GET /api/tags?status=&country=&search=&limit=&offset=
1506
1570
  if (req.method === "GET" && routePath === "/api/tags") {
1507
1571
  const status = params.status || null;
1508
1572
  const country = params.country || null;
1573
+ const search = params.search || null;
1509
1574
  const limit = Math.min(parseInt(params.limit) || 200, 500);
1510
1575
  const offset = parseInt(params.offset) || 0;
1511
1576
  const upperCountry = country ? country.toUpperCase() : null;
1577
+ const searchTerm = search ? search.trim().toLowerCase() : null;
1512
1578
 
1513
1579
  let tags;
1514
1580
  if (status) {
1515
- tags = store.getTagsByStatus(status, limit, offset, upperCountry);
1581
+ tags = store.getTagsByStatus(
1582
+ status,
1583
+ limit,
1584
+ offset,
1585
+ upperCountry,
1586
+ searchTerm,
1587
+ );
1516
1588
  } else {
1517
- tags = store.getAllTags(limit, offset, upperCountry);
1589
+ tags = store.getAllTags(limit, offset, upperCountry, searchTerm);
1518
1590
  }
1519
1591
 
1520
- // 获取总数(带国家过滤,按 status 返回对应计数)
1521
- const stats = store.getTagStats(upperCountry);
1592
+ // 获取总数(带搜索过滤,按 status 返回对应计数)
1593
+ const stats = store.getTagStats(upperCountry, searchTerm);
1522
1594
  let total;
1523
1595
  if (stats) {
1524
1596
  if (status === "new") total = stats.new;
@@ -1676,6 +1748,35 @@ export function startWatchServer(
1676
1748
  return;
1677
1749
  }
1678
1750
 
1751
+ // POST /api/tags/batch-add-if-new — 批量添加 tag(只插入不存在的,不覆盖已有)
1752
+ if (req.method === "POST" && routePath === "/api/tags/batch-add-if-new") {
1753
+ try {
1754
+ const body = await readBody(req);
1755
+ const { tags, countries, source } = body || {};
1756
+ if (!Array.isArray(tags) || tags.length === 0) {
1757
+ sendJSON(res, 400, { error: "tags 不能为空" });
1758
+ return;
1759
+ }
1760
+ if (!Array.isArray(countries) || countries.length === 0) {
1761
+ sendJSON(res, 400, { error: "countries 不能为空" });
1762
+ return;
1763
+ }
1764
+ const ret = store.insertTagsIfNew(tags, countries, source || "video");
1765
+ if (ret.error) {
1766
+ sendJSON(res, 500, { error: ret.error });
1767
+ return;
1768
+ }
1769
+ sendJSON(res, 200, {
1770
+ ok: true,
1771
+ inserted: ret.inserted,
1772
+ tags: ret.tags,
1773
+ });
1774
+ } catch (e) {
1775
+ sendJSON(res, 500, { error: e.message });
1776
+ }
1777
+ return;
1778
+ }
1779
+
1679
1780
  // GET /api/tags/health?country=ES — 自闭环:获取国家 tag 健康指标
1680
1781
  if (req.method === "GET" && routePath === "/api/tags/health") {
1681
1782
  try {
@@ -2179,7 +2280,9 @@ export function startWatchServer(
2179
2280
 
2180
2281
  if (
2181
2282
  req.method === "GET" &&
2182
- (routePath === "/" || routePath === "/index.html")
2283
+ (routePath === "/" ||
2284
+ routePath === "/index.html" ||
2285
+ routePath === "/settings")
2183
2286
  ) {
2184
2287
  // 设有密码时,检查登录态
2185
2288
  if (PASSWORD_ENABLED) {
@@ -23,8 +23,10 @@ export const COUNTRY_LANG = {
23
23
  AT: "de",
24
24
  };
25
25
 
26
- export const LLM_URL = "http://82.156.52.214:18000/v1/chat/completions";
27
- export const LLM_MODEL = "zc-fast";
26
+ // 旧常量保留兼容(外部可能引用),实际调用走 llm-config 动态配置
27
+ export const LLM_URL =
28
+ process.env.LLM_URL || "http://82.156.52.214:18000/v1/chat/completions";
29
+ export const LLM_MODEL = process.env.LLM_MODEL || "zc-fast";
28
30
 
29
31
  export function getLang(country) {
30
32
  return COUNTRY_LANG[country] || "en";
@@ -33,25 +35,74 @@ export function getLang(country) {
33
35
  // ====== LLM 调用 ======
34
36
 
35
37
  export async function callLLM(prompt, temperature = 0.7) {
36
- const apiKey = process.env.APIKEY || "";
37
- const { fetch } = await import("undici");
38
-
39
- const response = await fetch(LLM_URL, {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- Authorization: `Bearer ${apiKey}`,
44
- },
45
- body: JSON.stringify({
46
- model: LLM_MODEL,
47
- messages: [{ role: "user", content: prompt }],
48
- max_tokens: 1024,
49
- temperature,
50
- }),
51
- });
38
+ const { getLlmUrl, getLlmApiKey, isAutoMode, getAutoPool } =
39
+ await import("./llm-config.js");
40
+ const url = getLlmUrl();
41
+ const apiKey = getLlmApiKey();
42
+
43
+ // auto 模式:依次尝试候选池中的模型
44
+ const models = isAutoMode() ? getAutoPool() : [null];
45
+
46
+ let lastError = "";
47
+ for (let i = 0; i < models.length; i++) {
48
+ const model =
49
+ models[i] !== null
50
+ ? models[i]
51
+ : (await import("./llm-config.js")).getLlmModel();
52
+ const { fetch } = await import("undici");
53
+
54
+ console.error(
55
+ `[callLLM] → ${url} model=${model} (${i + 1}/${models.length})`,
56
+ );
52
57
 
53
- const result = await response.json();
54
- return result.choices?.[0]?.message?.content || "";
58
+ let response;
59
+ try {
60
+ response = await fetch(url, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Authorization: `Bearer ${apiKey}`,
65
+ },
66
+ body: JSON.stringify({
67
+ model,
68
+ messages: [{ role: "user", content: prompt }],
69
+ max_tokens: 1024,
70
+ temperature,
71
+ enable_thinking: false,
72
+ }),
73
+ });
74
+ } catch (e) {
75
+ lastError = e.message;
76
+ console.error(`[callLLM] ${model} 网络失败: ${e.message},尝试下一个`);
77
+ continue;
78
+ }
79
+
80
+ const status = response.status;
81
+ const result = await response.json().catch(() => null);
82
+
83
+ if (status !== 200 || !result || !result.choices?.[0]?.message?.content) {
84
+ const errMsg = result?.error?.message || `HTTP ${status}`;
85
+ lastError = errMsg;
86
+ console.error(`[callLLM] ${model} 失败: ${errMsg},尝试下一个`);
87
+ continue;
88
+ }
89
+
90
+ const content = result.choices[0].message.content;
91
+ if (i > 0) {
92
+ console.error(`[callLLM] ✅ auto 切换成功: ${model}`);
93
+ }
94
+ // 记录成功的模型,下次优先用它
95
+ if (isAutoMode()) {
96
+ const { recordAutoSuccess } = await import("./llm-config.js");
97
+ recordAutoSuccess(model);
98
+ }
99
+ return content;
100
+ }
101
+
102
+ console.error(
103
+ `[callLLM] ❌ 全部 ${models.length} 个模型均失败,最后错误: ${lastError}`,
104
+ );
105
+ return "";
55
106
  }
56
107
 
57
108
  export function normalizeTag(t) {
@@ -750,21 +801,31 @@ export async function discoverTagsForCountry(
750
801
  };
751
802
 
752
803
  let forceNicheSwitch = null;
753
- if (health && health.consecutiveLowProductiveRounds >= 2) {
754
- // 连续 ≥2 轮 productive=0,强制切换方向
755
- // 找出当前 productive tag 覆盖最多的 niche(即当前方向)
804
+ if (health && health.consecutiveLowProductiveRounds >= 3) {
805
+ // 连续 >=3 轮 productive=0,强制切换方向
806
+ // from = LLM 当前正在攻击的 niche(从 discover_log 追踪)
756
807
  const currentNiche =
757
- nicheRecommendation?.saturated?.[0]?.niche || "marketplace_local";
758
- // 找出覆盖最少的 niche 作为新方向
759
- const targetNiche = nicheRecommendation?.uncovered?.[0]?.niche || "food";
808
+ health.currentNiche ||
809
+ nicheRecommendation?.saturated?.[0]?.niche ||
810
+ "marketplace_local";
811
+
812
+ // to = uncovered 中排除 currentNiche 后的第一个
813
+ const uncoveredList = nicheRecommendation?.uncovered || [];
814
+ const nextUncovered = uncoveredList.find((n) => n.niche !== currentNiche);
815
+ // 如果所有 uncovered 都被排除(极端情况),回退到 uncovered 最后一个
816
+ const targetNiche =
817
+ nextUncovered?.niche ||
818
+ uncoveredList[uncoveredList.length - 1]?.niche ||
819
+ "food";
820
+
760
821
  forceNicheSwitch = {
761
822
  from: currentNiche,
762
823
  to: targetNiche,
763
- reason: `连续 ${health.consecutiveLowProductiveRounds} 轮无有效产出`,
824
+ reason: `连续 ${health.consecutiveLowProductiveRounds} 轮无有效产出,当前位置: ${currentNiche}`,
764
825
  description: NICHE_CATEGORIES[targetNiche] || targetNiche,
765
826
  };
766
827
  console.error(
767
- `[tag-service] 🔄 ${country} 强制方向切换: ${currentNiche} → ${targetNiche} (原因: 连续${health.consecutiveLowProductiveRounds}轮低效)`,
828
+ `[tag-service] 🔄 ${country} 强制方向切换: ${currentNiche} -> ${targetNiche} (原因: 连续${health.consecutiveLowProductiveRounds}轮低效, 当前niche=${health.currentNiche || "?"})`,
768
829
  );
769
830
  }
770
831
 
@@ -778,16 +839,17 @@ export async function discoverTagsForCountry(
778
839
  }
779
840
 
780
841
  // ====== 自闭环 Step 3: 动态温度调整 ======
842
+ // 策略:默认 0.7,卡死时升温打破 LLM 确定性重复
781
843
  let temperature = 0.7; // 默认
782
844
  if (health) {
783
- if (health.healthStatus === "critical") {
784
- temperature = 0.3; // 极度保守
845
+ if (health.consecutiveLowProductiveRounds >= 3) {
846
+ temperature = 1.0; // 最大随机性,强制探索新空间
847
+ } else if (health.consecutiveLowProductiveRounds >= 2) {
848
+ temperature = 0.9; // 较高随机性,打破模式
849
+ } else if (health.healthStatus === "critical") {
850
+ temperature = 0.5; // 有问题但还没卡死,略保守
785
851
  } else if (health.healthStatus === "warning") {
786
- temperature = 0.5; // 较保守
787
- }
788
- // 连续低效轮时进一步降温,让 LLM 更保守
789
- if (health.consecutiveLowProductiveRounds >= 2) {
790
- temperature = Math.min(temperature, 0.3);
852
+ temperature = 0.6;
791
853
  }
792
854
  }
793
855
 
@@ -903,6 +965,7 @@ export async function discoverTagsForCountry(
903
965
  country,
904
966
  round,
905
967
  strategy,
968
+ niche: niche || forceNicheSwitch?.to || null,
906
969
  tagsRaw: deduped,
907
970
  tagsGenerated: unique,
908
971
  tagsAdded: inserted.length,
@@ -1343,12 +1406,7 @@ export async function reflectAndLearn(store, country) {
1343
1406
  console.error(` [${rule.ruleType}] ${rule.ruleText}`);
1344
1407
  }
1345
1408
 
1346
- // Step 6: 废弃旧规则,写入新规则
1347
- const supersedeResult = supersedeRules(country, "new reflection round");
1348
- console.error(
1349
- `[reflection] ${country} 废弃 ${supersedeResult.superseded} 条旧规则`,
1350
- );
1351
-
1409
+ // Step 6: 废弃旧规则(带相似度去重),写入新规则
1352
1410
  const rulesToInsert = reflection.rules.map((r) => ({
1353
1411
  country,
1354
1412
  ruleType: r.ruleType,
@@ -1359,6 +1417,26 @@ export async function reflectAndLearn(store, country) {
1359
1417
  analysisSummary: reflection.analysisSummary,
1360
1418
  }));
1361
1419
 
1420
+ const supersedeResult = supersedeRules(
1421
+ country,
1422
+ rulesToInsert,
1423
+ "new reflection round",
1424
+ );
1425
+ if (supersedeResult.skipped) {
1426
+ console.error(
1427
+ `[reflection] ${country} 跳过反思(规则与上次高度相似),不写入新规则`,
1428
+ );
1429
+ return {
1430
+ reflected: true,
1431
+ rulesAdded: 0,
1432
+ analysis: reflection.analysisSummary,
1433
+ skipped: "rules too similar to previous reflection",
1434
+ };
1435
+ }
1436
+ console.error(
1437
+ `[reflection] ${country} 废弃 ${supersedeResult.superseded} 条旧规则`,
1438
+ );
1439
+
1362
1440
  const insertResult = insertReflectionRules(rulesToInsert);
1363
1441
  console.error(
1364
1442
  `[reflection] ${country} 写入 ${insertResult.inserted} 条新规则`,