tt-help-cli-ycl 1.4.21 → 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/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 +196 -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
|
@@ -1245,6 +1245,8 @@ function handleRoute() {
|
|
|
1245
1245
|
showTargetPage();
|
|
1246
1246
|
} else if (hash === "#tags") {
|
|
1247
1247
|
showTagsPage();
|
|
1248
|
+
} else if (hash === "#settings") {
|
|
1249
|
+
showSettingsPage();
|
|
1248
1250
|
} else {
|
|
1249
1251
|
showMainPage();
|
|
1250
1252
|
}
|
|
@@ -1273,6 +1275,7 @@ function showPendingPage() {
|
|
|
1273
1275
|
document.getElementById("rawPage").classList.remove("active");
|
|
1274
1276
|
document.getElementById("targetPage").classList.remove("active");
|
|
1275
1277
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1278
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1276
1279
|
fetchPendingByCountry();
|
|
1277
1280
|
fetchAttachStuckByCountry();
|
|
1278
1281
|
}
|
|
@@ -1284,6 +1287,7 @@ function showUserUpdatePage() {
|
|
|
1284
1287
|
document.getElementById("rawPage").classList.remove("active");
|
|
1285
1288
|
document.getElementById("targetPage").classList.remove("active");
|
|
1286
1289
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1290
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1287
1291
|
fetchUserUpdateByCountry();
|
|
1288
1292
|
fetchAttachStuckByCountry();
|
|
1289
1293
|
}
|
|
@@ -1302,6 +1306,7 @@ function showRawPage() {
|
|
|
1302
1306
|
document.getElementById("rawPage").classList.add("active");
|
|
1303
1307
|
document.getElementById("targetPage").classList.remove("active");
|
|
1304
1308
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1309
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1305
1310
|
fetchRawByCountry();
|
|
1306
1311
|
fetchRawJobs();
|
|
1307
1312
|
}
|
|
@@ -1313,6 +1318,7 @@ function showMainPage() {
|
|
|
1313
1318
|
document.getElementById("rawPage").classList.remove("active");
|
|
1314
1319
|
document.getElementById("targetPage").classList.remove("active");
|
|
1315
1320
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1321
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1316
1322
|
}
|
|
1317
1323
|
|
|
1318
1324
|
function navigateToTarget() {
|
|
@@ -1323,6 +1329,189 @@ function navigateToTags() {
|
|
|
1323
1329
|
window.location.hash = "#tags";
|
|
1324
1330
|
}
|
|
1325
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
|
+
|
|
1326
1515
|
// ========== 添加关键词弹窗 ==========
|
|
1327
1516
|
|
|
1328
1517
|
function openAddTagModal() {
|
|
@@ -1483,6 +1672,7 @@ function showTagsPage() {
|
|
|
1483
1672
|
document.getElementById("rawPage").classList.remove("active");
|
|
1484
1673
|
document.getElementById("targetPage").classList.remove("active");
|
|
1485
1674
|
document.getElementById("tagsPage").classList.add("active");
|
|
1675
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1486
1676
|
showLoading("正在加载关键词...");
|
|
1487
1677
|
Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]).finally(
|
|
1488
1678
|
hideLoading,
|
|
@@ -1626,6 +1816,11 @@ async function loadTagsPage(append = false) {
|
|
|
1626
1816
|
if (currentTagsFilter !== "all") url += `&status=${currentTagsFilter}`;
|
|
1627
1817
|
if (country) url += `&country=${encodeURIComponent(country)}`;
|
|
1628
1818
|
|
|
1819
|
+
const searchVal =
|
|
1820
|
+
document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
|
|
1821
|
+
"";
|
|
1822
|
+
if (searchVal) url += `&search=${encodeURIComponent(searchVal)}`;
|
|
1823
|
+
|
|
1629
1824
|
const res = await fetch(url);
|
|
1630
1825
|
const data = await res.json();
|
|
1631
1826
|
|
|
@@ -1652,14 +1847,6 @@ function renderTagsTable() {
|
|
|
1652
1847
|
const moreHint = document.getElementById("tagsMoreHint");
|
|
1653
1848
|
let display = [...currentTags];
|
|
1654
1849
|
|
|
1655
|
-
// 搜索过滤(本地二次过滤)
|
|
1656
|
-
const searchVal =
|
|
1657
|
-
document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
|
|
1658
|
-
"";
|
|
1659
|
-
if (searchVal) {
|
|
1660
|
-
display = display.filter((t) => t.tag.toLowerCase().includes(searchVal));
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
1850
|
// 本地排序
|
|
1664
1851
|
if (currentTagsSort.key) {
|
|
1665
1852
|
display.sort((a, b) => {
|
|
@@ -1957,6 +2144,7 @@ function showTargetPage() {
|
|
|
1957
2144
|
document.getElementById("rawPage").classList.remove("active");
|
|
1958
2145
|
document.getElementById("targetPage").classList.add("active");
|
|
1959
2146
|
document.getElementById("tagsPage").classList.remove("active");
|
|
2147
|
+
document.getElementById("settingsPage").classList.remove("active");
|
|
1960
2148
|
showLoading("正在加载目标商家数据...");
|
|
1961
2149
|
fetchTargetByCountry();
|
|
1962
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) {
|