tt-help-cli-ycl 1.4.20 → 1.4.22
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/lib/parse-ssr.mjs +6 -0
- package/src/lib/tag-discover.js +8 -4
- package/src/lib/tag-fetcher.js +5 -0
- package/src/npm-main.js +3 -0
- package/src/watch/data-store.js +2 -0
- package/src/watch/db-tags.js +237 -17
- package/src/watch/llm-config.js +269 -0
- package/src/watch/llm-scoring.js +103 -63
- package/src/watch/public/app.js +198 -8
- package/src/watch/public/index.html +39 -0
- package/src/watch/public/style.css +154 -0
- package/src/watch/server.js +104 -6
- package/src/watch/tag-service.js +119 -41
package/src/watch/public/app.js
CHANGED
|
@@ -268,6 +268,7 @@ function renderActiveClients(clients) {
|
|
|
268
268
|
"explore",
|
|
269
269
|
"refresh",
|
|
270
270
|
"attach",
|
|
271
|
+
"recheck",
|
|
271
272
|
"comments",
|
|
272
273
|
"scoring",
|
|
273
274
|
"tag-refresh",
|
|
@@ -276,6 +277,7 @@ function renderActiveClients(clients) {
|
|
|
276
277
|
explore: "Explore",
|
|
277
278
|
refresh: "Refresh",
|
|
278
279
|
attach: "Attach",
|
|
280
|
+
recheck: "Recheck",
|
|
279
281
|
comments: "Comments",
|
|
280
282
|
scoring: "Scoring",
|
|
281
283
|
"tag-refresh": "TagRefresh",
|
|
@@ -1243,6 +1245,8 @@ function handleRoute() {
|
|
|
1243
1245
|
showTargetPage();
|
|
1244
1246
|
} else if (hash === "#tags") {
|
|
1245
1247
|
showTagsPage();
|
|
1248
|
+
} else if (hash === "#settings") {
|
|
1249
|
+
showSettingsPage();
|
|
1246
1250
|
} else {
|
|
1247
1251
|
showMainPage();
|
|
1248
1252
|
}
|
|
@@ -1271,6 +1275,7 @@ function showPendingPage() {
|
|
|
1271
1275
|
document.getElementById("rawPage").classList.remove("active");
|
|
1272
1276
|
document.getElementById("targetPage").classList.remove("active");
|
|
1273
1277
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1278
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1274
1279
|
fetchPendingByCountry();
|
|
1275
1280
|
fetchAttachStuckByCountry();
|
|
1276
1281
|
}
|
|
@@ -1282,6 +1287,7 @@ function showUserUpdatePage() {
|
|
|
1282
1287
|
document.getElementById("rawPage").classList.remove("active");
|
|
1283
1288
|
document.getElementById("targetPage").classList.remove("active");
|
|
1284
1289
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1290
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1285
1291
|
fetchUserUpdateByCountry();
|
|
1286
1292
|
fetchAttachStuckByCountry();
|
|
1287
1293
|
}
|
|
@@ -1300,6 +1306,7 @@ function showRawPage() {
|
|
|
1300
1306
|
document.getElementById("rawPage").classList.add("active");
|
|
1301
1307
|
document.getElementById("targetPage").classList.remove("active");
|
|
1302
1308
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1309
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1303
1310
|
fetchRawByCountry();
|
|
1304
1311
|
fetchRawJobs();
|
|
1305
1312
|
}
|
|
@@ -1311,6 +1318,7 @@ function showMainPage() {
|
|
|
1311
1318
|
document.getElementById("rawPage").classList.remove("active");
|
|
1312
1319
|
document.getElementById("targetPage").classList.remove("active");
|
|
1313
1320
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1321
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1314
1322
|
}
|
|
1315
1323
|
|
|
1316
1324
|
function navigateToTarget() {
|
|
@@ -1321,6 +1329,189 @@ function navigateToTags() {
|
|
|
1321
1329
|
window.location.hash = "#tags";
|
|
1322
1330
|
}
|
|
1323
1331
|
|
|
1332
|
+
function navigateToSettings() {
|
|
1333
|
+
window.location.hash = "#settings";
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ========== LLM 配置页面 ==========
|
|
1337
|
+
|
|
1338
|
+
function hideAllPages() {
|
|
1339
|
+
document.getElementById("mainPage").classList.add("hidden");
|
|
1340
|
+
document.getElementById("pendingPage").classList.remove("active");
|
|
1341
|
+
document.getElementById("userUpdatePage").classList.remove("active");
|
|
1342
|
+
document.getElementById("rawPage").classList.remove("active");
|
|
1343
|
+
document.getElementById("targetPage").classList.remove("active");
|
|
1344
|
+
document.getElementById("tagsPage").classList.remove("active");
|
|
1345
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function showSettingsPage() {
|
|
1349
|
+
hideAllPages();
|
|
1350
|
+
document.getElementById("settingsPage").classList.add("active");
|
|
1351
|
+
fetchLlmConfig();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
let _llmProvidersCache = null;
|
|
1355
|
+
|
|
1356
|
+
async function fetchLlmConfig() {
|
|
1357
|
+
try {
|
|
1358
|
+
const res = await fetch("/api/llm-config");
|
|
1359
|
+
const data = await res.json();
|
|
1360
|
+
_llmProvidersCache = data.providers || [];
|
|
1361
|
+
|
|
1362
|
+
const providerSel = document.getElementById("settingsProvider");
|
|
1363
|
+
const modelSel = document.getElementById("settingsModel");
|
|
1364
|
+
const urlInput = document.getElementById("settingsUrl");
|
|
1365
|
+
const keyInput = document.getElementById("settingsApiKey");
|
|
1366
|
+
const keyHint = document.getElementById("settingsKeyHint");
|
|
1367
|
+
const status = document.getElementById("settingsStatus");
|
|
1368
|
+
|
|
1369
|
+
// 渲染服务商下拉(只渲染一次)
|
|
1370
|
+
if (providerSel && providerSel.options.length === 0) {
|
|
1371
|
+
providerSel.innerHTML = _llmProvidersCache
|
|
1372
|
+
.map(
|
|
1373
|
+
(p) =>
|
|
1374
|
+
`<option value="${p.id}" ${p.id === data.provider ? "selected" : ""}>${p.name}</option>`,
|
|
1375
|
+
)
|
|
1376
|
+
.join("");
|
|
1377
|
+
} else if (providerSel) {
|
|
1378
|
+
providerSel.value = data.provider;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// 根据当前 provider 渲染模型下拉
|
|
1382
|
+
renderModelSelect(data.provider, data.model);
|
|
1383
|
+
|
|
1384
|
+
if (urlInput) urlInput.value = data.url || "";
|
|
1385
|
+
if (keyInput) {
|
|
1386
|
+
keyInput.value = data.hasApiKey ? data.apiKeyHint : "";
|
|
1387
|
+
}
|
|
1388
|
+
if (keyHint) {
|
|
1389
|
+
keyHint.textContent = data.hasApiKey
|
|
1390
|
+
? "已配置"
|
|
1391
|
+
: "未配置,请输入 API Key";
|
|
1392
|
+
keyHint.style.color = data.hasApiKey ? "#4ade80" : "#f87171";
|
|
1393
|
+
}
|
|
1394
|
+
if (status) {
|
|
1395
|
+
const provider = _llmProvidersCache.find((p) => p.id === data.provider);
|
|
1396
|
+
status.textContent = `当前: ${provider?.name || data.provider} / ${data.model}`;
|
|
1397
|
+
status.className = "settings-status ok";
|
|
1398
|
+
}
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
console.error("fetchLlmConfig error:", e);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function renderModelSelect(providerId, selectedModel) {
|
|
1405
|
+
const modelSel = document.getElementById("settingsModel");
|
|
1406
|
+
if (!modelSel) return;
|
|
1407
|
+
const provider = (_llmProvidersCache || []).find((p) => p.id === providerId);
|
|
1408
|
+
const models = provider?.models || [];
|
|
1409
|
+
modelSel.innerHTML = models
|
|
1410
|
+
.map(
|
|
1411
|
+
(m) =>
|
|
1412
|
+
`<option value="${m.id}" ${m.id === selectedModel ? "selected" : ""}>${m.label}</option>`,
|
|
1413
|
+
)
|
|
1414
|
+
.join("");
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Provider 切换 → 更新 Model 列表 + URL
|
|
1418
|
+
function onProviderChange() {
|
|
1419
|
+
const providerSel = document.getElementById("settingsProvider");
|
|
1420
|
+
const providerId = providerSel?.value;
|
|
1421
|
+
if (!providerId) return;
|
|
1422
|
+
|
|
1423
|
+
const provider = (_llmProvidersCache || []).find((p) => p.id === providerId);
|
|
1424
|
+
if (!provider) return;
|
|
1425
|
+
|
|
1426
|
+
// 更新 URL
|
|
1427
|
+
const urlInput = document.getElementById("settingsUrl");
|
|
1428
|
+
if (urlInput) urlInput.value = provider.url;
|
|
1429
|
+
|
|
1430
|
+
// 更新模型列表(默认选第一个)
|
|
1431
|
+
renderModelSelect(providerId, provider.models[0]?.id);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// 绑定事件
|
|
1435
|
+
(function initSettingsEvents() {
|
|
1436
|
+
const providerSel = document.getElementById("settingsProvider");
|
|
1437
|
+
if (providerSel) {
|
|
1438
|
+
providerSel.addEventListener("change", onProviderChange);
|
|
1439
|
+
}
|
|
1440
|
+
})();
|
|
1441
|
+
|
|
1442
|
+
function toggleApiKeyVisibility() {
|
|
1443
|
+
const input = document.getElementById("settingsApiKey");
|
|
1444
|
+
const btn = document.getElementById("settingsToggleKey");
|
|
1445
|
+
if (input.type === "password") {
|
|
1446
|
+
input.type = "text";
|
|
1447
|
+
btn.textContent = "🙈";
|
|
1448
|
+
} else {
|
|
1449
|
+
input.type = "password";
|
|
1450
|
+
btn.textContent = "👁";
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
async function saveLlmConfig() {
|
|
1455
|
+
const provider = document.getElementById("settingsProvider").value;
|
|
1456
|
+
const model = document.getElementById("settingsModel").value;
|
|
1457
|
+
const url = document.getElementById("settingsUrl").value;
|
|
1458
|
+
const apiKeyInput = document.getElementById("settingsApiKey");
|
|
1459
|
+
const status = document.getElementById("settingsStatus");
|
|
1460
|
+
|
|
1461
|
+
// 防止把提示字符串当 key 保存:若输入值含 "..." 且未被用户修改过,不发送
|
|
1462
|
+
const rawKey = apiKeyInput.value.trim();
|
|
1463
|
+
const isHint = rawKey.includes("...") && rawKey.length < 20;
|
|
1464
|
+
const keyWasEdited = apiKeyInput.dataset.edited === "true";
|
|
1465
|
+
let apiKey = undefined;
|
|
1466
|
+
if (rawKey && !isHint) {
|
|
1467
|
+
apiKey = rawKey;
|
|
1468
|
+
} else if (rawKey && isHint && keyWasEdited) {
|
|
1469
|
+
// 用户手动修改了(可能是输入了新 key),发送
|
|
1470
|
+
apiKey = rawKey;
|
|
1471
|
+
}
|
|
1472
|
+
// 如果 isHint && !keyWasEdited → 不发送,保留服务端已有 key
|
|
1473
|
+
|
|
1474
|
+
status.textContent = "保存中...";
|
|
1475
|
+
status.className = "settings-status loading";
|
|
1476
|
+
|
|
1477
|
+
try {
|
|
1478
|
+
const body = { provider, model, url };
|
|
1479
|
+
if (apiKey !== undefined) body.apiKey = apiKey;
|
|
1480
|
+
const res = await fetch("/api/llm-config", {
|
|
1481
|
+
method: "POST",
|
|
1482
|
+
headers: { "Content-Type": "application/json" },
|
|
1483
|
+
body: JSON.stringify(body),
|
|
1484
|
+
});
|
|
1485
|
+
const data = await res.json();
|
|
1486
|
+
if (data.hasApiKey) {
|
|
1487
|
+
apiKeyInput.value = data.apiKeyHint;
|
|
1488
|
+
apiKeyInput.dataset.edited = "false";
|
|
1489
|
+
const keyHint = document.getElementById("settingsKeyHint");
|
|
1490
|
+
if (keyHint) {
|
|
1491
|
+
keyHint.textContent = "已配置";
|
|
1492
|
+
keyHint.style.color = "#4ade80";
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const p = (_llmProvidersCache || []).find((x) => x.id === data.provider);
|
|
1496
|
+
status.textContent = `✅ 已保存: ${p?.name || data.provider} / ${data.model}`;
|
|
1497
|
+
status.className = "settings-status ok";
|
|
1498
|
+
_llmProvidersCache = data.providers || _llmProvidersCache;
|
|
1499
|
+
} catch (e) {
|
|
1500
|
+
status.textContent = "❌ 保存失败: " + e.message;
|
|
1501
|
+
status.className = "settings-status error";
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// 追踪 API Key 输入框是否被用户编辑过
|
|
1506
|
+
(function initKeyEditTracking() {
|
|
1507
|
+
const input = document.getElementById("settingsApiKey");
|
|
1508
|
+
if (input) {
|
|
1509
|
+
input.addEventListener("input", () => {
|
|
1510
|
+
input.dataset.edited = "true";
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
})();
|
|
1514
|
+
|
|
1324
1515
|
// ========== 添加关键词弹窗 ==========
|
|
1325
1516
|
|
|
1326
1517
|
function openAddTagModal() {
|
|
@@ -1481,6 +1672,7 @@ function showTagsPage() {
|
|
|
1481
1672
|
document.getElementById("rawPage").classList.remove("active");
|
|
1482
1673
|
document.getElementById("targetPage").classList.remove("active");
|
|
1483
1674
|
document.getElementById("tagsPage").classList.add("active");
|
|
1675
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1484
1676
|
showLoading("正在加载关键词...");
|
|
1485
1677
|
Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]).finally(
|
|
1486
1678
|
hideLoading,
|
|
@@ -1624,6 +1816,11 @@ async function loadTagsPage(append = false) {
|
|
|
1624
1816
|
if (currentTagsFilter !== "all") url += `&status=${currentTagsFilter}`;
|
|
1625
1817
|
if (country) url += `&country=${encodeURIComponent(country)}`;
|
|
1626
1818
|
|
|
1819
|
+
const searchVal =
|
|
1820
|
+
document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
|
|
1821
|
+
"";
|
|
1822
|
+
if (searchVal) url += `&search=${encodeURIComponent(searchVal)}`;
|
|
1823
|
+
|
|
1627
1824
|
const res = await fetch(url);
|
|
1628
1825
|
const data = await res.json();
|
|
1629
1826
|
|
|
@@ -1650,14 +1847,6 @@ function renderTagsTable() {
|
|
|
1650
1847
|
const moreHint = document.getElementById("tagsMoreHint");
|
|
1651
1848
|
let display = [...currentTags];
|
|
1652
1849
|
|
|
1653
|
-
// 搜索过滤(本地二次过滤)
|
|
1654
|
-
const searchVal =
|
|
1655
|
-
document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
|
|
1656
|
-
"";
|
|
1657
|
-
if (searchVal) {
|
|
1658
|
-
display = display.filter((t) => t.tag.toLowerCase().includes(searchVal));
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
1850
|
// 本地排序
|
|
1662
1851
|
if (currentTagsSort.key) {
|
|
1663
1852
|
display.sort((a, b) => {
|
|
@@ -1955,6 +2144,7 @@ function showTargetPage() {
|
|
|
1955
2144
|
document.getElementById("rawPage").classList.remove("active");
|
|
1956
2145
|
document.getElementById("targetPage").classList.add("active");
|
|
1957
2146
|
document.getElementById("tagsPage").classList.remove("active");
|
|
2147
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1958
2148
|
showLoading("正在加载目标商家数据...");
|
|
1959
2149
|
fetchTargetByCountry();
|
|
1960
2150
|
fetchTargetOtherCountries();
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<div style="display:flex;gap:8px;align-items:center">
|
|
17
17
|
<a href="/scripts/run-explore.sh" class="script-link" download="run-explore.sh">mac</a>
|
|
18
18
|
<a href="/scripts/run-explore.bat" class="script-link" download="run-explore.bat">windows</a>
|
|
19
|
+
<a class="script-link" onclick="navigateToSettings()" style="cursor:pointer">⚙️ LLM</a>
|
|
19
20
|
<span class="status" id="lastUpdate">--</span>
|
|
20
21
|
</div>
|
|
21
22
|
</div>
|
|
@@ -446,6 +447,44 @@
|
|
|
446
447
|
</div>
|
|
447
448
|
</div>
|
|
448
449
|
</div>
|
|
450
|
+
<div id="settingsPage">
|
|
451
|
+
<div class="stats" style="margin-bottom:16px">
|
|
452
|
+
<div class="stat-card clickable" onclick="navigateToMain()" style="background:rgba(96,165,250,0.1)">
|
|
453
|
+
<div class="label">← 返回主页面</div>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="settings-card">
|
|
457
|
+
<h3>⚙️ LLM 模型配置</h3>
|
|
458
|
+
<p class="settings-hint">切换后立即生效,无需重启服务。配置保存在数据库中。</p>
|
|
459
|
+
<div class="settings-form">
|
|
460
|
+
<div class="settings-field">
|
|
461
|
+
<label>服务商</label>
|
|
462
|
+
<select id="settingsProvider" onchange="onProviderChange()"></select>
|
|
463
|
+
</div>
|
|
464
|
+
<div class="settings-field">
|
|
465
|
+
<label>模型</label>
|
|
466
|
+
<select id="settingsModel"></select>
|
|
467
|
+
</div>
|
|
468
|
+
<div class="settings-field">
|
|
469
|
+
<label>API 地址</label>
|
|
470
|
+
<input type="text" id="settingsUrl" placeholder="https://..." readonly>
|
|
471
|
+
<span class="settings-note">选服务商后自动填入,也可手动修改</span>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="settings-field">
|
|
474
|
+
<label>API Key</label>
|
|
475
|
+
<div class="settings-key-row">
|
|
476
|
+
<input type="password" id="settingsApiKey" placeholder="sk-...">
|
|
477
|
+
<button id="settingsToggleKey" onclick="toggleApiKeyVisibility()" title="显示/隐藏">👁</button>
|
|
478
|
+
</div>
|
|
479
|
+
<span class="settings-note" id="settingsKeyHint"></span>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="settings-status" id="settingsStatus"></div>
|
|
482
|
+
<div class="btn-row">
|
|
483
|
+
<button class="btn-submit" onclick="saveLlmConfig()">保存配置</button>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
449
488
|
<script src="app.js"></script>
|
|
450
489
|
</body>
|
|
451
490
|
|
|
@@ -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
|
+
}
|
package/src/watch/server.js
CHANGED
|
@@ -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
|
|
@@ -1253,6 +1263,55 @@ export function startWatchServer(
|
|
|
1253
1263
|
return;
|
|
1254
1264
|
}
|
|
1255
1265
|
|
|
1266
|
+
// ====== LLM 配置 API ======
|
|
1267
|
+
if (req.method === "GET" && routePath === "/api/llm-config") {
|
|
1268
|
+
try {
|
|
1269
|
+
const { getLlmConfig, LLM_PROVIDERS } =
|
|
1270
|
+
await import("./llm-config.js");
|
|
1271
|
+
sendJSON(res, 200, {
|
|
1272
|
+
...getLlmConfig(),
|
|
1273
|
+
providers: LLM_PROVIDERS,
|
|
1274
|
+
});
|
|
1275
|
+
} catch (e) {
|
|
1276
|
+
sendJSON(res, 500, { error: e.message });
|
|
1277
|
+
}
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (req.method === "POST" && routePath === "/api/llm-config") {
|
|
1282
|
+
try {
|
|
1283
|
+
const body = await readBody(req);
|
|
1284
|
+
const { updateLlmConfig, getLlmConfig, LLM_PROVIDERS } =
|
|
1285
|
+
await import("./llm-config.js");
|
|
1286
|
+
const result = updateLlmConfig({
|
|
1287
|
+
provider: body.provider,
|
|
1288
|
+
model: body.model,
|
|
1289
|
+
url: body.url,
|
|
1290
|
+
apiKey: body.apiKey,
|
|
1291
|
+
});
|
|
1292
|
+
sendJSON(res, 200, { ...result, providers: LLM_PROVIDERS });
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
sendJSON(res, 400, { error: e.message });
|
|
1295
|
+
}
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// GET /api/llm-test — 直接测试 LLM API 连通性
|
|
1300
|
+
if (req.method === "GET" && routePath === "/api/llm-test") {
|
|
1301
|
+
try {
|
|
1302
|
+
const { callLLM } = await import("./tag-service.js");
|
|
1303
|
+
const result = await callLLM("Say hello in exactly one word.", 0.1);
|
|
1304
|
+
sendJSON(res, 200, {
|
|
1305
|
+
ok: result.length > 0,
|
|
1306
|
+
reply: result || "(空)",
|
|
1307
|
+
replyLen: result.length,
|
|
1308
|
+
});
|
|
1309
|
+
} catch (e) {
|
|
1310
|
+
sendJSON(res, 500, { ok: false, error: e.message });
|
|
1311
|
+
}
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1256
1315
|
if (req.method === "POST" && routePath === "/api/attach-stuck/restore") {
|
|
1257
1316
|
try {
|
|
1258
1317
|
const body = await readBody(req);
|
|
@@ -1502,23 +1561,31 @@ export function startWatchServer(
|
|
|
1502
1561
|
return;
|
|
1503
1562
|
}
|
|
1504
1563
|
|
|
1505
|
-
// GET /api/tags?status=&country=&limit=&offset=
|
|
1564
|
+
// GET /api/tags?status=&country=&search=&limit=&offset=
|
|
1506
1565
|
if (req.method === "GET" && routePath === "/api/tags") {
|
|
1507
1566
|
const status = params.status || null;
|
|
1508
1567
|
const country = params.country || null;
|
|
1568
|
+
const search = params.search || null;
|
|
1509
1569
|
const limit = Math.min(parseInt(params.limit) || 200, 500);
|
|
1510
1570
|
const offset = parseInt(params.offset) || 0;
|
|
1511
1571
|
const upperCountry = country ? country.toUpperCase() : null;
|
|
1572
|
+
const searchTerm = search ? search.trim().toLowerCase() : null;
|
|
1512
1573
|
|
|
1513
1574
|
let tags;
|
|
1514
1575
|
if (status) {
|
|
1515
|
-
tags = store.getTagsByStatus(
|
|
1576
|
+
tags = store.getTagsByStatus(
|
|
1577
|
+
status,
|
|
1578
|
+
limit,
|
|
1579
|
+
offset,
|
|
1580
|
+
upperCountry,
|
|
1581
|
+
searchTerm,
|
|
1582
|
+
);
|
|
1516
1583
|
} else {
|
|
1517
|
-
tags = store.getAllTags(limit, offset, upperCountry);
|
|
1584
|
+
tags = store.getAllTags(limit, offset, upperCountry, searchTerm);
|
|
1518
1585
|
}
|
|
1519
1586
|
|
|
1520
|
-
//
|
|
1521
|
-
const stats = store.getTagStats(upperCountry);
|
|
1587
|
+
// 获取总数(带搜索过滤,按 status 返回对应计数)
|
|
1588
|
+
const stats = store.getTagStats(upperCountry, searchTerm);
|
|
1522
1589
|
let total;
|
|
1523
1590
|
if (stats) {
|
|
1524
1591
|
if (status === "new") total = stats.new;
|
|
@@ -1676,6 +1743,35 @@ export function startWatchServer(
|
|
|
1676
1743
|
return;
|
|
1677
1744
|
}
|
|
1678
1745
|
|
|
1746
|
+
// POST /api/tags/batch-add-if-new — 批量添加 tag(只插入不存在的,不覆盖已有)
|
|
1747
|
+
if (req.method === "POST" && routePath === "/api/tags/batch-add-if-new") {
|
|
1748
|
+
try {
|
|
1749
|
+
const body = await readBody(req);
|
|
1750
|
+
const { tags, countries, source } = body || {};
|
|
1751
|
+
if (!Array.isArray(tags) || tags.length === 0) {
|
|
1752
|
+
sendJSON(res, 400, { error: "tags 不能为空" });
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (!Array.isArray(countries) || countries.length === 0) {
|
|
1756
|
+
sendJSON(res, 400, { error: "countries 不能为空" });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const ret = store.insertTagsIfNew(tags, countries, source || "video");
|
|
1760
|
+
if (ret.error) {
|
|
1761
|
+
sendJSON(res, 500, { error: ret.error });
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
sendJSON(res, 200, {
|
|
1765
|
+
ok: true,
|
|
1766
|
+
inserted: ret.inserted,
|
|
1767
|
+
tags: ret.tags,
|
|
1768
|
+
});
|
|
1769
|
+
} catch (e) {
|
|
1770
|
+
sendJSON(res, 500, { error: e.message });
|
|
1771
|
+
}
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1679
1775
|
// GET /api/tags/health?country=ES — 自闭环:获取国家 tag 健康指标
|
|
1680
1776
|
if (req.method === "GET" && routePath === "/api/tags/health") {
|
|
1681
1777
|
try {
|
|
@@ -2179,7 +2275,9 @@ export function startWatchServer(
|
|
|
2179
2275
|
|
|
2180
2276
|
if (
|
|
2181
2277
|
req.method === "GET" &&
|
|
2182
|
-
(routePath === "/" ||
|
|
2278
|
+
(routePath === "/" ||
|
|
2279
|
+
routePath === "/index.html" ||
|
|
2280
|
+
routePath === "/settings")
|
|
2183
2281
|
) {
|
|
2184
2282
|
// 设有密码时,检查登录态
|
|
2185
2283
|
if (PASSWORD_ENABLED) {
|