init: Search Hub - 统一多搜索引擎聚合服务
This commit is contained in:
363
static/js/app.js
Normal file
363
static/js/app.js
Normal 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>' +
|
||||
' 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user