Spaces:
Running
Running
<html lang="zh-CN"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>交互式 Markdown 知识库处理器 (最终版)</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
<link rel="preconnect" href="https://fonts.googleapis.com"> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800&display=swap" rel="stylesheet"> | |
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
<style> | |
body { font-family: 'Inter', sans-serif; } | |
.gradient-bg { background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); } | |
.status-dot { width: 10px; height: 10px; border-radius: 50%; } | |
.status-dot.red { background-color: #ef4444; } | |
.status-dot.yellow { background-color: #f59e0b; animation: pulse 2s infinite; } | |
.status-dot.green { background-color: #22c55e; } | |
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } | |
.prose h1, .prose h2, .prose h3 { font-weight: 700; } | |
.prose p { margin-bottom: 1em; line-height: 1.6; } | |
.prose ul { list-style-type: disc; margin-left: 1.5em; } | |
.prose code { background-color: #e5e7eb; padding: 0.2em 0.4em; border-radius: 3px; font-size: 85%; } | |
.prose pre > code { background-color: transparent; padding: 0; } | |
details > summary { list-style: none; cursor: pointer; } | |
details > summary::-webkit-details-marker { display: none; } | |
details[open] summary .fa-chevron-down { transform: rotate(180deg); } | |
</style> | |
</head> | |
<body class="gradient-bg min-h-screen text-gray-800"> | |
<div class="container mx-auto px-4 py-8"> | |
<header class="text-center mb-12"> | |
<h1 class="text-4xl md:text-5xl font-bold mb-4"><span class="highlight-text relative inline-block z-10">Markdown</span> 知识库处理器</h1> | |
<p class="text-xl text-gray-600 max-w-3xl mx-auto">✨ Gemini 增强版:将静态文档转变为可对话、会总结的智能知识库。</p> | |
</header> | |
<section class="mb-16 bg-white p-6 sm:p-8 rounded-2xl shadow-lg border border-gray-200"> | |
<div class="flex justify-between items-center mb-6"> | |
<h2 class="text-3xl font-bold"><i class="fas fa-bolt text-blue-500 mr-2"></i>知识库控制台</h2> | |
<div id="status-container" class="flex items-center space-x-2"> | |
<div id="status-dot" class="status-dot red"></div> | |
<span id="status-text" class="text-gray-600 font-medium">服务未连接</span> | |
</div> | |
</div> | |
<div class="bg-yellow-50 border border-yellow-200 p-6 rounded-lg mb-8"> | |
<h3 class="font-bold text-xl mb-4 text-yellow-800"><i class="fas fa-key mr-2"></i>API 密钥配置</h3> | |
<p class="text-gray-700 mb-4">请输入您的应用专属 API 密钥以授权访问。</p> | |
<div class="flex flex-col sm:flex-row gap-4"> | |
<input type="password" id="apiKeyInput" class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-yellow-500" placeholder="在此输入您的 API 密钥"> | |
<button id="saveApiKeyButton" class="bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-6 rounded-lg transition-colors shadow flex-shrink-0"> | |
<i class="fas fa-save mr-2"></i>保存密钥 | |
</button> | |
</div> | |
<p id="apiKeyMessage" class="text-sm text-gray-600 mt-3 h-5"></p> | |
</div> | |
<div class="bg-gray-50 p-6 rounded-lg mb-8 border"> | |
<h3 class="font-bold text-xl mb-2">1. 构建知识库</h3> | |
<p class="text-gray-600 mb-4">输入 Markdown 文件夹的本地绝对路径。</p> | |
<div class="flex flex-col sm:flex-row gap-4"> | |
<input type="text" id="folderPathInput" class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" placeholder="例如: C:\Users\YourName\Documents\Notes"> | |
<button id="buildButton" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-6 rounded-lg transition-colors shadow flex-shrink-0" disabled> | |
<i class="fas fa-hammer mr-2"></i>开始构建 | |
</button> | |
</div> | |
<div class="mt-2"> | |
<input id="clearExistingCheckbox" type="checkbox" class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"> | |
<label for="clearExistingCheckbox" class="ml-2 text-sm text-gray-700">在构建前清空现有知识库</label> | |
</div> | |
<p id="build-message" class="text-sm text-gray-500 mt-3 h-5"></p> | |
<details class="mt-4"> | |
<summary class="font-medium text-indigo-600"> | |
高级构建设置 <i class="fas fa-chevron-down ml-1 text-sm transition-transform"></i> | |
</summary> | |
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t mt-2"> | |
<div><label for="chunkSizeInput" class="block text-sm font-medium text-gray-700">块大小</label><input type="number" id="chunkSizeInput" value="4096" class="mt-1 block w-full p-2 border border-gray-300 rounded-md"></div> | |
<div><label for="overlapInput" class="block text-sm font-medium text-gray-700">重叠大小</label><input type="number" id="overlapInput" value="400" class="mt-1 block w-full p-2 border border-gray-300 rounded-md"></div> | |
<div><label for="maxFilesInput" class="block text-sm font-medium text-gray-700">最大文件数</label><input type="number" id="maxFilesInput" value="500" class="mt-1 block w-full p-2 border border-gray-300 rounded-md"></div> | |
<div><label for="sampleModeInput" class="block text-sm font-medium text-gray-700">采样模式</label><select id="sampleModeInput" class="mt-1 block w-full p-2 border-gray-300 rounded-md"><option value="largest">最大的</option><option value="random">随机</option><option value="recent">最新的</option></select></div> | |
</div> | |
</details> | |
</div> | |
<div class="bg-gray-50 p-6 rounded-lg mb-8 border"> | |
<h3 class="font-bold text-xl mb-2">2. 搜索知识库</h3> | |
<div class="relative"> | |
<input type="text" id="searchInput" class="w-full pl-4 pr-12 py-3 border-2 border-gray-300 rounded-lg" placeholder="输入问题开始搜索..." disabled> | |
<button id="searchButton" class="absolute inset-y-0 right-0 px-4 text-gray-600" disabled><i class="fas fa-search text-xl"></i></button> | |
</div> | |
<details class="mt-4"> | |
<summary class="font-medium text-blue-600"> | |
搜索设置 <i class="fas fa-chevron-down ml-1 text-sm transition-transform"></i> | |
</summary> | |
<div class="flex items-center gap-8 pt-4 border-t mt-2"> | |
<div><label for="topKInput" class="block text-sm font-medium text-gray-700">返回结果数</label><input type="number" id="topKInput" value="5" class="mt-1 block w-full p-2 border border-gray-300 rounded-md"></div> | |
</div> | |
</details> | |
</div> | |
<div id="summarySection" class="hidden"><div class="flex justify-between items-center mb-4"><h3 class="font-bold text-xl">✨ AI 智能总结</h3><button id="summarizeButton" class="bg-gradient-to-r from-purple-500 to-blue-500 text-white font-bold py-2 px-4 rounded-lg"><i class="fas fa-magic-wand-sparkles mr-2"></i>生成智能总结</button></div><div id="summaryResultCard" class="bg-blue-50 border-l-4 border-blue-400 p-4 rounded-r-lg"></div></div> | |
<div id="loadingIndicator" class="hidden text-center mt-8"><i class="fas fa-spinner fa-spin text-3xl text-blue-500"></i><p class="mt-2">正在检索...</p></div> | |
<div id="searchResults" class="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6"></div> | |
</section> | |
</div> | |
<script> | |
document.addEventListener('DOMContentLoaded', () => { | |
const API_BASE_URL = 'http://127.0.0.1:5000'; | |
const el = id => document.getElementById(id); | |
const statusDot = el('status-dot'), statusText = el('status-text'); | |
const apiKeyInput = el('apiKeyInput'), saveApiKeyButton = el('saveApiKeyButton'), apiKeyMessage = el('apiKeyMessage'); | |
const folderPathInput = el('folderPathInput'), buildButton = el('buildButton'), buildMessage = el('build-message'), clearExistingCheckbox = el('clearExistingCheckbox'); | |
const chunkSizeInput = el('chunkSizeInput'), overlapInput = el('overlapInput'), maxFilesInput = el('maxFilesInput'), sampleModeInput = el('sampleModeInput'); | |
const searchInput = el('searchInput'), searchButton = el('searchButton'), topKInput = el('topKInput'); | |
const summarySection = el('summarySection'), summarizeButton = el('summarizeButton'), summaryResultCard = el('summaryResultCard'); | |
const loadingIndicator = el('loadingIndicator'), searchResultsContainer = el('searchResults'); | |
let lastSearchResults = []; | |
let statusInterval; | |
const saveApiKey = () => { | |
const key = apiKeyInput.value.trim(); | |
if (key) { | |
localStorage.setItem('knowledgeBaseApiKey', key); | |
apiKeyMessage.textContent = '密钥已保存到浏览器。'; | |
apiKeyMessage.style.color = 'green'; | |
} else { | |
apiKeyMessage.textContent = '请输入有效的密钥。'; | |
apiKeyMessage.style.color = 'red'; | |
} | |
setTimeout(() => apiKeyMessage.textContent = '', 3000); | |
}; | |
const loadApiKey = () => { | |
const key = localStorage.getItem('knowledgeBaseApiKey'); | |
if (key) { | |
apiKeyInput.value = key; | |
apiKeyMessage.textContent = '已从本地加载密钥。'; | |
setTimeout(() => apiKeyMessage.textContent = '', 3000); | |
} | |
}; | |
const getAuthHeaders = (isGetRequest = false) => { | |
const key = localStorage.getItem('knowledgeBaseApiKey'); | |
const headers = {}; | |
if (!isGetRequest) { | |
headers['Content-Type'] = 'application/json'; | |
} | |
if (key) { | |
headers['X-API-Key'] = key; | |
} else { | |
console.warn("API Key not found in localStorage."); | |
} | |
return headers; | |
}; | |
const updateStatus = async () => { | |
try { | |
const response = await fetch(`${API_BASE_URL}/status`); | |
if (!response.ok) throw new Error('Network response was not ok'); | |
const data = await response.json(); | |
statusText.textContent = data.message; | |
statusDot.className = 'status-dot'; | |
const isReadyForSearch = data.is_built && !data.is_building; | |
const isReadyForBuild = !data.is_building; | |
searchInput.disabled = !isReadyForSearch; | |
searchButton.disabled = !isReadyForSearch; | |
buildButton.disabled = !isReadyForBuild; | |
if (data.is_building) { | |
statusDot.classList.add('yellow'); | |
buildButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>构建中...'; | |
} else { | |
buildButton.innerHTML = '<i class="fas fa-hammer mr-2"></i>开始构建'; | |
statusDot.classList.add(data.is_built ? 'green' : 'red'); | |
} | |
} catch (error) { | |
statusText.textContent = '服务连接失败'; | |
statusDot.className = 'status-dot red'; | |
searchInput.disabled = true; | |
searchButton.disabled = true; | |
buildButton.disabled = true; | |
if(statusInterval) clearInterval(statusInterval); | |
} | |
}; | |
const handleBuild = async () => { | |
const folderPath = folderPathInput.value.trim(); | |
if (!folderPath) { buildMessage.textContent = '错误:文件夹路径不能为空。'; return; } | |
const buildParams = { | |
folder_path: folderPath, | |
clear_existing: clearExistingCheckbox.checked, | |
chunk_size: parseInt(chunkSizeInput.value, 10) || 4096, | |
overlap: parseInt(overlapInput.value, 10) || 400, | |
max_files: parseInt(maxFilesInput.value, 10) || 500, | |
sample_mode: sampleModeInput.value, | |
}; | |
buildMessage.textContent = '已发送构建请求...'; | |
try { | |
const response = await fetch(`${API_BASE_URL}/build`, { | |
method: 'POST', | |
headers: getAuthHeaders(), | |
body: JSON.stringify(buildParams), | |
}); | |
const result = await response.json(); | |
if (!response.ok) throw new Error(result.error || '构建请求失败'); | |
buildMessage.textContent = result.message; | |
updateStatus(); | |
} catch (error) { buildMessage.textContent = `错误: ${error.message}`; } | |
}; | |
const performSearch = async () => { | |
const query = searchInput.value.trim(); | |
if (!query) return; | |
summarySection.classList.add('hidden'); | |
summaryResultCard.innerHTML = ''; | |
searchResultsContainer.innerHTML = ''; | |
loadingIndicator.classList.remove('hidden'); | |
const searchUrl = new URL(`${API_BASE_URL}/search`); | |
searchUrl.searchParams.append('query', query); | |
searchUrl.searchParams.append('top_k', topKInput.value || 5); | |
try { | |
const response = await fetch(searchUrl, { method: 'GET', headers: getAuthHeaders(true) }); | |
if (response.status === 403) throw new Error('授权失败。请检查 API 密钥是否正确。'); | |
const results = await response.json(); | |
if (!response.ok) throw new Error(results.error || '搜索失败'); | |
lastSearchResults = results; | |
displayResults(results, query); | |
if (results.length > 0) { | |
summarySection.classList.remove('hidden'); | |
} | |
} catch (error) { | |
searchResultsContainer.innerHTML = `<p class="text-center text-red-500 md:col-span-2">搜索出错: ${error.message}</p>`; | |
} finally { | |
loadingIndicator.classList.add('hidden'); | |
} | |
}; | |
const displayResults = (results, query) => { | |
if (!results || results.length === 0) { | |
searchResultsContainer.innerHTML = `<p class="text-center text-gray-500 md:col-span-2">未找到与 "${query}" 相关的结果。</p>`; | |
return; | |
} | |
searchResultsContainer.innerHTML = results.map(result => { | |
const distance = typeof result.distance === 'number' ? result.distance : 2.0; | |
const similarity = Math.max(0, 1 - distance / 2); // Normalize score to be more intuitive | |
const fileName = result.metadata?.file_name || '未知文件'; | |
const sourcePath = result.metadata?.source || fileName; | |
const sanitizedContent = result.content.replace(/</g, "<").replace(/>/g, ">"); | |
return ` | |
<div class="bg-white border border-gray-200 rounded-lg p-4 transition-all hover:shadow-md"> | |
<div class="flex justify-between items-center mb-3"> | |
<h4 class="font-bold text-blue-700 truncate pr-4" title="${sourcePath}">${fileName}</h4> | |
<span class="text-xs font-medium bg-blue-100 text-blue-800 py-1 px-2 rounded-full flex-shrink-0">相似度: ${similarity.toFixed(4)}</span> | |
</div> | |
<p class="text-gray-600 text-sm break-words">${sanitizedContent}</p> | |
</div>`; | |
}).join(''); | |
}; | |
const handleSummarize = async () => { | |
if (lastSearchResults.length === 0) return; | |
summarizeButton.disabled = true; | |
summarizeButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>AI 正在思考...'; | |
summaryResultCard.innerHTML = '<p class="text-gray-600">请稍候,正在为您生成总结...</p>'; | |
try { | |
const response = await fetch(`${API_BASE_URL}/summarize`, { | |
method: 'POST', | |
headers: getAuthHeaders(), | |
body: JSON.stringify({ query: searchInput.value, results: lastSearchResults }), | |
}); | |
if (response.status === 403) throw new Error('授权失败。请检查 API 密钥。'); | |
const data = await response.json(); | |
if (!response.ok) throw new Error(data.error || '总结生成失败'); | |
summaryResultCard.innerHTML = marked.parse(data.summary); | |
summaryResultCard.classList.add('prose'); | |
} catch (error) { | |
summaryResultCard.innerHTML = `<p class="text-red-500">生成总结时出错: ${error.message}</p>`; | |
} finally { | |
summarizeButton.disabled = false; | |
summarizeButton.innerHTML = '<i class="fas fa-magic-wand-sparkles mr-2"></i>重新生成总结'; | |
} | |
}; | |
saveApiKeyButton.addEventListener('click', saveApiKey); | |
buildButton.addEventListener('click', handleBuild); | |
searchButton.addEventListener('click', performSearch); | |
searchInput.addEventListener('keyup', e => e.key === 'Enter' && performSearch()); | |
summarizeButton.addEventListener('click', handleSummarize); | |
loadApiKey(); | |
updateStatus(); | |
statusInterval = setInterval(updateStatus, 5000); | |
}); | |
</script> | |
</body> | |
</html> |