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.
- package/package.json +1 -1
- package/src/cli/comments.js +63 -1
- package/src/cli/refresh.js +1 -1
- package/src/lib/parse-ssr.mjs +6 -0
- package/src/lib/tag-discover.js +8 -4
- package/src/lib/tag-fetcher.js +5 -0
- package/src/scraper/explore-core.js +14 -4
- package/src/watch/data-store.js +80 -1
- package/src/watch/db-columns.js +2 -0
- package/src/watch/db-schema.js +1 -0
- package/src/watch/db-tags.js +237 -17
- package/src/watch/llm-config.js +319 -0
- package/src/watch/llm-scoring.js +103 -63
- package/src/watch/public/app.js +248 -23
- package/src/watch/public/index.html +39 -0
- package/src/watch/public/style.css +154 -0
- package/src/watch/server.js +109 -6
- package/src/watch/tag-service.js +119 -41
package/src/watch/db-tags.js
CHANGED
|
@@ -50,11 +50,59 @@ export function insertTag(tag, countries, source = "llm") {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 批量插入 tag,只插入不存在的(INSERT OR IGNORE)。
|
|
55
|
+
* 用于非 LLM 来源(如视频页采集),不覆盖已有 tag 的任何字段。
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]} tags - 原始 tag 名列表(可带 # 前缀)
|
|
58
|
+
* @param {string[]} countries - 国家列表
|
|
59
|
+
* @param {string} source - 来源标识,默认 'video'
|
|
60
|
+
* @returns {{ inserted: number, tags: string[] }}
|
|
61
|
+
*/
|
|
62
|
+
export function insertTagsIfNew(tags, countries, source = "video") {
|
|
63
|
+
const db = getDb();
|
|
64
|
+
if (!db) return { inserted: 0, tags: [] };
|
|
65
|
+
if (!Array.isArray(tags) || tags.length === 0)
|
|
66
|
+
return { inserted: 0, tags: [] };
|
|
67
|
+
if (!Array.isArray(countries) || countries.length === 0)
|
|
68
|
+
return { inserted: 0, tags: [] };
|
|
69
|
+
|
|
70
|
+
const normalizedList = tags
|
|
71
|
+
.map((t) => t.replace(/^#+/, "").trim().toLowerCase())
|
|
72
|
+
.filter((t) => t.length >= 2);
|
|
73
|
+
|
|
74
|
+
if (normalizedList.length === 0) return { inserted: 0, tags: [] };
|
|
75
|
+
|
|
76
|
+
const countriesJson = JSON.stringify(countries);
|
|
77
|
+
let inserted = 0;
|
|
78
|
+
const insertedTags = [];
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const stmt = db.prepare(
|
|
82
|
+
`INSERT OR IGNORE INTO tags (tag, countries, source) VALUES (?, ?, ?)`,
|
|
83
|
+
);
|
|
84
|
+
const txn = db.transaction((items) => {
|
|
85
|
+
for (const tag of items) {
|
|
86
|
+
const result = stmt.run(tag, countriesJson, source);
|
|
87
|
+
if (result.changes > 0) {
|
|
88
|
+
inserted++;
|
|
89
|
+
insertedTags.push(tag);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
txn(normalizedList);
|
|
94
|
+
return { inserted, tags: insertedTags };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { inserted, error: e.message };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
53
100
|
export function getTagsByStatus(
|
|
54
101
|
status,
|
|
55
102
|
limit = 100,
|
|
56
103
|
offset = 0,
|
|
57
104
|
country = null,
|
|
105
|
+
search = null,
|
|
58
106
|
) {
|
|
59
107
|
const db = getDb();
|
|
60
108
|
if (!db) return [];
|
|
@@ -64,6 +112,10 @@ export function getTagsByStatus(
|
|
|
64
112
|
sql += " AND countries LIKE ?";
|
|
65
113
|
params.push(`%"${country}"%`);
|
|
66
114
|
}
|
|
115
|
+
if (search) {
|
|
116
|
+
sql += " AND tag LIKE ?";
|
|
117
|
+
params.push(`%${search}%`);
|
|
118
|
+
}
|
|
67
119
|
sql +=
|
|
68
120
|
" ORDER BY refreshed_at DESC NULLS LAST, score ASC, created_at ASC LIMIT ? OFFSET ?";
|
|
69
121
|
params.push(limit, offset);
|
|
@@ -71,21 +123,29 @@ export function getTagsByStatus(
|
|
|
71
123
|
return rows.map(parseTagRow);
|
|
72
124
|
}
|
|
73
125
|
|
|
74
|
-
export function getTagStats(country = null) {
|
|
126
|
+
export function getTagStats(country = null, search = null) {
|
|
75
127
|
const db = getDb();
|
|
76
128
|
if (!db) return null;
|
|
129
|
+
const conditions = [];
|
|
130
|
+
const params = [];
|
|
131
|
+
if (country) {
|
|
132
|
+
conditions.push("countries LIKE ?");
|
|
133
|
+
params.push(`%"${country}"%`);
|
|
134
|
+
}
|
|
135
|
+
if (search) {
|
|
136
|
+
conditions.push("tag LIKE ?");
|
|
137
|
+
params.push(`%${search}%`);
|
|
138
|
+
}
|
|
139
|
+
const whereClause =
|
|
140
|
+
conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
|
|
141
|
+
|
|
77
142
|
let sql = `SELECT
|
|
78
143
|
COUNT(*) as total,
|
|
79
144
|
SUM(CASE WHEN status = 'productive' THEN 1 ELSE 0 END) as productive,
|
|
80
145
|
SUM(CASE WHEN status = 'dead' THEN 1 ELSE 0 END) as dead,
|
|
81
146
|
SUM(CASE WHEN status = 'new' THEN 1 ELSE 0 END) as newCount,
|
|
82
147
|
SUM(CASE WHEN status = 'scoring' THEN 1 ELSE 0 END) as scoring
|
|
83
|
-
FROM tags`;
|
|
84
|
-
const params = [];
|
|
85
|
-
if (country) {
|
|
86
|
-
sql += " WHERE countries LIKE ?";
|
|
87
|
-
params.push(`%"${country}"%`);
|
|
88
|
-
}
|
|
148
|
+
FROM tags${whereClause}`;
|
|
89
149
|
const row = db.prepare(sql).get(...params);
|
|
90
150
|
// 获取所有出现过的国家
|
|
91
151
|
const allRows = db.prepare("SELECT countries FROM tags").all();
|
|
@@ -276,15 +336,28 @@ export function reportTagScore(tag, fields) {
|
|
|
276
336
|
}
|
|
277
337
|
}
|
|
278
338
|
|
|
279
|
-
export function getAllTags(
|
|
339
|
+
export function getAllTags(
|
|
340
|
+
limit = 200,
|
|
341
|
+
offset = 0,
|
|
342
|
+
country = null,
|
|
343
|
+
search = null,
|
|
344
|
+
) {
|
|
280
345
|
const db = getDb();
|
|
281
346
|
if (!db) return [];
|
|
282
347
|
let sql = "SELECT * FROM tags";
|
|
283
348
|
const params = [];
|
|
349
|
+
const conditions = [];
|
|
284
350
|
if (country) {
|
|
285
|
-
|
|
351
|
+
conditions.push("countries LIKE ?");
|
|
286
352
|
params.push(`%"${country}"%`);
|
|
287
353
|
}
|
|
354
|
+
if (search) {
|
|
355
|
+
conditions.push("tag LIKE ?");
|
|
356
|
+
params.push(`%${search}%`);
|
|
357
|
+
}
|
|
358
|
+
if (conditions.length > 0) {
|
|
359
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
360
|
+
}
|
|
288
361
|
sql +=
|
|
289
362
|
" ORDER BY refreshed_at DESC NULLS LAST, score DESC, created_at DESC LIMIT ? OFFSET ?";
|
|
290
363
|
params.push(limit, offset);
|
|
@@ -371,6 +444,7 @@ export function insertDiscoverLog({
|
|
|
371
444
|
country,
|
|
372
445
|
round,
|
|
373
446
|
strategy,
|
|
447
|
+
niche,
|
|
374
448
|
tagsGenerated,
|
|
375
449
|
tagsRaw,
|
|
376
450
|
tagsAdded,
|
|
@@ -382,18 +456,26 @@ export function insertDiscoverLog({
|
|
|
382
456
|
const db = getDb();
|
|
383
457
|
if (!db) return { ok: false, error: "db not ready" };
|
|
384
458
|
|
|
459
|
+
// 确保 niche 列存在(兼容旧表)
|
|
460
|
+
try {
|
|
461
|
+
db.prepare("ALTER TABLE discover_log ADD COLUMN niche TEXT").run();
|
|
462
|
+
} catch {
|
|
463
|
+
// 列已存在则忽略
|
|
464
|
+
}
|
|
465
|
+
|
|
385
466
|
try {
|
|
386
467
|
const result = db
|
|
387
468
|
.prepare(
|
|
388
469
|
`INSERT INTO discover_log
|
|
389
|
-
(country, round, strategy, tags_generated, tags_raw, tags_added,
|
|
470
|
+
(country, round, strategy, niche, tags_generated, tags_raw, tags_added,
|
|
390
471
|
productive_sample, dead_sample, avg_productive_score, avg_dead_score)
|
|
391
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
472
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
392
473
|
)
|
|
393
474
|
.run(
|
|
394
475
|
country,
|
|
395
476
|
round,
|
|
396
477
|
strategy || null,
|
|
478
|
+
niche || null,
|
|
397
479
|
JSON.stringify(tagsGenerated || []),
|
|
398
480
|
tagsRaw ? JSON.stringify(tagsRaw) : null,
|
|
399
481
|
tagsAdded || 0,
|
|
@@ -714,6 +796,9 @@ export function getCountryHealth(country) {
|
|
|
714
796
|
// 推荐的 niche 方向(基于现有 productive tag 的分类分析)
|
|
715
797
|
const nicheRecommendation = recommendNiche(country, productive);
|
|
716
798
|
|
|
799
|
+
// 追踪 LLM 当前在攻击哪个 niche(取最近3轮 discover_log 的 niche 字段,众数)
|
|
800
|
+
const currentNiche = getCurrentNiche(country);
|
|
801
|
+
|
|
717
802
|
return {
|
|
718
803
|
country,
|
|
719
804
|
total,
|
|
@@ -732,9 +817,43 @@ export function getCountryHealth(country) {
|
|
|
732
817
|
healthStatus,
|
|
733
818
|
issues,
|
|
734
819
|
nicheRecommendation,
|
|
820
|
+
currentNiche,
|
|
735
821
|
};
|
|
736
822
|
}
|
|
737
823
|
|
|
824
|
+
/**
|
|
825
|
+
* 获取 LLM 当前正在攻击的 niche(最近3轮 discover_log 的 niche 众数)
|
|
826
|
+
*/
|
|
827
|
+
function getCurrentNiche(country) {
|
|
828
|
+
const db = getDb();
|
|
829
|
+
if (!db) return null;
|
|
830
|
+
|
|
831
|
+
try {
|
|
832
|
+
const rows = db
|
|
833
|
+
.prepare(
|
|
834
|
+
`SELECT niche FROM discover_log
|
|
835
|
+
WHERE country = ? AND niche IS NOT NULL
|
|
836
|
+
ORDER BY round DESC
|
|
837
|
+
LIMIT 3`,
|
|
838
|
+
)
|
|
839
|
+
.all(country);
|
|
840
|
+
|
|
841
|
+
if (rows.length === 0) return null;
|
|
842
|
+
|
|
843
|
+
// 取出现次数最多的 niche
|
|
844
|
+
const counts = {};
|
|
845
|
+
for (const r of rows) {
|
|
846
|
+
const n = r.niche;
|
|
847
|
+
if (n) counts[n] = (counts[n] || 0) + 1;
|
|
848
|
+
}
|
|
849
|
+
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
850
|
+
return sorted.length > 0 ? sorted[0][0] : null;
|
|
851
|
+
} catch {
|
|
852
|
+
// niche 列尚不存在(旧表),返回 null
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
738
857
|
/**
|
|
739
858
|
* 基于现有 productive tag 推荐新的 niche 方向
|
|
740
859
|
* 分析已有 tag 的类别分布,找出未覆盖或覆盖不足的方向
|
|
@@ -1121,13 +1240,68 @@ export function insertReflectionRules(rules) {
|
|
|
1121
1240
|
return { ok: true, inserted: inserted.length };
|
|
1122
1241
|
}
|
|
1123
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* 计算两段规则文本的相似度(简单的词重叠率)
|
|
1245
|
+
* 返回 0-1,越高越相似
|
|
1246
|
+
*/
|
|
1247
|
+
function ruleTextSimilarity(a, b) {
|
|
1248
|
+
const wordsA = new Set(
|
|
1249
|
+
a
|
|
1250
|
+
.toLowerCase()
|
|
1251
|
+
.split(/\s+/)
|
|
1252
|
+
.filter((w) => w.length > 2),
|
|
1253
|
+
);
|
|
1254
|
+
const wordsB = new Set(
|
|
1255
|
+
b
|
|
1256
|
+
.toLowerCase()
|
|
1257
|
+
.split(/\s+/)
|
|
1258
|
+
.filter((w) => w.length > 2),
|
|
1259
|
+
);
|
|
1260
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
1261
|
+
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
|
1262
|
+
return intersection / Math.max(wordsA.size, wordsB.size);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1124
1265
|
/**
|
|
1125
1266
|
* 废弃旧规则(当新反思覆盖旧规则时)
|
|
1267
|
+
* 加入去重:如果新规则与旧规则语义相似(>70% 词重叠),跳过 supersede
|
|
1126
1268
|
*/
|
|
1127
|
-
export function supersedeRules(
|
|
1269
|
+
export function supersedeRules(
|
|
1270
|
+
country,
|
|
1271
|
+
newRules = [],
|
|
1272
|
+
reason = "new reflection",
|
|
1273
|
+
) {
|
|
1128
1274
|
const db = getDb();
|
|
1129
1275
|
if (!db) return { ok: false, error: "db not ready" };
|
|
1130
1276
|
|
|
1277
|
+
// 获取当前活跃规则
|
|
1278
|
+
const oldRules = db
|
|
1279
|
+
.prepare(
|
|
1280
|
+
"SELECT id, rule_text FROM reflection_rules WHERE country = ? AND is_active = 1",
|
|
1281
|
+
)
|
|
1282
|
+
.all(country);
|
|
1283
|
+
|
|
1284
|
+
// 规则去重:如果所有新规则与所有旧规则高度相似,跳过
|
|
1285
|
+
if (oldRules.length > 0 && newRules.length > 0) {
|
|
1286
|
+
let allSimilar = true;
|
|
1287
|
+
for (const nr of newRules) {
|
|
1288
|
+
const newText = typeof nr === "string" ? nr : nr.ruleText || "";
|
|
1289
|
+
const hasSimilar = oldRules.some(
|
|
1290
|
+
(or) => ruleTextSimilarity(newText, or.rule_text) > 0.7,
|
|
1291
|
+
);
|
|
1292
|
+
if (!hasSimilar) {
|
|
1293
|
+
allSimilar = false;
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (allSimilar) {
|
|
1298
|
+
console.error(
|
|
1299
|
+
`[reflection] 跳过 supersede: ${newRules.length} 条新规则与 ${oldRules.length} 条旧规则高度相似`,
|
|
1300
|
+
);
|
|
1301
|
+
return { ok: true, superseded: 0, skipped: "rules too similar" };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1131
1305
|
const result = db
|
|
1132
1306
|
.prepare(
|
|
1133
1307
|
`UPDATE reflection_rules
|
|
@@ -1141,13 +1315,45 @@ export function supersedeRules(country, reason = "new reflection") {
|
|
|
1141
1315
|
|
|
1142
1316
|
/**
|
|
1143
1317
|
* 检查是否需要触发反思
|
|
1144
|
-
* 条件:最近 N 轮 productive_cnt ≤ threshold
|
|
1318
|
+
* 条件:最近 N 轮 productive_cnt ≤ threshold,且距上次反思 ≥ cooldownRounds 轮
|
|
1145
1319
|
* @returns {{ shouldReflect: boolean, reason: string, recentRounds: array }}
|
|
1146
1320
|
*/
|
|
1147
|
-
export function shouldTriggerReflection(
|
|
1321
|
+
export function shouldTriggerReflection(
|
|
1322
|
+
country,
|
|
1323
|
+
lookback = 5,
|
|
1324
|
+
threshold = 1,
|
|
1325
|
+
cooldownRounds = 10,
|
|
1326
|
+
) {
|
|
1148
1327
|
const db = getDb();
|
|
1149
1328
|
if (!db) return { shouldReflect: false, reason: "db not ready" };
|
|
1150
1329
|
|
|
1330
|
+
// 冷却期检查:距上次反思不足 cooldownRounds 轮则跳过
|
|
1331
|
+
const lastReflection = db
|
|
1332
|
+
.prepare(
|
|
1333
|
+
`SELECT MAX(rr.created_at) as lastReflectAt
|
|
1334
|
+
FROM reflection_rules rr
|
|
1335
|
+
WHERE rr.country = ? AND rr.is_active = 0 AND rr.superseded_at IS NOT NULL
|
|
1336
|
+
ORDER BY rr.superseded_at DESC
|
|
1337
|
+
LIMIT 1`,
|
|
1338
|
+
)
|
|
1339
|
+
.get(country);
|
|
1340
|
+
|
|
1341
|
+
if (lastReflection?.lastReflectAt) {
|
|
1342
|
+
const roundsSince = db
|
|
1343
|
+
.prepare(
|
|
1344
|
+
`SELECT COUNT(*) as cnt FROM discover_log
|
|
1345
|
+
WHERE country = ? AND created_at > ?`,
|
|
1346
|
+
)
|
|
1347
|
+
.get(country, lastReflection.lastReflectAt);
|
|
1348
|
+
if ((roundsSince?.cnt || 0) < cooldownRounds) {
|
|
1349
|
+
return {
|
|
1350
|
+
shouldReflect: false,
|
|
1351
|
+
reason: `冷却期: 距上次反思仅${roundsSince?.cnt || 0}轮,需≥${cooldownRounds}轮`,
|
|
1352
|
+
recentRounds: [],
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1151
1357
|
// 获取最近 lookback 轮的 discover_log
|
|
1152
1358
|
const recentLogs = db
|
|
1153
1359
|
.prepare(
|
|
@@ -1202,14 +1408,14 @@ export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
|
|
|
1202
1408
|
}
|
|
1203
1409
|
|
|
1204
1410
|
const avgProductive = totalProductive / lookback;
|
|
1205
|
-
// 触发条件:平均 productive ≤ threshold,或连续 ≥
|
|
1411
|
+
// 触发条件:平均 productive ≤ threshold,或连续 ≥3 轮 productive ≤ 1
|
|
1206
1412
|
const shouldReflect =
|
|
1207
|
-
avgProductive <= threshold || consecutiveLowProductiveRounds >=
|
|
1413
|
+
avgProductive <= threshold || consecutiveLowProductiveRounds >= 3;
|
|
1208
1414
|
|
|
1209
1415
|
return {
|
|
1210
1416
|
shouldReflect,
|
|
1211
1417
|
reason: shouldReflect
|
|
1212
|
-
? consecutiveLowProductiveRounds >=
|
|
1418
|
+
? consecutiveLowProductiveRounds >= 3
|
|
1213
1419
|
? `连续${consecutiveLowProductiveRounds}轮productive≤1,需要反思换方向`
|
|
1214
1420
|
: `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} ≤ ${threshold}`
|
|
1215
1421
|
: `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} > ${threshold}`,
|
|
@@ -1222,6 +1428,20 @@ export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
|
|
|
1222
1428
|
};
|
|
1223
1429
|
}
|
|
1224
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* 清理所有已废弃的反思规则(is_active=0)
|
|
1433
|
+
*/
|
|
1434
|
+
export function cleanupInactiveReflectionRules() {
|
|
1435
|
+
const db = getDb();
|
|
1436
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
1437
|
+
|
|
1438
|
+
const result = db
|
|
1439
|
+
.prepare("DELETE FROM reflection_rules WHERE is_active = 0")
|
|
1440
|
+
.run();
|
|
1441
|
+
console.error(`[reflection] 🧹 清理了 ${result.changes} 条废弃规则`);
|
|
1442
|
+
return { ok: true, deleted: result.changes };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1225
1445
|
/**
|
|
1226
1446
|
* 获取反思所需的数据摘要(供 LLM 分析)
|
|
1227
1447
|
*/
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM 配置持久化模块
|
|
3
|
+
*
|
|
4
|
+
* 运行时动态读写 LLM 配置(Provider、Model、URL、APIKey),
|
|
5
|
+
* 优先从 SQLite 读取,fallback 到环境变量。
|
|
6
|
+
* 通过 API 修改后立即生效,无需重启。
|
|
7
|
+
*
|
|
8
|
+
* 结构:Provider(服务商) → Models(模型列表)
|
|
9
|
+
* 选择 Provider 自动填入对应 URL,选择 Model 使用对应 model id。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getDb } from "./db-schema.js";
|
|
13
|
+
|
|
14
|
+
// ====== Provider + Model 定义 ======
|
|
15
|
+
|
|
16
|
+
export const LLM_PROVIDERS = [
|
|
17
|
+
{
|
|
18
|
+
id: "legacy",
|
|
19
|
+
name: "旧 LLM (zc-fast)",
|
|
20
|
+
url: "http://82.156.52.214:18000/v1/chat/completions",
|
|
21
|
+
models: [{ id: "zc-fast", label: "zc-fast" }],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "dashscope",
|
|
25
|
+
name: "阿里云 DashScope",
|
|
26
|
+
url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
|
27
|
+
models: [
|
|
28
|
+
{ id: "auto", label: "🤖 自动(程序选择)" },
|
|
29
|
+
{ id: "qwen3.7-max", label: "Qwen3.7-Max" },
|
|
30
|
+
{ id: "qwen3.7-max-2026-06-08", label: "Qwen3.7-Max-2026-06-08" },
|
|
31
|
+
{ id: "qwen3.7-max-2026-05-20", label: "Qwen3.7-Max-2026-05-20" },
|
|
32
|
+
{ id: "qwen3.7-max-2026-05-17", label: "Qwen3.7-Max-2026-05-17" },
|
|
33
|
+
{ id: "qwen3.7-max-preview", label: "Qwen3.7-Max-Preview" },
|
|
34
|
+
{ id: "qwen3.7-plus", label: "Qwen3.7-Plus" },
|
|
35
|
+
{ id: "qwen3.7-plus-2026-05-26", label: "Qwen3.7-Plus-2026-05-26" },
|
|
36
|
+
{ id: "qwen3.6-plus", label: "Qwen3.6-Plus" },
|
|
37
|
+
{ id: "qwen3.6-plus-2026-04-02", label: "Qwen3.6-Plus-2026-04-02" },
|
|
38
|
+
{ id: "qwen3.6-max-preview", label: "Qwen3.6-Max-Preview" },
|
|
39
|
+
{ id: "qwen3.6-flash", label: "Qwen3.6-Flash" },
|
|
40
|
+
{ id: "qwen3.6-flash-2026-04-16", label: "Qwen3.6-Flash-2026-04-16" },
|
|
41
|
+
{ id: "qwen3.6-35b-a3b", label: "Qwen3.6-35B-A3B" },
|
|
42
|
+
{ id: "qwen3.6-27b", label: "Qwen3.6-27B" },
|
|
43
|
+
{ id: "qwen3.5-plus", label: "Qwen3.5-Plus" },
|
|
44
|
+
{ id: "qwen3.5-plus-2026-04-20", label: "Qwen3.5-Plus-2026-04-20" },
|
|
45
|
+
{ id: "qwen3.5-ocr", label: "Qwen3.5-OCR" },
|
|
46
|
+
{ id: "qwen-plus", label: "Qwen-Plus" },
|
|
47
|
+
{ id: "qwen-turbo", label: "Qwen-Turbo(即将下线)" },
|
|
48
|
+
{ id: "qwen-max", label: "Qwen-Max" },
|
|
49
|
+
{ id: "deepseek-v4-flash", label: "DeepSeek-V4-Flash" },
|
|
50
|
+
{ id: "deepseek-v4-pro", label: "DeepSeek-V4-Pro" },
|
|
51
|
+
{ id: "glm-5.2", label: "GLM-5.2" },
|
|
52
|
+
{ id: "kimi-k2.7-code", label: "Kimi-K2.7-Code" },
|
|
53
|
+
{ id: "kimi-k2.6", label: "Kimi-K2.6" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** 根据 providerId 查找 provider */
|
|
59
|
+
export function findProvider(providerId) {
|
|
60
|
+
return LLM_PROVIDERS.find((p) => p.id === providerId) || LLM_PROVIDERS[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 根据 providerId + modelId 查找模型 */
|
|
64
|
+
export function findModel(providerId, modelId) {
|
|
65
|
+
const provider = findProvider(providerId);
|
|
66
|
+
return provider.models.find((m) => m.id === modelId) || provider.models[0];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 兼容旧接口:扁平模型选项列表
|
|
70
|
+
export const LLM_MODEL_OPTIONS = LLM_PROVIDERS.flatMap((p) =>
|
|
71
|
+
p.models.map((m) => ({
|
|
72
|
+
id: m.id,
|
|
73
|
+
label: `${m.label} (${p.name})`,
|
|
74
|
+
url: p.url,
|
|
75
|
+
})),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// ====== 内存缓存 ======
|
|
79
|
+
|
|
80
|
+
let _cachedProvider = null;
|
|
81
|
+
let _cachedModel = null;
|
|
82
|
+
let _cachedUrl = null;
|
|
83
|
+
let _cachedApiKey = null;
|
|
84
|
+
let _loaded = false;
|
|
85
|
+
|
|
86
|
+
function ensureTable() {
|
|
87
|
+
const db = getDb();
|
|
88
|
+
if (!db) return;
|
|
89
|
+
db.prepare(
|
|
90
|
+
`CREATE TABLE IF NOT EXISTS _llm_config (
|
|
91
|
+
key TEXT PRIMARY KEY,
|
|
92
|
+
value TEXT NOT NULL
|
|
93
|
+
)`,
|
|
94
|
+
).run();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadFromDb() {
|
|
98
|
+
const db = getDb();
|
|
99
|
+
if (!db) return {};
|
|
100
|
+
ensureTable();
|
|
101
|
+
try {
|
|
102
|
+
const rows = db.prepare("SELECT key, value FROM _llm_config").all();
|
|
103
|
+
const config = {};
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
config[row.key] = row.value;
|
|
106
|
+
}
|
|
107
|
+
return config;
|
|
108
|
+
} catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function saveToDb(key, value) {
|
|
114
|
+
const db = getDb();
|
|
115
|
+
if (!db) return;
|
|
116
|
+
ensureTable();
|
|
117
|
+
db.prepare(
|
|
118
|
+
`INSERT INTO _llm_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
119
|
+
).run(key, String(value));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 加载配置(服务启动时调用一次)
|
|
124
|
+
* 优先级:SQLite > 环境变量 > 默认值
|
|
125
|
+
*/
|
|
126
|
+
export function loadLlmConfig() {
|
|
127
|
+
if (_loaded) return;
|
|
128
|
+
const dbConfig = loadFromDb();
|
|
129
|
+
|
|
130
|
+
_cachedUrl = dbConfig.llm_url || process.env.LLM_URL || LLM_PROVIDERS[0].url;
|
|
131
|
+
_cachedModel =
|
|
132
|
+
dbConfig.llm_model ||
|
|
133
|
+
process.env.LLM_MODEL ||
|
|
134
|
+
LLM_PROVIDERS[0].models[0].id;
|
|
135
|
+
_cachedProvider = dbConfig.llm_provider || LLM_PROVIDERS[0].id;
|
|
136
|
+
// 按 provider 隔离读取 API Key:优先 llm_apikey_{provider},兼容旧 llm_apikey,最后 env
|
|
137
|
+
_cachedApiKey =
|
|
138
|
+
dbConfig[`llm_apikey_${_cachedProvider}`] ||
|
|
139
|
+
dbConfig.llm_apikey ||
|
|
140
|
+
process.env.APIKEY ||
|
|
141
|
+
"";
|
|
142
|
+
_loaded = true;
|
|
143
|
+
|
|
144
|
+
console.error(
|
|
145
|
+
`[llm-config] 加载完成: provider=${_cachedProvider}, model=${_cachedModel}, url=${_cachedUrl}, hasKey=${!!_cachedApiKey}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 获取当前 LLM 配置(供前端 API 使用)
|
|
151
|
+
*/
|
|
152
|
+
export function getLlmConfig() {
|
|
153
|
+
if (!_loaded) loadLlmConfig();
|
|
154
|
+
return {
|
|
155
|
+
provider: _cachedProvider,
|
|
156
|
+
model: _cachedModel,
|
|
157
|
+
url: _cachedUrl,
|
|
158
|
+
hasApiKey: !!_cachedApiKey,
|
|
159
|
+
apiKeyHint: _cachedApiKey
|
|
160
|
+
? _cachedApiKey.slice(0, 6) + "..." + _cachedApiKey.slice(-4)
|
|
161
|
+
: "",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 获取当前 LLM URL(内部调用) */
|
|
166
|
+
export function getLlmUrl() {
|
|
167
|
+
if (!_loaded) loadLlmConfig();
|
|
168
|
+
return _cachedUrl;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Auto 模式的候选模型池缓存(从 DashScope provider 动态构建,排除 auto 自身)
|
|
172
|
+
let _autoPoolCache = null;
|
|
173
|
+
|
|
174
|
+
/** 从 DashScope provider 动态构建 auto 候选池(按优先级:flash/turbo > plus > 27b/35b > max/pro > 第三方) */
|
|
175
|
+
function buildAutoPool() {
|
|
176
|
+
if (_autoPoolCache) return _autoPoolCache;
|
|
177
|
+
const dashscope = LLM_PROVIDERS.find((p) => p.id === "dashscope");
|
|
178
|
+
if (!dashscope) return [];
|
|
179
|
+
const models = dashscope.models.filter((m) => m.id !== "auto");
|
|
180
|
+
|
|
181
|
+
const priority = (id) => {
|
|
182
|
+
if (/flash|turbo/i.test(id)) return 0;
|
|
183
|
+
if (/plus/i.test(id)) return 1;
|
|
184
|
+
if (/27b|35b/i.test(id)) return 2;
|
|
185
|
+
if (/max|pro/i.test(id)) return 3;
|
|
186
|
+
return 4; // deepseek, glm, kimi 等第三方
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// 记录原始顺序,排序时优先按 capability tier,同 tier 保持原始顺序
|
|
190
|
+
const indexed = models.map((m, i) => ({ id: m.id, idx: i }));
|
|
191
|
+
indexed.sort((a, b) => {
|
|
192
|
+
const pa = priority(a.id);
|
|
193
|
+
const pb = priority(b.id);
|
|
194
|
+
if (pa !== pb) return pa - pb;
|
|
195
|
+
return a.idx - b.idx;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
_autoPoolCache = indexed.map((m) => m.id);
|
|
199
|
+
console.error(
|
|
200
|
+
`[llm-config] auto 候选池已构建: ${_autoPoolCache.length} 个模型(${_autoPoolCache.slice(0, 5).join(", ")}...)`,
|
|
201
|
+
);
|
|
202
|
+
return _autoPoolCache;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 清除 auto 候选池缓存(provider 变更后调用) */
|
|
206
|
+
function clearAutoPoolCache() {
|
|
207
|
+
_autoPoolCache = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let _lastAutoSuccess = null; // 上次成功的模型,下次优先用它
|
|
211
|
+
|
|
212
|
+
/** 是否处于 auto 模式 */
|
|
213
|
+
export function isAutoMode() {
|
|
214
|
+
if (!_loaded) loadLlmConfig();
|
|
215
|
+
return _cachedModel === "auto";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 记录 auto 模式下成功调用的模型 */
|
|
219
|
+
export function recordAutoSuccess(model) {
|
|
220
|
+
_lastAutoSuccess = model;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 获取 auto 模式候选池(上次成功的排最前,失败时依次尝试) */
|
|
224
|
+
export function getAutoPool() {
|
|
225
|
+
const pool = [...buildAutoPool()];
|
|
226
|
+
if (_lastAutoSuccess) {
|
|
227
|
+
const idx = pool.indexOf(_lastAutoSuccess);
|
|
228
|
+
if (idx > 0) {
|
|
229
|
+
// 把上次成功的移到第一位,其余保持相对顺序
|
|
230
|
+
pool.splice(idx, 1);
|
|
231
|
+
pool.unshift(_lastAutoSuccess);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return pool;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 解析当前实际使用的 model(auto 模式下轮询选一个) */
|
|
238
|
+
function resolveModel() {
|
|
239
|
+
if (_cachedModel !== "auto") return _cachedModel;
|
|
240
|
+
const pool = buildAutoPool();
|
|
241
|
+
const idx = _autoRoundIdx % pool.length;
|
|
242
|
+
_autoRoundIdx++;
|
|
243
|
+
return pool[idx];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 获取当前 LLM Model(内部调用,auto 模式自动轮询) */
|
|
247
|
+
export function getLlmModel() {
|
|
248
|
+
if (!_loaded) loadLlmConfig();
|
|
249
|
+
return resolveModel();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 获取当前 API Key(内部调用) */
|
|
253
|
+
export function getLlmApiKey() {
|
|
254
|
+
if (!_loaded) loadLlmConfig();
|
|
255
|
+
return _cachedApiKey;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 更新 LLM 配置(通过 API 调用)
|
|
260
|
+
* @param {{ provider?: string, model?: string, url?: string, apiKey?: string }} updates
|
|
261
|
+
*/
|
|
262
|
+
export function updateLlmConfig(updates = {}) {
|
|
263
|
+
if (!_loaded) loadLlmConfig();
|
|
264
|
+
|
|
265
|
+
// Provider 变更:切换 URL,Model 用指定的或默认第一个,同时加载对应 provider 的 API Key
|
|
266
|
+
if (updates.provider !== undefined) {
|
|
267
|
+
const provider = findProvider(updates.provider);
|
|
268
|
+
const prevProvider = _cachedProvider;
|
|
269
|
+
_cachedProvider = provider.id;
|
|
270
|
+
_cachedUrl = provider.url;
|
|
271
|
+
clearAutoPoolCache(); // provider 变了,auto 候选池也需重建
|
|
272
|
+
// 如果同时传了 model 且在 provider 中存在,用指定的;否则用第一个
|
|
273
|
+
const specifiedModel =
|
|
274
|
+
updates.model !== undefined
|
|
275
|
+
? provider.models.find((m) => m.id === updates.model)
|
|
276
|
+
: null;
|
|
277
|
+
_cachedModel = specifiedModel ? specifiedModel.id : provider.models[0].id;
|
|
278
|
+
// 切换到新 provider:从 DB 加载该 provider 的 API Key
|
|
279
|
+
if (provider.id !== prevProvider) {
|
|
280
|
+
const dbConfig = loadFromDb();
|
|
281
|
+
_cachedApiKey =
|
|
282
|
+
dbConfig[`llm_apikey_${provider.id}`] ||
|
|
283
|
+
dbConfig.llm_apikey ||
|
|
284
|
+
process.env.APIKEY ||
|
|
285
|
+
"";
|
|
286
|
+
console.error(
|
|
287
|
+
`[llm-config] 切换到 ${provider.id},API Key: ${_cachedApiKey ? "已加载" : "未设置"}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
saveToDb("llm_provider", provider.id);
|
|
291
|
+
saveToDb("llm_url", provider.url);
|
|
292
|
+
saveToDb("llm_model", _cachedModel);
|
|
293
|
+
} else if (updates.model !== undefined) {
|
|
294
|
+
// Model 单独变更(同 provider 内切换)
|
|
295
|
+
_cachedModel = updates.model;
|
|
296
|
+
saveToDb("llm_model", updates.model);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// URL 手动覆盖
|
|
300
|
+
if (updates.url !== undefined) {
|
|
301
|
+
_cachedUrl = updates.url;
|
|
302
|
+
saveToDb("llm_url", updates.url);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// API Key(按 provider 隔离存储,跳过提示字符串)
|
|
306
|
+
if (updates.apiKey !== undefined && updates.apiKey !== "") {
|
|
307
|
+
const isHint = updates.apiKey.includes("...") && updates.apiKey.length < 20;
|
|
308
|
+
if (!isHint) {
|
|
309
|
+
_cachedApiKey = updates.apiKey;
|
|
310
|
+
saveToDb(`llm_apikey_${_cachedProvider}`, updates.apiKey);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
console.error(
|
|
315
|
+
`[llm-config] 配置已更新: provider=${_cachedProvider}, model=${_cachedModel}, url=${_cachedUrl}, hasKey=${!!_cachedApiKey}`,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
return getLlmConfig();
|
|
319
|
+
}
|