Browse Source

一期-前端适配

master
wanghanlin 2 days ago
parent
commit
97ac5c6ad9
  1. 674
      frontend.html
  2. 684
      src/main/resources/static/frontend.html

674
frontend.html

@ -26,7 +26,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.tab-icon { font-size:18px; }
/* 内容区 */
.main { max-width:1200px; margin:0 auto; padding:20px; }
.main { max-width:1400px; margin:0 auto; padding:20px; }
.tab-panel { display:none; animation: fadeIn .3s ease; }
.tab-panel.active { display:block; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
@ -55,6 +55,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.btn-outline { background:#fff; color:var(--primary); border:1px solid var(--primary); }
.btn-sm { padding:6px 12px; font-size:12px; }
.btn-danger { background:var(--danger); color:#fff; }
.btn-warn { background:var(--warn); color:#fff; }
/* 消息区 */
.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fafbfc; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; }
@ -94,8 +95,47 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.stream-compare { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.stream-compare .card { margin-bottom:0; }
/* 表格样式 */
.data-table { width:100%; border-collapse:collapse; font-size:13px; }
.data-table th { background:#f9fafb; padding:10px 12px; text-align:left; font-weight:600; border-bottom:2px solid var(--border); white-space:nowrap; }
.data-table td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:middle; }
.data-table tr:hover { background:#f9fafb; }
.data-table .status-ready { color:var(--success); font-weight:600; }
.data-table .status-processing { color:var(--warn); font-weight:600; }
.data-table .status-failed { color:var(--danger); font-weight:600; }
/* 分页 */
.pagination { display:flex; gap:4px; justify-content:center; margin-top:12px; }
.pagination button { padding:6px 12px; border:1px solid var(--border); background:#fff; border-radius:6px; cursor:pointer; font-size:12px; }
.pagination button:hover:not(:disabled) { background:var(--primary); color:#fff; border-color:var(--primary); }
.pagination button:disabled { opacity:.5; cursor:not-allowed; }
.pagination button.active { background:var(--primary); color:#fff; border-color:var(--primary); }
/* 统计卡片 */
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; }
.stat-card { background:#f9fafb; border-radius:8px; padding:16px; text-align:center; border:1px solid var(--border); }
.stat-card .number { font-size:28px; font-weight:700; color:var(--primary); }
.stat-card .label { font-size:12px; color:var(--sub); margin-top:4px; }
/* 弹窗 */
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:200; display:none; align-items:center; justify-content:center; }
.modal-overlay.active { display:flex; }
.modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; }
.modal-box h2 { margin-bottom:16px; font-size:18px; }
.modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; }
.modal-close:hover { color:var(--text); }
/* 分类标签 */
.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#eef2ff; color:var(--primary); }
/* 搜索结果 */
.search-result { background:#f9fafb; border-radius:8px; padding:12px; margin-bottom:8px; border:1px solid var(--border); }
.search-result .score { font-size:12px; color:var(--success); font-weight:600; }
.search-result .meta { font-size:12px; color:var(--sub); margin-top:4px; }
.search-result .content { font-size:13px; margin-top:6px; line-height:1.5; }
/* 响应式 */
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } }
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } }
.badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; }
.badge-get { background:#dbeafe; color:#1d4ed8; }
@ -107,7 +147,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<div class="topbar">
<span class="logo">🤖 Support Bot</span><span class="ver">AI 智能客服系统</span>
<span class="links">
<a href="http://localhost:8080/doc.html" target="_blank">📖 API 文档</a>
<a href="/doc.html" target="_blank">📖 API 文档</a>
</span>
</div>
@ -137,10 +177,28 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<button class="btn btn-outline btn-sm" onclick="clearMessages()">🗑️ 清屏</button>
</div>
<div class="input-row" style="margin-top:8px;padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;">
<input type="checkbox" id="ragModeToggle" onchange="toggleRagMode()" style="width:16px;height:16px;cursor:pointer;">
<span style="font-weight:600;color:var(--primary);">📚 启用 RAG 知识库检索</span>
</label>
<select class="select" id="ragStrategySelect" style="display:none;margin-left:auto;" onchange="updateRagStatus()">
<option value="NONE">无重写</option>
<option value="REWRITE" selected>查询重写</option>
<option value="TRANSLATION">翻译扩展</option>
<option value="COMPRESSION">查询压缩</option>
<option value="MULTI_QUERY">多路扩展</option>
</select>
</div>
<div id="ragStatusTip" style="display:none;margin-bottom:12px;padding:8px 12px;background:#eef2ff;border-radius:6px;font-size:12px;color:var(--primary);border-left:3px solid var(--primary);">
💡 当前已启用 RAG 知识库检索,将从向量数据库中检索相关信息以增强回答质量。
</div>
<div class="msg-area" id="chatMessages">
<div class="msg assistant">
<div class="msg-avatar">🤖</div>
<div class="msg-bubble">您好!我是电商智能客服助手。<br>可以帮您解答商品、订单、支付、物流和售后问题。<br><br>💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。</div>
<div class="msg-bubble">您好!我是电商智能客服助手。<br>可以帮您解答商品、订单、支付、物流和售后问题。<br><br>💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。<br>📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。</div>
</div>
</div>
@ -180,9 +238,92 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- ==================== Tab 3: 知识库文档管理 ==================== -->
<div class="tab-panel" id="panel-document">
<!-- 统计面板 -->
<div class="card">
<h2>📊 知识库概览</h2>
<div class="stat-grid" id="statsGrid">
<div class="stat-card"><div class="number" id="statTotalDocs">-</div><div class="label">文档总数</div></div>
<div class="stat-card"><div class="number" id="statTotalVectors">-</div><div class="label">向量总数</div></div>
<div class="stat-card"><div class="number" id="statLastUpload">-</div><div class="label">最近上传</div></div>
</div>
<div id="statsDetail" style="font-size:13px;color:var(--sub);"></div>
</div>
<!-- 语义搜索 -->
<div class="card">
<h2>🔍 语义搜索测试</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句,测试知识库检索效果</p>
<div class="input-row">
<input class="input" id="searchQuery" placeholder="输入查询,如:Spring Boot 是什么?">
<input class="input input-sm" id="searchTopK" placeholder="TopK" value="5" type="number" min="1" max="20">
<input class="input input-sm" id="searchThreshold" placeholder="阈值" value="0.5" type="number" min="0" max="1" step="0.1">
<button class="btn btn-primary" onclick="doSearch()">🔍 搜索</button>
</div>
<div id="searchResults" style="margin-top:12px;"></div>
</div>
<!-- 分类管理 -->
<div class="card">
<h2>📄 知识库文档管理</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector,即可用于 AI 检索问答</p>
<h2>🏷️ 分类管理</h2>
<div class="input-row">
<input class="input" id="catName" placeholder="分类名称">
<input class="input" id="catDesc" placeholder="分类描述(可选)">
<input class="input input-sm" id="catSort" placeholder="排序" value="0" type="number">
<button class="btn btn-success" onclick="createCategory()">➕ 创建分类</button>
<button class="btn btn-outline btn-sm" onclick="loadCategories()">🔄 刷新</button>
</div>
<div id="categoryList" style="margin-top:12px;font-size:13px;">暂无分类</div>
</div>
<!-- 文档列表 -->
<div class="card">
<h2>📋 文档列表</h2>
<div class="input-row">
<select class="select" id="filterCategory" onchange="loadDocuments()">
<option value="">全部分类</option>
</select>
<select class="select" id="filterStatus" onchange="loadDocuments()">
<option value="">全部状态</option>
<option value="READY">✅ 已完成</option>
<option value="PROCESSING">⏳ 处理中</option>
<option value="FAILED">❌ 失败</option>
</select>
<button class="btn btn-outline btn-sm" onclick="loadDocuments()">🔄 刷新</button>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="docTable">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>类型</th>
<th>状态</th>
<th>分块数</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="docTableBody">
<tr><td colspan="7" style="text-align:center;color:var(--sub);">点击刷新加载文档</td></tr>
</tbody>
</table>
</div>
<div class="pagination" id="docPagination"></div>
</div>
<!-- 文档上传 -->
<div class="card">
<h2>📤 文档上传</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector</p>
<!-- 上传元信息 -->
<div class="input-row" style="padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<select class="select" id="uploadCategory">
<option value="">选择分类(可选)</option>
</select>
<input class="input" id="uploadTags" placeholder="标签,逗号分隔(可选)">
</div>
<!-- 子 Tab -->
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;">
@ -196,7 +337,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- 通用文件 -->
<div class="doc-panel active" id="doc-file">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/file</code><span style="font-size:12px;color:var(--sub);">(Tika 多格式解析)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">(Tika 多格式解析)</span></div>
<div class="upload-zone" id="zone-file" onclick="document.getElementById('fileInput').click()">
<div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)</p>
<input type="file" id="fileInput" style="display:none" multiple onchange="handleFileSelect(this,'file')">
@ -208,15 +349,17 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- 文本内容 -->
<div class="doc-panel" id="doc-string" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/string</code><span style="font-size:12px;color:var(--sub);">(直接上传文本)</span></div>
<textarea class="textarea" id="stringContent" placeholder="输入要加入知识库的文本内容...&#10;例如:公司退换货政策、商品FAQ、物流说明等"></textarea>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/string</code><span style="font-size:12px;color:var(--sub);">(直接上传文本)</span></div>
<input class="input" id="stringTitle" placeholder="文档标题" style="margin-bottom:8px;">
<textarea class="textarea" id="stringContent" placeholder="输入要加入知识库的文本内容...
例如:公司退换货政策、商品FAQ、物流说明等"></textarea>
<button class="btn btn-primary" style="margin-top:12px;" onclick="uploadDocument('string')">🚀 上传并向量化</button>
<div id="doc-string-result"></div>
</div>
<!-- Markdown -->
<div class="doc-panel" id="doc-markdown" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/markdown</code></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/markdown</code></div>
<div class="upload-zone" id="zone-md" onclick="document.getElementById('mdInput').click()">
<div class="icon">📑</div><p>点击或拖拽上传,支持多文件(Markdown .md)</p>
<input type="file" id="mdInput" style="display:none" accept=".md" multiple onchange="handleFileSelect(this,'markdown')">
@ -228,7 +371,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- JSON 基本 -->
<div class="doc-panel" id="doc-jsonBasic" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/json/basic</code><span style="font-size:12px;color:var(--sub);">(整体解析)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/basic</code><span style="font-size:12px;color:var(--sub);">(整体解析)</span></div>
<div class="upload-zone" id="zone-jsonB" onclick="document.getElementById('jsonBInput').click()">
<div class="icon">📋</div><p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" id="jsonBInput" style="display:none" accept=".json" multiple onchange="handleFileSelect(this,'jsonBasic')">
@ -240,7 +383,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- JSON 按字段 -->
<div class="doc-panel" id="doc-jsonFields" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/json/fields</code><span style="font-size:12px;color:var(--sub);">(按字段名提取)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/fields</code><span style="font-size:12px;color:var(--sub);">(按字段名提取)</span></div>
<div class="upload-zone" id="zone-jsonF" onclick="document.getElementById('jsonFInput').click()">
<div class="icon">🔑</div><p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" id="jsonFInput" style="display:none" accept=".json" multiple onchange="handleFileSelect(this,'jsonFields')">
@ -253,7 +396,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- JSON 按指针 -->
<div class="doc-panel" id="doc-jsonPointer" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">(JSON Pointer 路径拆分)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">(JSON Pointer 路径拆分)</span></div>
<div class="upload-zone" id="zone-jsonP" onclick="document.getElementById('jsonPInput').click()">
<div class="icon">📍</div><p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" id="jsonPInput" style="display:none" accept=".json" multiple onchange="handleFileSelect(this,'jsonPointer')">
@ -265,21 +408,28 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
</div>
</div>
<!-- 上传历史 -->
<div class="card">
<h2>📋 最近上传记录</h2>
<div id="uploadLog" style="font-size:13px;color:var(--sub);">暂无上传记录</div>
</div>
</div>
<!-- 文档详情弹窗 -->
<div class="modal-overlay" id="docModal">
<div class="modal-box">
<button class="modal-close" onclick="closeDocModal()">&times;</button>
<h2>📄 文档详情</h2>
<div id="docModalContent"></div>
</div>
</div>
<div class="toast-container" id="toasts"></div>
<script>
// ==================== 全局状态 ====================
const API = 'http://localhost:8080';
const API = 'http://localhost:9090';
let currentChatId = '';
let isRagMode = false;
let currentDocPage = 1;
let categoryMap = {}; // id -> name
// ==================== 工具函数 ====================
function toast(msg, type='info') {
@ -295,6 +445,12 @@ function $(id) { return document.getElementById(id); }
function formatBytes(b) { return b<1024?b+' B':b<1048576?(b/1024).toFixed(1)+' KB':(b/1048576).toFixed(1)+' MB'; }
function formatDate(d) {
if(!d) return '-';
const date = new Date(d);
return date.toLocaleString('zh-CN');
}
// ==================== Tab 切换 ====================
document.getElementById('tabs').addEventListener('click', function(e) {
const btn = e.target.closest('.tab-btn');
@ -303,6 +459,11 @@ document.getElementById('tabs').addEventListener('click', function(e) {
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.getElementById('panel-'+btn.dataset.tab).classList.add('active');
if(btn.dataset.tab === 'document') {
loadStats();
loadCategories();
loadDocuments();
}
});
// 子 Tab 切换 (文档管理)
@ -319,13 +480,9 @@ document.addEventListener('click', function(e) {
// ==================== 初始化 ====================
function init() {
newChatId();
// 初始化子tab样式
const firstSub = document.querySelector('.doc-sub-tab.active');
if(firstSub) { firstSub.style.background='var(--primary)'; firstSub.style.color='#fff'; }
// 隐藏非活跃doc panel
document.querySelectorAll('.doc-panel').forEach((p,i)=>{ if(i>0) p.style.display='none'; });
// 加载后端上传记录
loadUploadHistory();
}
function newChatId() {
currentChatId = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2,6);
@ -337,43 +494,56 @@ function clearMessages() {
$('chatMessages').innerHTML = '<div class="msg assistant"><div class="msg-avatar">🤖</div><div class="msg-bubble">已清屏,开始新的对话吧~</div></div>';
}
// ==================== RAG 模式切换 ====================
function toggleRagMode() {
isRagMode = $('ragModeToggle').checked;
$('ragStrategySelect').style.display = isRagMode ? 'block' : 'none';
$('ragStatusTip').style.display = isRagMode ? 'block' : 'none';
if (isRagMode) updateRagStatus();
}
function updateRagStatus() {
if (!isRagMode) return;
const strategy = $('ragStrategySelect').value;
const names = {'NONE':'无重写','REWRITE':'查询重写','TRANSLATION':'翻译扩展','COMPRESSION':'查询压缩','MULTI_QUERY':'多路扩展'};
$('ragStatusTip').innerHTML = `💡 当前已启用 RAG 知识库检索(策略:<strong>${names[strategy]||strategy}</strong>)`;
}
// ==================== Tab 1: AI 对话 ====================
function addMsg(role, content, isStreaming) {
const el = document.createElement('div');
el.className = 'msg ' + role + (isStreaming?' streaming':'');
el.innerHTML = '<div class="msg-avatar">'+(role==='user'?'👤':'🤖')+'</div><div class="msg-bubble"></div>';
$('chatMessages').appendChild(el);
const bubble = el.querySelector('.msg-bubble');
bubble.textContent = content;
const area = $('chatMessages');
area.scrollTop = area.scrollHeight;
return el.querySelector('.msg-bubble');
return bubble;
}
async function chatSend() {
const input = $('userInput');
const message = input.value.trim();
if(!message) return;
input.value = ''; input.disabled = true; $('sendBtn').disabled = true;
addMsg('user', message);
const bubble = addMsg('assistant', '', true);
const mode = $('modeSelect').value;
const chatId = $('chatId').value || currentChatId;
try {
if (isRagMode && mode === 'sync') {
await chatRagSync(message, chatId, bubble);
} else if (isRagMode && mode !== 'sync') {
bubble.textContent = '⚠️ RAG 模式仅支持同步调用';
toast('RAG 模式暂不支持流式调用', 'error');
} else {
switch(mode) {
case 'sync':
await chatSync(message, chatId, bubble);
break;
case 'sse':
await chatSSE(message, chatId, bubble);
break;
case 'sse2':
await chatServerSentEvent(message, chatId, bubble);
break;
case 'sse3':
await chatSseEmitter(message, chatId, bubble);
break;
case 'sync': await chatSync(message, chatId, bubble); break;
case 'sse': await chatSSE(message, chatId, bubble); break;
case 'sse2': await chatServerSentEvent(message, chatId, bubble); break;
case 'sse3': await chatSseEmitter(message, chatId, bubble); break;
}
}
} catch(e) {
bubble.textContent = '请求失败:' + e.message;
@ -385,34 +555,26 @@ async function chatSend() {
}
}
// 接口1:同步对话
async function chatSync(message, chatId, bubble) {
const url = `${API}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try {
const body = await res.json();
if(body.error) errMsg = body.error;
if(body.message) errMsg = body.message;
} catch(_) {
try { const text = await res.text(); if(text) errMsg = text.substring(0,200); } catch(__) {}
}
throw new Error(errMsg);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
bubble.textContent = await res.text();
}
async function chatRagSync(message, chatId, bubble) {
const strategy = $('ragStrategySelect').value;
const url = `${API}/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`;
const res = await fetch(url);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
bubble.textContent = await res.text();
}
// 接口2:SSE 流式 (Flux)
async function chatSSE(message, chatId, bubble) {
bubble.textContent = '';
const url = `${API}/ai/assistant_app/chat/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try { const t = await res.text(); errMsg = t.substring(0,200); } catch(_) {}
throw new Error(errMsg);
}
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@ -434,16 +596,11 @@ async function chatSSE(message, chatId, bubble) {
}
}
// 接口3:ServerSentEvent 流式
async function chatServerSentEvent(message, chatId, bubble) {
bubble.textContent = '';
const url = `${API}/ai/assistant_app/chat/server_sent_event?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try { const t = await res.text(); errMsg = t.substring(0,200); } catch(_) {}
throw new Error(errMsg);
}
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@ -463,16 +620,11 @@ async function chatServerSentEvent(message, chatId, bubble) {
}
}
// 接口4:SseEmitter 流式
async function chatSseEmitter(message, chatId, bubble) {
bubble.textContent = '';
const url = `${API}/ai/assistant_app/chat/sse_emitter?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try { const t = await res.text(); errMsg = t.substring(0,200); } catch(_) {}
throw new Error(errMsg);
}
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@ -495,25 +647,20 @@ async function chatSseEmitter(message, chatId, bubble) {
}
// ==================== Tab 2: 商品信息提取 ====================
// 接口5:商品信息提取
async function extractProduct() {
const content = $('productContent').value.trim();
if(!content) { toast('请输入商品描述内容', 'error'); return; }
const btn = $('extractBtn');
btn.disabled = true; btn.textContent = '⏳ 提取中...';
try {
const url = `${API}/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`;
const res = await fetch(url);
if(!res.ok) throw new Error('HTTP '+res.status);
const text = await res.text();
const data = JSON.parse(text);
$('productResult').style.display = 'block';
const fields = ['title','description','price','rating','reviewCount','brand','category'];
const labels = {title:'商品标题',description:'描述',price:'价格',rating:'评分',reviewCount:'评论数',brand:'品牌',category:'分类'};
let html = '';
for(const f of fields) {
const val = data[f] !== null && data[f] !== undefined ? data[f] : '—';
@ -524,78 +671,355 @@ async function extractProduct() {
toast('商品信息提取成功!', 'success');
} catch(e) {
toast('提取失败:'+e.message, 'error');
// 也尝试显示原始返回
try {
const url = `${API}/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`;
const res = await fetch(url);
const text = await res.text();
$('productResult').style.display = 'block';
$('productGrid').innerHTML = '<div class="result-item"><div class="label">原始返回</div><div class="value">解析失败</div></div>';
$('productJson').textContent = text;
} catch(_) {}
} finally {
btn.disabled = false; btn.textContent = '🔍 提取商品信息';
}
}
// ==================== 知识库管理: 统计面板 ====================
async function loadStats() {
try {
const res = await fetch(`${API}/document/stats`);
const json = await res.json();
if(!json.success) return;
const d = json.data;
$('statTotalDocs').textContent = d.totalDocuments || 0;
$('statTotalVectors').textContent = d.totalVectors || 0;
$('statLastUpload').textContent = d.lastUploadTime ? formatDate(d.lastUploadTime) : '-';
let detail = '';
if(d.byFileType && Object.keys(d.byFileType).length > 0) {
detail += '文件类型: ' + Object.entries(d.byFileType).map(([k,v])=>`${k}: ${v}`).join(', ');
}
$('statsDetail').textContent = detail;
} catch(e) { console.error('加载统计失败', e); }
}
// ==================== 知识库管理: 分类管理 ====================
async function loadCategories() {
try {
const res = await fetch(`${API}/category/list`);
const json = await res.json();
if(!json.success) return;
const list = json.data || [];
categoryMap = {};
list.forEach(c => { categoryMap[c.id] = c.name; });
// 更新分类下拉框
const selects = [$('filterCategory'), $('uploadCategory')];
selects.forEach(sel => {
if(!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">' + (sel.id === 'filterCategory' ? '全部分类' : '选择分类(可选)') + '</option>';
list.forEach(c => {
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
sel.value = val;
});
// 更新分类列表
if(list.length === 0) {
$('categoryList').innerHTML = '暂无分类';
} else {
$('categoryList').innerHTML = list.map(c =>
`<div style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span><strong>${c.name}</strong> <span style="color:var(--sub);font-size:12px;">${c.description||''}</span></span>
<button class="btn btn-danger btn-sm" onclick="deleteCategory(${c.id})">删除</button>
</div>`
).join('');
}
} catch(e) { console.error('加载分类失败', e); }
}
async function createCategory() {
const name = $('catName').value.trim();
if(!name) { toast('请输入分类名称', 'error'); return; }
try {
const res = await fetch(`${API}/category`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name: name,
description: $('catDesc').value.trim(),
sortOrder: parseInt($('catSort').value) || 0
})
});
const json = await res.json();
if(json.success) {
toast('分类创建成功', 'success');
$('catName').value = '';
$('catDesc').value = '';
loadCategories();
} else {
toast(json.message || '创建失败', 'error');
}
} catch(e) { toast('创建分类失败:'+e.message, 'error'); }
}
async function deleteCategory(id) {
if(!confirm('确定删除此分类?关联文档将变为未分类')) return;
try {
const res = await fetch(`${API}/category/${id}`, {method:'DELETE'});
const json = await res.json();
if(json.success) {
toast('分类已删除', 'success');
loadCategories();
loadDocuments();
} else {
toast(json.message || '删除失败', 'error');
}
} catch(e) { toast('删除分类失败:'+e.message, 'error'); }
}
// ==================== 知识库管理: 文档列表 ====================
async function loadDocuments(page=1) {
currentDocPage = page;
const catId = $('filterCategory').value;
const status = $('filterStatus').value;
let url = `${API}/document/list?page=${page}&size=10`;
if(catId) url += `&categoryId=${catId}`;
if(status) url += `&status=${status}`;
try {
const res = await fetch(url);
const json = await res.json();
if(!json.success) {
$('docTableBody').innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--danger);">${json.message}</td></tr>`;
return;
}
const docs = json.data || [];
const total = json.total || 0;
const pages = json.pages || 1;
if(docs.length === 0) {
$('docTableBody').innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--sub);">暂无文档</td></tr>';
} else {
$('docTableBody').innerHTML = docs.map(d => {
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
return `<tr>
<td>${d.id}</td>
<td><strong>${d.title}</strong><br><span style="font-size:11px;color:var(--sub);">${d.sourceName||''}</span></td>
<td><span class="category-tag">${d.fileType}</span></td>
<td><span class="${statusClass}">${d.status}</span></td>
<td>${d.chunkCount}</td>
<td>${formatDate(d.createTime)}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="viewDocDetail(${d.id})">查看</button>
<button class="btn btn-sm btn-warn" onclick="reprocessDoc(${d.id})">重新处理</button>
<button class="btn btn-sm btn-danger" onclick="deleteDoc(${d.id})">删除</button>
</td>
</tr>`;
}).join('');
}
// 分页
let phtml = '';
if(pages > 1) {
phtml += `<button ${page<=1?'disabled':''} onclick="loadDocuments(${page-1})">上一页</button>`;
for(let i=1;i<=pages;i++) {
if(i===1 || i===pages || (i>=page-2 && i<=page+2)) {
phtml += `<button class="${i===page?'active':''}" onclick="loadDocuments(${i})">${i}</button>`;
} else if(i===page-3 || i===page+3) {
phtml += `<span style="padding:6px;">...</span>`;
}
}
phtml += `<button ${page>=pages?'disabled':''} onclick="loadDocuments(${page+1})">下一页</button>`;
}
$('docPagination').innerHTML = phtml;
} catch(e) {
$('docTableBody').innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--danger);">加载失败:${e.message}</td></tr>`;
}
}
async function viewDocDetail(id) {
try {
const [detailRes, chunksRes] = await Promise.all([
fetch(`${API}/document/${id}`),
fetch(`${API}/document/${id}/chunks`)
]);
const detailJson = await detailRes.json();
const chunksJson = await chunksRes.json();
if(!detailJson.success) { toast(detailJson.message, 'error'); return; }
const d = detailJson.data;
const chunks = chunksJson.success ? (chunksJson.data || []) : [];
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
let html = `
<div style="margin-bottom:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div class="result-item"><div class="label">文档标题</div><div class="value">${d.title}</div></div>
<div class="result-item"><div class="label">原始文件名</div><div class="value">${d.sourceName||'-'}</div></div>
<div class="result-item"><div class="label">文件类型</div><div class="value">${d.fileType}</div></div>
<div class="result-item"><div class="label">文件大小</div><div class="value">${formatBytes(d.fileSize||0)}</div></div>
<div class="result-item"><div class="label">状态</div><div class="value"><span class="${statusClass}">${d.status}</span></div></div>
<div class="result-item"><div class="label">分块数</div><div class="value">${d.chunkCount}</div></div>
<div class="result-item"><div class="label">分类</div><div class="value">${catName}</div></div>
<div class="result-item"><div class="label">创建时间</div><div class="value">${formatDate(d.createTime)}</div></div>
</div>
<h3>📄 原文内容</h3>
<div style="background:#f9fafb;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:200px;overflow-y:auto;">${d.content||'-'}</div>
</div>
<h3>🧩 分块详情(${chunks.length} 个)</h3>
`;
chunks.forEach((chunk, i) => {
let meta = chunk.metadata;
if(typeof meta === 'object' && meta && meta.value) meta = meta.value;
let keywords = '';
try {
const m = typeof meta === 'string' ? JSON.parse(meta) : meta;
keywords = m.excerpt_keywords || '';
} catch(e) {}
html += `
<div class="search-result">
<div class="meta">#${i+1} ${keywords ? '| 关键词: ' + keywords : ''}</div>
<div class="content">${chunk.content||''}</div>
</div>
`;
});
$('docModalContent').innerHTML = html;
$('docModal').classList.add('active');
} catch(e) { toast('查看详情失败:'+e.message, 'error'); }
}
function closeDocModal() {
$('docModal').classList.remove('active');
}
async function deleteDoc(id) {
if(!confirm('确定删除此文档?关联的向量也将被删除')) return;
try {
const res = await fetch(`${API}/document/${id}`, {method:'DELETE'});
const json = await res.json();
if(json.success) {
toast(`已删除,连带删除 ${json.deletedVectors||0} 个向量`, 'success');
loadDocuments(currentDocPage);
loadStats();
} else {
toast(json.message || '删除失败', 'error');
}
} catch(e) { toast('删除失败:'+e.message, 'error'); }
}
async function reprocessDoc(id) {
if(!confirm('确定重新处理此文档?将重新分块并向量化')) return;
try {
const res = await fetch(`${API}/document/${id}/reprocess`, {method:'PUT'});
const json = await res.json();
if(json.success) {
toast('重新处理成功', 'success');
loadDocuments(currentDocPage);
} else {
toast(json.message || '重新处理失败', 'error');
}
} catch(e) { toast('重新处理失败:'+e.message, 'error'); }
}
// ==================== 知识库管理: 语义搜索 ====================
async function doSearch() {
const query = $('searchQuery').value.trim();
if(!query) { toast('请输入查询内容', 'error'); return; }
const topK = parseInt($('searchTopK').value) || 5;
const threshold = parseFloat($('searchThreshold').value) || 0.5;
$('searchResults').innerHTML = '<div style="text-align:center;padding:20px;color:var(--sub);">⏳ 搜索中...</div>';
try {
const res = await fetch(`${API}/document/search`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({query, topK, similarityThreshold: threshold})
});
const json = await res.json();
if(!json.success) {
$('searchResults').innerHTML = `<div style="color:var(--danger);">搜索失败:${json.message}</div>`;
return;
}
const results = json.data || [];
if(results.length === 0) {
$('searchResults').innerHTML = '<div style="text-align:center;padding:20px;color:var(--sub);">未找到相关结果</div>';
return;
}
$('searchResults').innerHTML = results.map(r => {
const score = r.score !== null ? (1 - r.score).toFixed(4) : '-';
return `
<div class="search-result">
<div class="score">相似度得分: ${score} | ${r.title||'无标题'} | ${r.sourceName||'无来源'}</div>
<div class="content">${r.content||''}</div>
</div>
`;
}).join('');
} catch(e) {
$('searchResults').innerHTML = `<div style="color:var(--danger);">搜索失败:${e.message}</div>`;
}
}
// ==================== Tab 3: 文档上传 ====================
const fileData = {};
function handleFileSelect(input, type) {
if(!input.files || input.files.length === 0) return;
fileData[type] = Array.from(input.files);
const totalSize = fileData[type].reduce((s, f) => s + f.size, 0);
const filenames = fileData[type].map(f => `<span style="display:inline-block;margin:2px 4px;padding:2px 8px;background:#eef2ff;border-radius:4px;font-size:12px;">${f.name} <span style="color:var(--sub);">(${formatBytes(f.size)})</span></span>`).join('');
const infoId = type === 'file' ? 'fileFileInfo' : type === 'markdown' ? 'mdFileInfo' : type === 'jsonBasic' ? 'jsonBFileInfo' : type === 'jsonFields' ? 'jsonFFileInfo' : 'jsonPFileInfo';
const label = fileData[type].length > 1 ? `✅ 已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)})` : `✅ 已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})`;
const label = fileData[type].length > 1 ? `已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)})` : `已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})`;
$(infoId).innerHTML = `${label}<br><div style="margin-top:4px;">${filenames}</div>`;
const btnMap = {file:'btn-file', markdown:'btn-markdown', jsonBasic:'btn-jsonBasic', jsonFields:'btn-jsonFields', jsonPointer:'btn-jsonPointer'};
if(btnMap[type]) $(btnMap[type]).disabled = false;
const zoneMap = {file:'zone-file', markdown:'zone-md', jsonBasic:'zone-jsonB', jsonFields:'zone-jsonF', jsonPointer:'zone-jsonP'};
if(zoneMap[type]) $(zoneMap[type]).style.borderColor = 'var(--success)';
}
// 接口6-11:文档上传统一处理(支持批量多文件)
async function uploadDocument(type) {
let baseUrl, resultId, extraParam = null;
let baseUrl, resultId;
switch(type) {
case 'file': baseUrl = `${API}/document/upload/file`; resultId = 'doc-file-result'; break;
case 'string': baseUrl = `${API}/document/upload/string`; resultId = 'doc-string-result'; break;
case 'markdown': baseUrl = `${API}/document/upload/markdown`; resultId = 'doc-markdown-result'; break;
case 'jsonBasic': baseUrl = `${API}/document/upload/json/basic`; resultId = 'doc-jsonBasic-result'; break;
case 'file': baseUrl = `${API}/upload/file`; resultId = 'doc-file-result'; break;
case 'string': baseUrl = `${API}/upload/string`; resultId = 'doc-string-result'; break;
case 'markdown': baseUrl = `${API}/upload/markdown`; resultId = 'doc-markdown-result'; break;
case 'jsonBasic': baseUrl = `${API}/upload/json/basic`; resultId = 'doc-jsonBasic-result'; break;
case 'jsonFields':
resultId = 'doc-jsonFields-result';
{ const fieldsStr = $('jsonFieldsInput').value.trim();
if(!fieldsStr) { toast('请输入要提取的字段名', 'error'); return; }
baseUrl = `${API}/document/upload/json/fields?fields=${encodeURIComponent(fieldsStr)}`; }
baseUrl = `${API}/upload/json/fields?fields=${encodeURIComponent(fieldsStr)}`; }
break;
case 'jsonPointer':
resultId = 'doc-jsonPointer-result';
{ const pointer = $('jsonPointerInput').value.trim();
if(!pointer) { toast('请输入 JSON Pointer 路径', 'error'); return; }
baseUrl = `${API}/document/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`; }
baseUrl = `${API}/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`; }
break;
}
const btnId = {file:'btn-file', string:null, markdown:'btn-markdown', jsonBasic:'btn-jsonBasic', jsonFields:'btn-jsonFields', jsonPointer:'btn-jsonPointer'}[type];
const btn = btnId ? $(btnId) : null;
// 字符串上传:单次请求
// 构建附加参数
const catId = $('uploadCategory').value;
const tagsStr = $('uploadTags').value.trim();
const extraParams = [];
if(catId) extraParams.push(`categoryId=${catId}`);
if(tagsStr) extraParams.push(`tags=${encodeURIComponent(tagsStr)}`);
const extraQuery = extraParams.length > 0 ? (baseUrl.includes('?') ? '&' : '?') + extraParams.join('&') : '';
if(type === 'string') {
if(btn) { btn.disabled = true; btn.textContent = '⏳ 处理中...'; }
if(btn) { btn.disabled = true; btn.textContent = '处理中...'; }
try {
const res = await fetch(baseUrl, { method:'POST', headers:{'Content-Type':'text/plain'}, body:$('stringContent').value });
const title = $('stringTitle').value.trim() || $('stringContent').value.trim().substring(0, 30);
let url = baseUrl + (baseUrl.includes('?') ? '&' : '?') + `title=${encodeURIComponent(title)}`;
if(catId) url += `&categoryId=${catId}`;
if(tagsStr) url += `&tags=${encodeURIComponent(tagsStr)}`;
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'text/plain'}, body:$('stringContent').value });
const data = await res.json();
$(resultId).innerHTML = res.ok
? `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">✅ ${data.message} | 文档数量:<strong>${data.documentCount}</strong></div>`
: `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">❌ ${data.message}</div>`;
if(res.ok) { toast(data.message, 'success'); addUploadLog(type, data); }
? `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">${data.message} | 分块数<strong>${data.data.chunkCount}</strong> | 状态:<strong>${data.data.status}</strong></div>`
: `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">${data.message}</div>`;
if(res.ok) { toast(data.message, 'success'); loadDocuments(); loadStats(); }
} catch(e) {
$(resultId).innerHTML = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">❌ 上传失败:${e.message}</div>`;
$(resultId).innerHTML = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">上传失败:${e.message}</div>`;
toast('上传失败:'+e.message, 'error');
} finally {
if(btn) { btn.disabled = false; btn.textContent = '🚀 上传并向量化'; }
@ -603,58 +1027,43 @@ async function uploadDocument(type) {
return;
}
// 文件上传:支持批量
const files = fileData[type];
if(!files || files.length === 0) { toast('请先选择文件', 'error'); return; }
if(btn) { btn.disabled = true; btn.textContent = files.length > 1 ? `0/${files.length}` : '处理中...'; }
if(btn) { btn.disabled = true; btn.textContent = files.length > 1 ? `⏳ 0/${files.length}` : '⏳ 处理中...'; }
let successCount = 0, failCount = 0, totalDocs = 0;
let successCount = 0, failCount = 0;
const resultsHtml = [];
for(let i = 0; i < files.length; i++) {
const file = files[i];
if(btn && files.length > 1) btn.textContent = `⏳ ${i+1}/${files.length}`;
if(btn && files.length > 1) btn.textContent = `${i+1}/${files.length}`;
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(baseUrl, { method:'POST', body:formData });
if(catId) formData.append('categoryId', catId);
if(tagsStr) formData.append('tags', tagsStr);
const res = await fetch(baseUrl + extraQuery, { method:'POST', body:formData });
const data = await res.json();
if(res.ok) {
successCount++;
totalDocs += (data.documentCount || 0);
resultsHtml.push(`<span style="color:var(--success);">✅ ${file.name}</span> — ${data.documentCount||0} 文档`);
addUploadLog(type, data, file.name);
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${data.data.chunkCount||0} 分块`);
} else {
failCount++;
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${data.message||'未知错误'}`);
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${data.message||'错误'}`);
}
} catch(e) {
failCount++;
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${e.message}`);
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${e.message}`);
}
}
// 汇总结果
const summary = successCount > 0
? `上传完成:成功 <strong>${successCount}</strong> 个${failCount > 0 ? `,失败 <strong>${failCount}</strong> 个` : ''},共生成 <strong>${totalDocs}</strong> 个文档向量`
: `全部失败(${failCount} 个文件)`;
? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}`
: `全部失败(${failCount} 个文件)`;
$(resultId).innerHTML = `<div style="margin-top:8px;padding:12px;background:${successCount>0?'#ecfdf5':'#fef2f2'};border-radius:8px;font-size:13px;">${summary}<br><div style="margin-top:6px;line-height:1.8;">${resultsHtml.join('<br>')}</div></div>`;
toast(`${successCount>0?'✅':''} ${summary.replace(/<[^>]*>/g,'')}`, successCount > 0 ? 'success' : 'error');
toast(summary, successCount > 0 ? 'success' : 'error');
if(btn) { btn.disabled = false; btn.textContent = '🚀 上传并向量化'; }
}
function addUploadLog(type, data, filename) {
const log = $('uploadLog');
if(log.textContent === '暂无上传记录') log.innerHTML = '';
const typeLabels = {file:'通用文件', string:'文本内容', markdown:'Markdown', jsonBasic:'JSON 基本', jsonFields:'JSON 按字段', jsonPointer:'JSON 按指针'};
const entry = document.createElement('div');
entry.style.cssText = 'padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;';
const fname = filename ? ` <span style="color:#999;font-size:12px;">— ${filename}</span>` : '';
entry.innerHTML = `<span>📄 <strong>${typeLabels[type]||type}</strong>${fname} — ${data.documentCount} 个文档</span><span style="color:var(--sub);">${new Date().toLocaleTimeString()}</span>`;
log.insertBefore(entry, log.firstChild);
if(successCount > 0) { loadDocuments(); loadStats(); loadCategories(); }
}
// 拖放支持
@ -677,6 +1086,11 @@ function addUploadLog(type, data, filename) {
});
});
// 弹窗关闭点击外部
document.getElementById('docModal').addEventListener('click', function(e) {
if(e.target === this) closeDocModal();
});
// ==================== 启动 ====================
init();
</script>

684
src/main/resources/static/frontend.html

@ -26,7 +26,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.tab-icon { font-size:18px; }
/* 内容区 */
.main { max-width:1200px; margin:0 auto; padding:20px; }
.main { max-width:1400px; margin:0 auto; padding:20px; }
.tab-panel { display:none; animation: fadeIn .3s ease; }
.tab-panel.active { display:block; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
@ -55,6 +55,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.btn-outline { background:#fff; color:var(--primary); border:1px solid var(--primary); }
.btn-sm { padding:6px 12px; font-size:12px; }
.btn-danger { background:var(--danger); color:#fff; }
.btn-warn { background:var(--warn); color:#fff; }
/* 消息区 */
.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fafbfc; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; }
@ -94,8 +95,47 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.stream-compare { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.stream-compare .card { margin-bottom:0; }
/* 表格样式 */
.data-table { width:100%; border-collapse:collapse; font-size:13px; }
.data-table th { background:#f9fafb; padding:10px 12px; text-align:left; font-weight:600; border-bottom:2px solid var(--border); white-space:nowrap; }
.data-table td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:middle; }
.data-table tr:hover { background:#f9fafb; }
.data-table .status-ready { color:var(--success); font-weight:600; }
.data-table .status-processing { color:var(--warn); font-weight:600; }
.data-table .status-failed { color:var(--danger); font-weight:600; }
/* 分页 */
.pagination { display:flex; gap:4px; justify-content:center; margin-top:12px; }
.pagination button { padding:6px 12px; border:1px solid var(--border); background:#fff; border-radius:6px; cursor:pointer; font-size:12px; }
.pagination button:hover:not(:disabled) { background:var(--primary); color:#fff; border-color:var(--primary); }
.pagination button:disabled { opacity:.5; cursor:not-allowed; }
.pagination button.active { background:var(--primary); color:#fff; border-color:var(--primary); }
/* 统计卡片 */
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; }
.stat-card { background:#f9fafb; border-radius:8px; padding:16px; text-align:center; border:1px solid var(--border); }
.stat-card .number { font-size:28px; font-weight:700; color:var(--primary); }
.stat-card .label { font-size:12px; color:var(--sub); margin-top:4px; }
/* 弹窗 */
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:200; display:none; align-items:center; justify-content:center; }
.modal-overlay.active { display:flex; }
.modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; }
.modal-box h2 { margin-bottom:16px; font-size:18px; }
.modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; }
.modal-close:hover { color:var(--text); }
/* 分类标签 */
.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#eef2ff; color:var(--primary); }
/* 搜索结果 */
.search-result { background:#f9fafb; border-radius:8px; padding:12px; margin-bottom:8px; border:1px solid var(--border); }
.search-result .score { font-size:12px; color:var(--success); font-weight:600; }
.search-result .meta { font-size:12px; color:var(--sub); margin-top:4px; }
.search-result .content { font-size:13px; margin-top:6px; line-height:1.5; }
/* 响应式 */
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } }
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } }
.badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; }
.badge-get { background:#dbeafe; color:#1d4ed8; }
@ -198,9 +238,92 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- ==================== Tab 3: 知识库文档管理 ==================== -->
<div class="tab-panel" id="panel-document">
<!-- 统计面板 -->
<div class="card">
<h2>📊 知识库概览</h2>
<div class="stat-grid" id="statsGrid">
<div class="stat-card"><div class="number" id="statTotalDocs">-</div><div class="label">文档总数</div></div>
<div class="stat-card"><div class="number" id="statTotalVectors">-</div><div class="label">向量总数</div></div>
<div class="stat-card"><div class="number" id="statLastUpload">-</div><div class="label">最近上传</div></div>
</div>
<div id="statsDetail" style="font-size:13px;color:var(--sub);"></div>
</div>
<!-- 语义搜索 -->
<div class="card">
<h2>🔍 语义搜索测试</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句,测试知识库检索效果</p>
<div class="input-row">
<input class="input" id="searchQuery" placeholder="输入查询,如:Spring Boot 是什么?">
<input class="input input-sm" id="searchTopK" placeholder="TopK" value="5" type="number" min="1" max="20">
<input class="input input-sm" id="searchThreshold" placeholder="阈值" value="0.5" type="number" min="0" max="1" step="0.1">
<button class="btn btn-primary" onclick="doSearch()">🔍 搜索</button>
</div>
<div id="searchResults" style="margin-top:12px;"></div>
</div>
<!-- 分类管理 -->
<div class="card">
<h2>📄 知识库文档管理</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector,即可用于 AI 检索问答</p>
<h2>🏷️ 分类管理</h2>
<div class="input-row">
<input class="input" id="catName" placeholder="分类名称">
<input class="input" id="catDesc" placeholder="分类描述(可选)">
<input class="input input-sm" id="catSort" placeholder="排序" value="0" type="number">
<button class="btn btn-success" onclick="createCategory()">➕ 创建分类</button>
<button class="btn btn-outline btn-sm" onclick="loadCategories()">🔄 刷新</button>
</div>
<div id="categoryList" style="margin-top:12px;font-size:13px;">暂无分类</div>
</div>
<!-- 文档列表 -->
<div class="card">
<h2>📋 文档列表</h2>
<div class="input-row">
<select class="select" id="filterCategory" onchange="loadDocuments()">
<option value="">全部分类</option>
</select>
<select class="select" id="filterStatus" onchange="loadDocuments()">
<option value="">全部状态</option>
<option value="READY">✅ 已完成</option>
<option value="PROCESSING">⏳ 处理中</option>
<option value="FAILED">❌ 失败</option>
</select>
<button class="btn btn-outline btn-sm" onclick="loadDocuments()">🔄 刷新</button>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="docTable">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>类型</th>
<th>状态</th>
<th>分块数</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="docTableBody">
<tr><td colspan="7" style="text-align:center;color:var(--sub);">点击刷新加载文档</td></tr>
</tbody>
</table>
</div>
<div class="pagination" id="docPagination"></div>
</div>
<!-- 文档上传 -->
<div class="card">
<h2>📤 文档上传</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector</p>
<!-- 上传元信息 -->
<div class="input-row" style="padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<select class="select" id="uploadCategory">
<option value="">选择分类(可选)</option>
</select>
<input class="input" id="uploadTags" placeholder="标签,逗号分隔(可选)">
</div>
<!-- 子 Tab -->
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;">
@ -214,7 +337,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- 通用文件 -->
<div class="doc-panel active" id="doc-file">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/file</code><span style="font-size:12px;color:var(--sub);">(Tika 多格式解析)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">(Tika 多格式解析)</span></div>
<div class="upload-zone" id="zone-file" onclick="document.getElementById('fileInput').click()">
<div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)</p>
<input type="file" id="fileInput" style="display:none" multiple onchange="handleFileSelect(this,'file')">
@ -226,15 +349,17 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- 文本内容 -->
<div class="doc-panel" id="doc-string" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/string</code><span style="font-size:12px;color:var(--sub);">(直接上传文本)</span></div>
<textarea class="textarea" id="stringContent" placeholder="输入要加入知识库的文本内容...&#10;例如:公司退换货政策、商品FAQ、物流说明等"></textarea>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/string</code><span style="font-size:12px;color:var(--sub);">(直接上传文本)</span></div>
<input class="input" id="stringTitle" placeholder="文档标题" style="margin-bottom:8px;">
<textarea class="textarea" id="stringContent" placeholder="输入要加入知识库的文本内容...
例如:公司退换货政策、商品FAQ、物流说明等"></textarea>
<button class="btn btn-primary" style="margin-top:12px;" onclick="uploadDocument('string')">🚀 上传并向量化</button>
<div id="doc-string-result"></div>
</div>
<!-- Markdown -->
<div class="doc-panel" id="doc-markdown" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/markdown</code></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/markdown</code></div>
<div class="upload-zone" id="zone-md" onclick="document.getElementById('mdInput').click()">
<div class="icon">📑</div><p>点击或拖拽上传,支持多文件(Markdown .md)</p>
<input type="file" id="mdInput" style="display:none" accept=".md" multiple onchange="handleFileSelect(this,'markdown')">
@ -246,7 +371,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- JSON 基本 -->
<div class="doc-panel" id="doc-jsonBasic" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/json/basic</code><span style="font-size:12px;color:var(--sub);">(整体解析)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/basic</code><span style="font-size:12px;color:var(--sub);">(整体解析)</span></div>
<div class="upload-zone" id="zone-jsonB" onclick="document.getElementById('jsonBInput').click()">
<div class="icon">📋</div><p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" id="jsonBInput" style="display:none" accept=".json" multiple onchange="handleFileSelect(this,'jsonBasic')">
@ -258,7 +383,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- JSON 按字段 -->
<div class="doc-panel" id="doc-jsonFields" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/json/fields</code><span style="font-size:12px;color:var(--sub);">(按字段名提取)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/fields</code><span style="font-size:12px;color:var(--sub);">(按字段名提取)</span></div>
<div class="upload-zone" id="zone-jsonF" onclick="document.getElementById('jsonFInput').click()">
<div class="icon">🔑</div><p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" id="jsonFInput" style="display:none" accept=".json" multiple onchange="handleFileSelect(this,'jsonFields')">
@ -271,7 +396,7 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
<!-- JSON 按指针 -->
<div class="doc-panel" id="doc-jsonPointer" style="display:none;">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/document/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">(JSON Pointer 路径拆分)</span></div>
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">(JSON Pointer 路径拆分)</span></div>
<div class="upload-zone" id="zone-jsonP" onclick="document.getElementById('jsonPInput').click()">
<div class="icon">📍</div><p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" id="jsonPInput" style="display:none" accept=".json" multiple onchange="handleFileSelect(this,'jsonPointer')">
@ -283,13 +408,17 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
</div>
</div>
<!-- 上传历史 -->
<div class="card">
<h2>📋 最近上传记录</h2>
<div id="uploadLog" style="font-size:13px;color:var(--sub);">暂无上传记录</div>
</div>
</div>
<!-- 文档详情弹窗 -->
<div class="modal-overlay" id="docModal">
<div class="modal-box">
<button class="modal-close" onclick="closeDocModal()">&times;</button>
<h2>📄 文档详情</h2>
<div id="docModalContent"></div>
</div>
</div>
<div class="toast-container" id="toasts"></div>
@ -299,6 +428,8 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
const API = 'http://localhost:9090';
let currentChatId = '';
let isRagMode = false;
let currentDocPage = 1;
let categoryMap = {}; // id -> name
// ==================== 工具函数 ====================
function toast(msg, type='info') {
@ -314,31 +445,10 @@ function $(id) { return document.getElementById(id); }
function formatBytes(b) { return b<1024?b+' B':b<1048576?(b/1024).toFixed(1)+' KB':(b/1048576).toFixed(1)+' MB'; }
// ==================== RAG 模式切换 ====================
function toggleRagMode() {
isRagMode = $('ragModeToggle').checked;
$('ragStrategySelect').style.display = isRagMode ? 'block' : 'none';
$('ragStatusTip').style.display = isRagMode ? 'block' : 'none';
// 更新提示信息
if (isRagMode) {
updateRagStatus();
}
}
function updateRagStatus() {
if (!isRagMode) return;
const strategy = $('ragStrategySelect').value;
const strategyNames = {
'NONE': '无重写',
'REWRITE': '查询重写',
'TRANSLATION': '翻译扩展',
'COMPRESSION': '查询压缩',
'MULTI_QUERY': '多路扩展'
};
$('ragStatusTip').innerHTML = `💡 当前已启用 RAG 知识库检索(策略:<strong>${strategyNames[strategy] || strategy}</strong>),将从向量数据库中检索相关信息以增强回答质量。`;
function formatDate(d) {
if(!d) return '-';
const date = new Date(d);
return date.toLocaleString('zh-CN');
}
// ==================== Tab 切换 ====================
@ -349,6 +459,11 @@ document.getElementById('tabs').addEventListener('click', function(e) {
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.getElementById('panel-'+btn.dataset.tab).classList.add('active');
if(btn.dataset.tab === 'document') {
loadStats();
loadCategories();
loadDocuments();
}
});
// 子 Tab 切换 (文档管理)
@ -365,10 +480,8 @@ document.addEventListener('click', function(e) {
// ==================== 初始化 ====================
function init() {
newChatId();
// 初始化子tab样式
const firstSub = document.querySelector('.doc-sub-tab.active');
if(firstSub) { firstSub.style.background='var(--primary)'; firstSub.style.color='#fff'; }
// 隐藏非活跃doc panel
document.querySelectorAll('.doc-panel').forEach((p,i)=>{ if(i>0) p.style.display='none'; });
}
function newChatId() {
@ -381,6 +494,21 @@ function clearMessages() {
$('chatMessages').innerHTML = '<div class="msg assistant"><div class="msg-avatar">🤖</div><div class="msg-bubble">已清屏,开始新的对话吧~</div></div>';
}
// ==================== RAG 模式切换 ====================
function toggleRagMode() {
isRagMode = $('ragModeToggle').checked;
$('ragStrategySelect').style.display = isRagMode ? 'block' : 'none';
$('ragStatusTip').style.display = isRagMode ? 'block' : 'none';
if (isRagMode) updateRagStatus();
}
function updateRagStatus() {
if (!isRagMode) return;
const strategy = $('ragStrategySelect').value;
const names = {'NONE':'无重写','REWRITE':'查询重写','TRANSLATION':'翻译扩展','COMPRESSION':'查询压缩','MULTI_QUERY':'多路扩展'};
$('ragStatusTip').innerHTML = `💡 当前已启用 RAG 知识库检索(策略:<strong>${names[strategy]||strategy}</strong>)`;
}
// ==================== Tab 1: AI 对话 ====================
function addMsg(role, content, isStreaming) {
const el = document.createElement('div');
@ -398,37 +526,23 @@ async function chatSend() {
const input = $('userInput');
const message = input.value.trim();
if(!message) return;
input.value = ''; input.disabled = true; $('sendBtn').disabled = true;
addMsg('user', message);
const bubble = addMsg('assistant', '', true);
const mode = $('modeSelect').value;
const chatId = $('chatId').value || currentChatId;
try {
// 如果启用了 RAG 模式且是同步模式,使用 RAG 接口
if (isRagMode && mode === 'sync') {
await chatRagSync(message, chatId, bubble);
} else if (isRagMode && mode !== 'sync') {
// RAG 模式仅支持同步调用
bubble.textContent = '⚠️ RAG 知识库模式目前仅支持同步调用,请切换到"🔵 同步调用"模式,或关闭 RAG 模式后再使用流式对话。';
bubble.textContent = '⚠️ RAG 模式仅支持同步调用';
toast('RAG 模式暂不支持流式调用', 'error');
} else {
// 普通模式,按原有逻辑处理
switch(mode) {
case 'sync':
await chatSync(message, chatId, bubble);
break;
case 'sse':
await chatSSE(message, chatId, bubble);
break;
case 'sse2':
await chatServerSentEvent(message, chatId, bubble);
break;
case 'sse3':
await chatSseEmitter(message, chatId, bubble);
break;
case 'sync': await chatSync(message, chatId, bubble); break;
case 'sse': await chatSSE(message, chatId, bubble); break;
case 'sse2': await chatServerSentEvent(message, chatId, bubble); break;
case 'sse3': await chatSseEmitter(message, chatId, bubble); break;
}
}
} catch(e) {
@ -441,64 +555,26 @@ async function chatSend() {
}
}
// 接口1:同步对话
async function chatSync(message, chatId, bubble) {
const url = `${API}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try {
const body = await res.json();
if(body.error) errMsg = body.error;
if(body.message) errMsg = body.message;
} catch(_) {
try { const text = await res.text(); if(text) errMsg = text.substring(0,200); } catch(__) {}
}
throw new Error(errMsg);
}
if(!res.ok) throw new Error(`HTTP ${res.status}`);
bubble.textContent = await res.text();
}
// 接口1-RAG:RAG 知识库同步对话
async function chatRagSync(message, chatId, bubble) {
const strategy = $('ragStrategySelect').value;
const url = `${API}/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try {
const body = await res.json();
if(body.error) errMsg = body.error;
if(body.message) errMsg = body.message;
} catch(_) {
try { const text = await res.text(); if(text) errMsg = text.substring(0,200); } catch(__) {}
}
throw new Error(errMsg);
}
const answer = await res.text();
bubble.textContent = answer;
// 在回答末尾添加 RAG 标识
const strategyNames = {
'NONE': '无重写',
'REWRITE': '查询重写',
'TRANSLATION': '翻译扩展',
'COMPRESSION': '查询压缩',
'MULTI_QUERY': '多路扩展'
};
bubble.textContent += `\n\n---\n📚 [RAG 知识库检索 · ${strategyNames[strategy] || strategy}]`;
if(!res.ok) throw new Error(`HTTP ${res.status}`);
bubble.textContent = await res.text();
}
// 接口2:SSE 流式 (Flux)
async function chatSSE(message, chatId, bubble) {
bubble.textContent = '';
const url = `${API}/ai/assistant_app/chat/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try { const t = await res.text(); errMsg = t.substring(0,200); } catch(_) {}
throw new Error(errMsg);
}
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@ -520,16 +596,11 @@ async function chatSSE(message, chatId, bubble) {
}
}
// 接口3:ServerSentEvent 流式
async function chatServerSentEvent(message, chatId, bubble) {
bubble.textContent = '';
const url = `${API}/ai/assistant_app/chat/server_sent_event?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try { const t = await res.text(); errMsg = t.substring(0,200); } catch(_) {}
throw new Error(errMsg);
}
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@ -549,16 +620,11 @@ async function chatServerSentEvent(message, chatId, bubble) {
}
}
// 接口4:SseEmitter 流式
async function chatSseEmitter(message, chatId, bubble) {
bubble.textContent = '';
const url = `${API}/ai/assistant_app/chat/sse_emitter?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
if(!res.ok) {
let errMsg = `服务器错误 (${res.status})`;
try { const t = await res.text(); errMsg = t.substring(0,200); } catch(_) {}
throw new Error(errMsg);
}
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@ -581,25 +647,20 @@ async function chatSseEmitter(message, chatId, bubble) {
}
// ==================== Tab 2: 商品信息提取 ====================
// 接口5:商品信息提取
async function extractProduct() {
const content = $('productContent').value.trim();
if(!content) { toast('请输入商品描述内容', 'error'); return; }
const btn = $('extractBtn');
btn.disabled = true; btn.textContent = '⏳ 提取中...';
try {
const url = `${API}/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`;
const res = await fetch(url);
if(!res.ok) throw new Error('HTTP '+res.status);
const text = await res.text();
const data = JSON.parse(text);
$('productResult').style.display = 'block';
const fields = ['title','description','price','rating','reviewCount','brand','category'];
const labels = {title:'商品标题',description:'描述',price:'价格',rating:'评分',reviewCount:'评论数',brand:'品牌',category:'分类'};
let html = '';
for(const f of fields) {
const val = data[f] !== null && data[f] !== undefined ? data[f] : '—';
@ -610,78 +671,355 @@ async function extractProduct() {
toast('商品信息提取成功!', 'success');
} catch(e) {
toast('提取失败:'+e.message, 'error');
// 也尝试显示原始返回
try {
const url = `${API}/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`;
const res = await fetch(url);
const text = await res.text();
$('productResult').style.display = 'block';
$('productGrid').innerHTML = '<div class="result-item"><div class="label">原始返回</div><div class="value">解析失败</div></div>';
$('productJson').textContent = text;
} catch(_) {}
} finally {
btn.disabled = false; btn.textContent = '🔍 提取商品信息';
}
}
// ==================== 知识库管理: 统计面板 ====================
async function loadStats() {
try {
const res = await fetch(`${API}/document/stats`);
const json = await res.json();
if(!json.success) return;
const d = json.data;
$('statTotalDocs').textContent = d.totalDocuments || 0;
$('statTotalVectors').textContent = d.totalVectors || 0;
$('statLastUpload').textContent = d.lastUploadTime ? formatDate(d.lastUploadTime) : '-';
let detail = '';
if(d.byFileType && Object.keys(d.byFileType).length > 0) {
detail += '文件类型: ' + Object.entries(d.byFileType).map(([k,v])=>`${k}: ${v}`).join(', ');
}
$('statsDetail').textContent = detail;
} catch(e) { console.error('加载统计失败', e); }
}
// ==================== 知识库管理: 分类管理 ====================
async function loadCategories() {
try {
const res = await fetch(`${API}/category/list`);
const json = await res.json();
if(!json.success) return;
const list = json.data || [];
categoryMap = {};
list.forEach(c => { categoryMap[c.id] = c.name; });
// 更新分类下拉框
const selects = [$('filterCategory'), $('uploadCategory')];
selects.forEach(sel => {
if(!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">' + (sel.id === 'filterCategory' ? '全部分类' : '选择分类(可选)') + '</option>';
list.forEach(c => {
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
sel.value = val;
});
// 更新分类列表
if(list.length === 0) {
$('categoryList').innerHTML = '暂无分类';
} else {
$('categoryList').innerHTML = list.map(c =>
`<div style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span><strong>${c.name}</strong> <span style="color:var(--sub);font-size:12px;">${c.description||''}</span></span>
<button class="btn btn-danger btn-sm" onclick="deleteCategory(${c.id})">删除</button>
</div>`
).join('');
}
} catch(e) { console.error('加载分类失败', e); }
}
async function createCategory() {
const name = $('catName').value.trim();
if(!name) { toast('请输入分类名称', 'error'); return; }
try {
const res = await fetch(`${API}/category`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name: name,
description: $('catDesc').value.trim(),
sortOrder: parseInt($('catSort').value) || 0
})
});
const json = await res.json();
if(json.success) {
toast('分类创建成功', 'success');
$('catName').value = '';
$('catDesc').value = '';
loadCategories();
} else {
toast(json.message || '创建失败', 'error');
}
} catch(e) { toast('创建分类失败:'+e.message, 'error'); }
}
async function deleteCategory(id) {
if(!confirm('确定删除此分类?关联文档将变为未分类')) return;
try {
const res = await fetch(`${API}/category/${id}`, {method:'DELETE'});
const json = await res.json();
if(json.success) {
toast('分类已删除', 'success');
loadCategories();
loadDocuments();
} else {
toast(json.message || '删除失败', 'error');
}
} catch(e) { toast('删除分类失败:'+e.message, 'error'); }
}
// ==================== 知识库管理: 文档列表 ====================
async function loadDocuments(page=1) {
currentDocPage = page;
const catId = $('filterCategory').value;
const status = $('filterStatus').value;
let url = `${API}/document/list?page=${page}&size=10`;
if(catId) url += `&categoryId=${catId}`;
if(status) url += `&status=${status}`;
try {
const res = await fetch(url);
const json = await res.json();
if(!json.success) {
$('docTableBody').innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--danger);">${json.message}</td></tr>`;
return;
}
const docs = json.data || [];
const total = json.total || 0;
const pages = json.pages || 1;
if(docs.length === 0) {
$('docTableBody').innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--sub);">暂无文档</td></tr>';
} else {
$('docTableBody').innerHTML = docs.map(d => {
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
return `<tr>
<td>${d.id}</td>
<td><strong>${d.title}</strong><br><span style="font-size:11px;color:var(--sub);">${d.sourceName||''}</span></td>
<td><span class="category-tag">${d.fileType}</span></td>
<td><span class="${statusClass}">${d.status}</span></td>
<td>${d.chunkCount}</td>
<td>${formatDate(d.createTime)}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="viewDocDetail(${d.id})">查看</button>
<button class="btn btn-sm btn-warn" onclick="reprocessDoc(${d.id})">重新处理</button>
<button class="btn btn-sm btn-danger" onclick="deleteDoc(${d.id})">删除</button>
</td>
</tr>`;
}).join('');
}
// 分页
let phtml = '';
if(pages > 1) {
phtml += `<button ${page<=1?'disabled':''} onclick="loadDocuments(${page-1})">上一页</button>`;
for(let i=1;i<=pages;i++) {
if(i===1 || i===pages || (i>=page-2 && i<=page+2)) {
phtml += `<button class="${i===page?'active':''}" onclick="loadDocuments(${i})">${i}</button>`;
} else if(i===page-3 || i===page+3) {
phtml += `<span style="padding:6px;">...</span>`;
}
}
phtml += `<button ${page>=pages?'disabled':''} onclick="loadDocuments(${page+1})">下一页</button>`;
}
$('docPagination').innerHTML = phtml;
} catch(e) {
$('docTableBody').innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--danger);">加载失败:${e.message}</td></tr>`;
}
}
async function viewDocDetail(id) {
try {
const [detailRes, chunksRes] = await Promise.all([
fetch(`${API}/document/${id}`),
fetch(`${API}/document/${id}/chunks`)
]);
const detailJson = await detailRes.json();
const chunksJson = await chunksRes.json();
if(!detailJson.success) { toast(detailJson.message, 'error'); return; }
const d = detailJson.data;
const chunks = chunksJson.success ? (chunksJson.data || []) : [];
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
let html = `
<div style="margin-bottom:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div class="result-item"><div class="label">文档标题</div><div class="value">${d.title}</div></div>
<div class="result-item"><div class="label">原始文件名</div><div class="value">${d.sourceName||'-'}</div></div>
<div class="result-item"><div class="label">文件类型</div><div class="value">${d.fileType}</div></div>
<div class="result-item"><div class="label">文件大小</div><div class="value">${formatBytes(d.fileSize||0)}</div></div>
<div class="result-item"><div class="label">状态</div><div class="value"><span class="${statusClass}">${d.status}</span></div></div>
<div class="result-item"><div class="label">分块数</div><div class="value">${d.chunkCount}</div></div>
<div class="result-item"><div class="label">分类</div><div class="value">${catName}</div></div>
<div class="result-item"><div class="label">创建时间</div><div class="value">${formatDate(d.createTime)}</div></div>
</div>
<h3>📄 原文内容</h3>
<div style="background:#f9fafb;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:200px;overflow-y:auto;">${d.content||'-'}</div>
</div>
<h3>🧩 分块详情(${chunks.length} 个)</h3>
`;
chunks.forEach((chunk, i) => {
let meta = chunk.metadata;
if(typeof meta === 'object' && meta && meta.value) meta = meta.value;
let keywords = '';
try {
const m = typeof meta === 'string' ? JSON.parse(meta) : meta;
keywords = m.excerpt_keywords || '';
} catch(e) {}
html += `
<div class="search-result">
<div class="meta">#${i+1} ${keywords ? '| 关键词: ' + keywords : ''}</div>
<div class="content">${chunk.content||''}</div>
</div>
`;
});
$('docModalContent').innerHTML = html;
$('docModal').classList.add('active');
} catch(e) { toast('查看详情失败:'+e.message, 'error'); }
}
function closeDocModal() {
$('docModal').classList.remove('active');
}
async function deleteDoc(id) {
if(!confirm('确定删除此文档?关联的向量也将被删除')) return;
try {
const res = await fetch(`${API}/document/${id}`, {method:'DELETE'});
const json = await res.json();
if(json.success) {
toast(`已删除,连带删除 ${json.deletedVectors||0} 个向量`, 'success');
loadDocuments(currentDocPage);
loadStats();
} else {
toast(json.message || '删除失败', 'error');
}
} catch(e) { toast('删除失败:'+e.message, 'error'); }
}
async function reprocessDoc(id) {
if(!confirm('确定重新处理此文档?将重新分块并向量化')) return;
try {
const res = await fetch(`${API}/document/${id}/reprocess`, {method:'PUT'});
const json = await res.json();
if(json.success) {
toast('重新处理成功', 'success');
loadDocuments(currentDocPage);
} else {
toast(json.message || '重新处理失败', 'error');
}
} catch(e) { toast('重新处理失败:'+e.message, 'error'); }
}
// ==================== 知识库管理: 语义搜索 ====================
async function doSearch() {
const query = $('searchQuery').value.trim();
if(!query) { toast('请输入查询内容', 'error'); return; }
const topK = parseInt($('searchTopK').value) || 5;
const threshold = parseFloat($('searchThreshold').value) || 0.5;
$('searchResults').innerHTML = '<div style="text-align:center;padding:20px;color:var(--sub);">⏳ 搜索中...</div>';
try {
const res = await fetch(`${API}/document/search`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({query, topK, similarityThreshold: threshold})
});
const json = await res.json();
if(!json.success) {
$('searchResults').innerHTML = `<div style="color:var(--danger);">搜索失败:${json.message}</div>`;
return;
}
const results = json.data || [];
if(results.length === 0) {
$('searchResults').innerHTML = '<div style="text-align:center;padding:20px;color:var(--sub);">未找到相关结果</div>';
return;
}
$('searchResults').innerHTML = results.map(r => {
const score = r.score !== null ? (1 - r.score).toFixed(4) : '-';
return `
<div class="search-result">
<div class="score">相似度得分: ${score} | ${r.title||'无标题'} | ${r.sourceName||'无来源'}</div>
<div class="content">${r.content||''}</div>
</div>
`;
}).join('');
} catch(e) {
$('searchResults').innerHTML = `<div style="color:var(--danger);">搜索失败:${e.message}</div>`;
}
}
// ==================== Tab 3: 文档上传 ====================
const fileData = {};
function handleFileSelect(input, type) {
if(!input.files || input.files.length === 0) return;
fileData[type] = Array.from(input.files);
const totalSize = fileData[type].reduce((s, f) => s + f.size, 0);
const filenames = fileData[type].map(f => `<span style="display:inline-block;margin:2px 4px;padding:2px 8px;background:#eef2ff;border-radius:4px;font-size:12px;">${f.name} <span style="color:var(--sub);">(${formatBytes(f.size)})</span></span>`).join('');
const infoId = type === 'file' ? 'fileFileInfo' : type === 'markdown' ? 'mdFileInfo' : type === 'jsonBasic' ? 'jsonBFileInfo' : type === 'jsonFields' ? 'jsonFFileInfo' : 'jsonPFileInfo';
const label = fileData[type].length > 1 ? `✅ 已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)})` : `✅ 已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})`;
const label = fileData[type].length > 1 ? `已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)})` : `已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})`;
$(infoId).innerHTML = `${label}<br><div style="margin-top:4px;">${filenames}</div>`;
const btnMap = {file:'btn-file', markdown:'btn-markdown', jsonBasic:'btn-jsonBasic', jsonFields:'btn-jsonFields', jsonPointer:'btn-jsonPointer'};
if(btnMap[type]) $(btnMap[type]).disabled = false;
const zoneMap = {file:'zone-file', markdown:'zone-md', jsonBasic:'zone-jsonB', jsonFields:'zone-jsonF', jsonPointer:'zone-jsonP'};
if(zoneMap[type]) $(zoneMap[type]).style.borderColor = 'var(--success)';
}
// 接口6-11:文档上传统一处理(支持批量多文件)
async function uploadDocument(type) {
let baseUrl, resultId, extraParam = null;
let baseUrl, resultId;
switch(type) {
case 'file': baseUrl = `${API}/document/upload/file`; resultId = 'doc-file-result'; break;
case 'string': baseUrl = `${API}/document/upload/string`; resultId = 'doc-string-result'; break;
case 'markdown': baseUrl = `${API}/document/upload/markdown`; resultId = 'doc-markdown-result'; break;
case 'jsonBasic': baseUrl = `${API}/document/upload/json/basic`; resultId = 'doc-jsonBasic-result'; break;
case 'file': baseUrl = `${API}/upload/file`; resultId = 'doc-file-result'; break;
case 'string': baseUrl = `${API}/upload/string`; resultId = 'doc-string-result'; break;
case 'markdown': baseUrl = `${API}/upload/markdown`; resultId = 'doc-markdown-result'; break;
case 'jsonBasic': baseUrl = `${API}/upload/json/basic`; resultId = 'doc-jsonBasic-result'; break;
case 'jsonFields':
resultId = 'doc-jsonFields-result';
{ const fieldsStr = $('jsonFieldsInput').value.trim();
if(!fieldsStr) { toast('请输入要提取的字段名', 'error'); return; }
baseUrl = `${API}/document/upload/json/fields?fields=${encodeURIComponent(fieldsStr)}`; }
baseUrl = `${API}/upload/json/fields?fields=${encodeURIComponent(fieldsStr)}`; }
break;
case 'jsonPointer':
resultId = 'doc-jsonPointer-result';
{ const pointer = $('jsonPointerInput').value.trim();
if(!pointer) { toast('请输入 JSON Pointer 路径', 'error'); return; }
baseUrl = `${API}/document/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`; }
baseUrl = `${API}/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`; }
break;
}
const btnId = {file:'btn-file', string:null, markdown:'btn-markdown', jsonBasic:'btn-jsonBasic', jsonFields:'btn-jsonFields', jsonPointer:'btn-jsonPointer'}[type];
const btn = btnId ? $(btnId) : null;
// 字符串上传:单次请求
// 构建附加参数
const catId = $('uploadCategory').value;
const tagsStr = $('uploadTags').value.trim();
const extraParams = [];
if(catId) extraParams.push(`categoryId=${catId}`);
if(tagsStr) extraParams.push(`tags=${encodeURIComponent(tagsStr)}`);
const extraQuery = extraParams.length > 0 ? (baseUrl.includes('?') ? '&' : '?') + extraParams.join('&') : '';
if(type === 'string') {
if(btn) { btn.disabled = true; btn.textContent = '⏳ 处理中...'; }
if(btn) { btn.disabled = true; btn.textContent = '处理中...'; }
try {
const res = await fetch(baseUrl, { method:'POST', headers:{'Content-Type':'text/plain'}, body:$('stringContent').value });
const title = $('stringTitle').value.trim() || $('stringContent').value.trim().substring(0, 30);
let url = baseUrl + (baseUrl.includes('?') ? '&' : '?') + `title=${encodeURIComponent(title)}`;
if(catId) url += `&categoryId=${catId}`;
if(tagsStr) url += `&tags=${encodeURIComponent(tagsStr)}`;
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'text/plain'}, body:$('stringContent').value });
const data = await res.json();
$(resultId).innerHTML = res.ok
? `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">✅ ${data.message} | 文档数量:<strong>${data.documentCount}</strong></div>`
: `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">❌ ${data.message}</div>`;
if(res.ok) { toast(data.message, 'success'); addUploadLog(type, data); }
? `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">${data.message} | 分块数<strong>${data.data.chunkCount}</strong> | 状态:<strong>${data.data.status}</strong></div>`
: `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">${data.message}</div>`;
if(res.ok) { toast(data.message, 'success'); loadDocuments(); loadStats(); }
} catch(e) {
$(resultId).innerHTML = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">❌ 上传失败:${e.message}</div>`;
$(resultId).innerHTML = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">上传失败:${e.message}</div>`;
toast('上传失败:'+e.message, 'error');
} finally {
if(btn) { btn.disabled = false; btn.textContent = '🚀 上传并向量化'; }
@ -689,58 +1027,43 @@ async function uploadDocument(type) {
return;
}
// 文件上传:支持批量
const files = fileData[type];
if(!files || files.length === 0) { toast('请先选择文件', 'error'); return; }
if(btn) { btn.disabled = true; btn.textContent = files.length > 1 ? `0/${files.length}` : '处理中...'; }
if(btn) { btn.disabled = true; btn.textContent = files.length > 1 ? `⏳ 0/${files.length}` : '⏳ 处理中...'; }
let successCount = 0, failCount = 0, totalDocs = 0;
let successCount = 0, failCount = 0;
const resultsHtml = [];
for(let i = 0; i < files.length; i++) {
const file = files[i];
if(btn && files.length > 1) btn.textContent = `⏳ ${i+1}/${files.length}`;
if(btn && files.length > 1) btn.textContent = `${i+1}/${files.length}`;
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(baseUrl, { method:'POST', body:formData });
if(catId) formData.append('categoryId', catId);
if(tagsStr) formData.append('tags', tagsStr);
const res = await fetch(baseUrl + extraQuery, { method:'POST', body:formData });
const data = await res.json();
if(res.ok) {
successCount++;
totalDocs += (data.documentCount || 0);
resultsHtml.push(`<span style="color:var(--success);">✅ ${file.name}</span> — ${data.documentCount||0} 文档`);
addUploadLog(type, data, file.name);
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${data.data.chunkCount||0} 分块`);
} else {
failCount++;
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${data.message||'未知错误'}`);
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${data.message||'错误'}`);
}
} catch(e) {
failCount++;
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${e.message}`);
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${e.message}`);
}
}
// 汇总结果
const summary = successCount > 0
? `上传完成:成功 <strong>${successCount}</strong> 个${failCount > 0 ? `,失败 <strong>${failCount}</strong> 个` : ''},共生成 <strong>${totalDocs}</strong> 个文档向量`
: `全部失败(${failCount} 个文件)`;
? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}`
: `全部失败(${failCount} 个文件)`;
$(resultId).innerHTML = `<div style="margin-top:8px;padding:12px;background:${successCount>0?'#ecfdf5':'#fef2f2'};border-radius:8px;font-size:13px;">${summary}<br><div style="margin-top:6px;line-height:1.8;">${resultsHtml.join('<br>')}</div></div>`;
toast(`${successCount>0?'✅':''} ${summary.replace(/<[^>]*>/g,'')}`, successCount > 0 ? 'success' : 'error');
toast(summary, successCount > 0 ? 'success' : 'error');
if(btn) { btn.disabled = false; btn.textContent = '🚀 上传并向量化'; }
}
function addUploadLog(type, data, filename) {
const log = $('uploadLog');
if(log.textContent === '暂无上传记录') log.innerHTML = '';
const typeLabels = {file:'通用文件', string:'文本内容', markdown:'Markdown', jsonBasic:'JSON 基本', jsonFields:'JSON 按字段', jsonPointer:'JSON 按指针'};
const entry = document.createElement('div');
entry.style.cssText = 'padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;';
const fname = filename ? ` <span style="color:#999;font-size:12px;">— ${filename}</span>` : '';
entry.innerHTML = `<span>📄 <strong>${typeLabels[type]||type}</strong>${fname} — ${data.documentCount} 个文档</span><span style="color:var(--sub);">${new Date().toLocaleTimeString()}</span>`;
log.insertBefore(entry, log.firstChild);
if(successCount > 0) { loadDocuments(); loadStats(); loadCategories(); }
}
// 拖放支持
@ -763,6 +1086,11 @@ function addUploadLog(type, data, filename) {
});
});
// 弹窗关闭点击外部
document.getElementById('docModal').addEventListener('click', function(e) {
if(e.target === this) closeDocModal();
});
// ==================== 启动 ====================
init();
</script>

Loading…
Cancel
Save