init: Search Hub - 统一多搜索引擎聚合服务

This commit is contained in:
2026-05-09 18:46:05 +08:00
commit 81d726179c
27 changed files with 3179 additions and 0 deletions

223
static/js/admin.js Normal file
View File

@@ -0,0 +1,223 @@
/* Search Hub — 管理面板 */
(function () {
'use strict';
let currentEditSource = null;
loadSources();
loadStatus();
loadApiDocs();
loadUsage();
// ===== 搜索源 =====
function loadSources() {
fetch('/api/sources')
.then(function (r) { return r.json(); })
.then(function (data) {
var sources = data.sources || [];
var html = '';
sources.forEach(function (s) {
var badge = s.available
? '<span class="source-badge badge-ok">可用</span>'
: '<span class="source-badge badge-fail">不可用</span>';
var keyInfo = s.needs_api_key
? (s.available ? ' | 密钥已配置' : ' | 需配置密钥')
: ' | 无需密钥';
var editBtn = s.needs_api_key
? '<button class="btn-ghost" onclick="openEditModal(\'' + s.name + '\')">编辑</button>'
: '';
html += '<div class="source-card">';
html += ' <div class="d-flex justify-content-between align-items-center">';
html += ' <div><span class="source-name">' + escapeHtml(s.display_name || s.name) + '</span></div>';
html += ' <div class="d-flex gap-3 align-items-center">' + badge + editBtn + '</div>';
html += ' </div>';
html += ' <div class="small text-muted mt-1">' + s.name + keyInfo + '</div>';
html += '</div>';
});
document.getElementById('sources-list').innerHTML = html;
});
}
// ===== 服务状态 =====
function loadStatus() {
fetch('/api/status')
.then(function (r) { return r.json(); })
.then(function (data) {
document.getElementById('hub-status').textContent = data.status === 'ok' ? '运行中' : '异常';
document.getElementById('hub-version').textContent = data.version || '-';
document.getElementById('hub-history').textContent = data.history_count || 0;
});
}
// ===== API 文档 =====
function loadApiDocs() {
fetch('/api/docs')
.then(function (r) { return r.json(); })
.then(function (data) {
var endpoints = data.endpoints || [];
var html = '';
endpoints.forEach(function (ep) {
var methodClass = ep.method === 'GET' ? 'method-get' : 'method-post';
html += '<div class="api-endpoint">';
html += ' <span class="method ' + methodClass + '">' + ep.method + '</span>';
html += ' <span class="path">' + ep.path + '</span>';
html += ' <div class="desc">' + escapeHtml(ep.desc) + '</div>';
if (ep.example) {
html += ' <button class="show-example-btn" onclick="toggleExample(this)">显示示例</button>';
html += ' <div class="example">' + escapeHtml(ep.example) + '</div>';
}
html += '</div>';
});
document.getElementById('api-docs-list').innerHTML = html;
});
}
// ===== 服务用量(异步加载,不阻塞页面) =====
function loadUsage() {
var el = document.getElementById('usage-list');
fetch('/api/admin/usage')
.then(function (r) { return r.json(); })
.then(function (data) {
var html = '';
// Tavily优先显示 account 级别用量)
var t = data.tavily || {};
if (t.available) {
var usage = t.key && t.key.limit > 0 ? t.key.usage : (t.account ? t.account.search_usage : 0);
var limit = t.key && t.key.limit > 0 ? t.key.limit : (t.account ? t.account.plan_limit : 0);
var remaining = t.key && t.key.limit > 0 ? t.key.remaining : (limit - usage);
var pct = limit > 0 ? Math.round(usage / limit * 100) : 0;
html += '<div class="info-chip">';
html += ' <strong>Tavily</strong>';
html += ' <div style="margin-top:4px">已用: ' + usage + ' / ' + limit;
html += ' <span style="margin-left:8px;color:' + (pct > 80 ? '#ef4444' : '#10b981') + '">剩余: ' + remaining + '</span></div>';
if (t.account) {
html += ' <div class="small text-muted" style="margin-top:2px">账户搜索: ' + t.account.search_usage + ' / ' + t.account.plan_limit;
if (t.account.paygo_usage > 0) html += ' | 按量: ' + t.account.paygo_usage;
html += '</div>';
}
html += '</div>';
} else {
html += '<div class="info-chip"><strong>Tavily</strong><br><span class="text-muted">' + (t.error || '未配置') + '</span></div>';
}
// 百度 VDB
var b = data.baidu_vdb || {};
if (b.available) {
html += '<div class="info-chip" style="margin-top:6px">';
html += ' <strong>百度 VDB</strong>';
html += ' <div style="margin-top:4px">免费额度: ' + b.freeQuota + '</div>';
html += '</div>';
} else {
html += '<div class="info-chip" style="margin-top:6px"><strong>百度 VDB</strong><br><span class="text-muted">' + (b.error || '未配置') + '</span></div>';
}
el.innerHTML = html;
})
.catch(function () {
el.innerHTML = '<div class="info-chip text-muted">用量查询暂不可用</div>';
});
}
window.toggleExample = function (btn) {
var example = btn.nextElementSibling;
if (example) {
example.classList.toggle('visible');
btn.textContent = example.classList.contains('visible') ? '收起' : '显示示例';
}
};
// ===== 编辑弹窗 =====
window.openEditModal = function (name) {
currentEditSource = name;
var modal = document.getElementById('edit-modal');
var title = document.getElementById('edit-modal-title');
var body = document.getElementById('edit-modal-body');
title.textContent = '加载中...';
body.innerHTML = '<div class="form-hint">加载中...</div>';
modal.style.display = 'flex';
fetch('/api/admin/sources/' + name)
.then(function (r) { return r.json(); })
.then(function (data) {
title.textContent = '编辑: ' + (data.display_name || name);
var html = '';
if (data.fields.length === 0) {
html = '<div class="form-hint">该搜索源无需配置</div>';
} else {
data.fields.forEach(function (f) {
html += '<div class="form-group">';
html += ' <label>' + escapeHtml(f.label) + (f.required ? ' <span class="text-danger">*</span>' : '') + '</label>';
if (f.type === 'password') {
html += ' <input type="password" class="form-control" id="cfg-' + f.key + '"';
html += ' placeholder="' + (f.has_value ? '已配置,输入新值覆盖' : '请输入') + '">';
if (f.has_value) {
html += ' <div class="form-hint">当前: ' + escapeHtml(f.value) + '</div>';
}
} else {
html += ' <input type="text" class="form-control" id="cfg-' + f.key + '"';
html += ' value="' + escapeHtml(f.value || '') + '">';
}
html += '</div>';
});
}
body.innerHTML = html;
})
.catch(function (err) {
body.innerHTML = '<div class="text-danger">加载失败: ' + err.message + '</div>';
});
};
window.closeEditModal = function () {
document.getElementById('edit-modal').style.display = 'none';
currentEditSource = null;
};
window.saveConfig = function () {
if (!currentEditSource) return;
var body = document.getElementById('edit-modal-body');
var inputs = body.querySelectorAll('input[id^="cfg-"]');
var data = {};
inputs.forEach(function (input) {
var key = input.id.replace('cfg-', '');
var val = input.value.trim();
if (val) data[key] = val;
});
if (Object.keys(data).length === 0) {
alert('请至少填写一个字段');
return;
}
var saveBtn = document.getElementById('save-btn');
saveBtn.disabled = true;
saveBtn.textContent = '保存中...';
fetch('/api/admin/sources/' + currentEditSource, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
.then(function (r) { return r.json(); })
.then(function (result) {
saveBtn.disabled = false;
saveBtn.textContent = '保存';
if (result.success) {
closeEditModal();
loadSources();
loadStatus();
} else {
alert('保存失败: ' + (result.error || '未知错误'));
}
})
.catch(function (err) {
saveBtn.disabled = false;
saveBtn.textContent = '保存';
alert('请求失败: ' + err.message);
});
};
// ===== 辅助 =====
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
})();

363
static/js/app.js Normal file
View File

@@ -0,0 +1,363 @@
/* Search Hub — Deepseek 风格 UI */
(function () {
'use strict';
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const summarizeBtn = document.getElementById('summarize-btn');
const statusBar = document.getElementById('status-bar');
const historyList = document.getElementById('history-list');
const loading = document.getElementById('loading');
const resultsSection = document.getElementById('results-section');
const emptyState = document.getElementById('empty-state');
const summarySection = document.getElementById('summary-section');
const summaryContent = document.getElementById('summary-content');
const summaryMeta = document.getElementById('summary-meta');
const searchSection = document.getElementById('search-section');
const searchTitle = document.getElementById('search-title');
let currentResults = [];
let currentQuery = '';
let currentSource = 'auto';
let isSummarizing = false;
// 初始隐藏总结区域
summarySection.style.display = 'none';
loadHistory();
setupSourceSelector();
searchBtn.addEventListener('click', doSearch);
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') doSearch();
});
summarizeBtn.addEventListener('click', doSummarize);
// ===== 搜索源选择 =====
function setupSourceSelector() {
document.querySelectorAll('.source-tag').forEach(function (el) {
el.addEventListener('click', function () {
var source = el.dataset.source;
if (!source) return;
if (el.classList.contains('source-disabled')) return;
document.querySelectorAll('.source-tag').forEach(function (t) {
t.classList.remove('source-active');
});
el.classList.add('source-active');
currentSource = source;
if (searchInput.value.trim()) {
doSearch();
}
});
});
}
// ===== 搜索(无总结) =====
function doSearch() {
const query = searchInput.value.trim();
if (!query) return;
hideSummary();
currentQuery = query;
currentResults = [];
showLoading(true);
setStatus('');
searchSection.classList.add('has-results');
searchTitle.textContent = query;
fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
source: currentSource,
}),
})
.then(function (r) { return r.json(); })
.then(function (data) {
showLoading(false);
if (data.error) {
setStatus(data.error);
resultsSection.innerHTML = '';
return;
}
currentResults = data.results || [];
renderResults(currentResults, data);
loadHistory();
})
.catch(function (err) {
showLoading(false);
setStatus('请求失败: ' + err.message);
});
}
// ===== 渲染结果 =====
function renderResults(results, data) {
if (!results || results.length === 0) {
emptyState.style.display = 'block';
resultsSection.innerHTML = '';
return;
}
emptyState.style.display = 'none';
var sourceLabel = data.source || currentSource;
var elapsed = data.elapsed || 0;
setStatus('共找到 ' + results.length + ' 条结果(' + sourceLabel + ' | ' + elapsed + 's');
document.querySelectorAll('.source-tag').forEach(function (t) {
t.classList.remove('source-active');
});
var match = document.querySelector('.source-tag[data-source="' + sourceLabel + '"]');
if (match) {
match.classList.add('source-active');
} else {
var autoTag = document.querySelector('.source-tag[data-source="auto"]');
if (autoTag) autoTag.classList.add('source-active');
}
var html = '';
results.forEach(function (item) {
var dateStr = formatDate(item.published_date || '');
var sourceTag = item.source ? '<span class="source-badge badge-ok">' + item.source + '</span>' : '';
html += '<div class="result-card">';
html += ' <div class="result-title"><a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener">' + escapeHtml(item.title) + '</a></div>';
html += ' <div class="result-content">' + escapeHtml(item.content) + '</div>';
html += ' <div class="result-meta">';
html += ' <span class="result-url">' + escapeHtml(item.url) + '</span>';
if (dateStr) html += ' <span>' + dateStr + '</span>';
html += ' ' + sourceTag;
html += ' </div>';
html += '</div>';
});
resultsSection.innerHTML = html;
}
// ===== AI 总结 =====
function doSummarize() {
if (isSummarizing) return;
// 无搜索词时取输入框内容
if (!currentQuery) {
var q = searchInput.value.trim();
if (!q) return;
currentQuery = q;
currentSource = document.querySelector('.source-tag.source-active')?.dataset.source || 'auto';
}
// 无搜索结果 → 先搜索再总结
if (!currentResults || currentResults.length === 0) {
doSearchThenSummarize();
return;
}
startSummarize();
}
function doSearchThenSummarize() {
var query = currentQuery || searchInput.value.trim();
if (!query) return;
hideSummary();
currentQuery = query;
currentResults = [];
showLoading(true);
setStatus('');
searchSection.classList.add('has-results');
searchTitle.textContent = query;
summarizeBtn.disabled = true;
summarizeBtn.textContent = '搜索中...';
fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
source: currentSource,
}),
})
.then(function (r) { return r.json(); })
.then(function (data) {
showLoading(false);
summarizeBtn.disabled = false;
summarizeBtn.textContent = 'AI 总结';
if (data.error) {
setStatus(data.error);
resultsSection.innerHTML = '';
return;
}
currentResults = data.results || [];
renderResults(currentResults, data);
loadHistory();
if (currentResults.length > 0) {
startSummarize();
}
})
.catch(function (err) {
showLoading(false);
summarizeBtn.disabled = false;
summarizeBtn.textContent = 'AI 总结';
setStatus('请求失败: ' + err.message);
});
}
function startSummarize() {
if (!currentQuery || !currentResults || currentResults.length === 0) return;
if (isSummarizing) return;
isSummarizing = true;
summaryContent.innerHTML =
'<div class="summary-input-placeholder" id="summary-placeholder">' +
' <div class="typing-indicator"><span></span><span></span><span></span></div>' +
' &nbsp;AI 总结中...' +
'</div>';
summaryMeta.textContent = '';
summarySection.style.display = 'block';
summarySection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
summarizeBtn.disabled = true;
summarizeBtn.textContent = '总结中...';
var fullSummary = '';
fetch('/api/ai-summarize/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: currentQuery,
results: currentResults,
}),
})
.then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || '请求失败'); });
var reader = r.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
function read() {
return reader.read().then(function (result) {
if (result.done) return;
buffer += decoder.decode(result.value, { stream: true });
var parts = buffer.split('\n\n');
buffer = parts.pop() || '';
parts.forEach(function (part) {
var lines = part.trim().split('\n');
var eventType = '';
var dataStr = '';
lines.forEach(function (line) {
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
else if (line.startsWith('data: ')) dataStr = line.slice(6).trim();
});
if (!dataStr) return;
try {
var parsed = JSON.parse(dataStr);
if (eventType === 'delta') {
fullSummary += parsed.content || '';
summaryContent.innerHTML = markedSummary(fullSummary);
} else if (eventType === 'meta') {
summaryMeta.textContent = '模型: ' + (parsed.model || '') + ' | 耗时 ' + parsed.elapsed + 's';
} else if (eventType === 'error') {
setStatus('AI 总结失败: ' + (parsed.error || ''));
}
} catch (e) {}
});
return read();
});
}
return read();
})
.then(function () {
isSummarizing = false;
summarizeBtn.disabled = false;
summarizeBtn.textContent = 'AI 总结';
})
.catch(function (err) {
isSummarizing = false;
summarizeBtn.disabled = false;
summarizeBtn.textContent = 'AI 总结';
summaryContent.innerHTML = '<div class="text-danger">总结失败: ' + escapeHtml(err.message) + '</div>';
});
}
function hideSummary() {
summarySection.style.display = 'none';
summaryContent.innerHTML = '';
summaryMeta.textContent = '';
isSummarizing = false;
}
// ===== 搜索历史 =====
function loadHistory() {
fetch('/api/history')
.then(function (r) { return r.json(); })
.then(function (data) {
var list = data.history || [];
if (list.length === 0) {
historyList.innerHTML = '';
return;
}
var html = '';
list.forEach(function (item) {
html += '<span class="history-tag">' + escapeHtml(item) + '</span>';
});
historyList.innerHTML = html;
document.querySelectorAll('.history-tag').forEach(function (el) {
el.addEventListener('click', function () {
searchInput.value = el.textContent;
doSearch();
});
});
});
}
// ===== 辅助 =====
function showLoading(v) { loading.style.display = v ? 'block' : 'none'; }
function setStatus(msg) { statusBar.textContent = msg; statusBar.style.display = msg ? 'block' : 'none'; }
function formatDate(dateStr) {
if (!dateStr) return '';
try {
var d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
} catch (e) { return dateStr; }
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
function markedSummary(text) {
if (!text) return '';
var lines = text.split('\n');
var html = '';
var inList = false;
lines.forEach(function (line) {
line = escapeHtml(line);
var hMatch = line.match(/^(#{1,3})\s+(.+)/);
if (hMatch) {
if (inList) { html += '</ul>'; inList = false; }
html += '<h' + hMatch[1].length + '>' + hMatch[2] + '</h' + hMatch[1].length + '>';
return;
}
var lMatch = line.match(/^[\s]*[-*+]\s+(.+)/);
if (lMatch) {
if (!inList) { html += '<ul>'; inList = true; }
html += '<li>' + lMatch[1] + '</li>';
return;
}
if (line.trim() === '') {
if (inList) { html += '</ul>'; inList = false; }
return;
}
if (inList) { html += '</ul>'; inList = false; }
line = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html += '<p>' + line + '</p>';
});
if (inList) html += '</ul>';
return html;
}
})();

7
static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long