19 changed files with 3509 additions and 136 deletions
-
178chat.html
-
684frontend.html
-
7pom.xml
-
90src/main/java/com/wok/supportbot/app/AssistantApp.java
-
117src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
21src/main/java/com/wok/supportbot/config/MybatisPlusConfig.java
-
17src/main/java/com/wok/supportbot/controller/AiController.java
-
516src/main/java/com/wok/supportbot/controller/DocumentController.java
-
12src/main/java/com/wok/supportbot/dao/KnowledgeCategoryMapper.java
-
12src/main/java/com/wok/supportbot/dao/KnowledgeDocumentMapper.java
-
58src/main/java/com/wok/supportbot/entity/CategoryNode.java
-
72src/main/java/com/wok/supportbot/entity/KnowledgeCategory.java
-
110src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
-
62src/main/java/com/wok/supportbot/entity/SearchResult.java
-
630src/main/java/com/wok/supportbot/service/DocumentService.java
-
21src/main/resources/add-comments.sql
-
78src/main/resources/knowledge-base.sql
-
178src/main/resources/static/chat.html
-
770src/main/resources/static/frontend.html
@ -0,0 +1,178 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>AI 智能客服 - 对话窗口</title> |
||||
|
<style> |
||||
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
||||
|
body { |
||||
|
font-family: -apple-system, "Microsoft YaHei", sans-serif; |
||||
|
background: #f0f2f5; |
||||
|
display: flex; justify-content: center; align-items: center; |
||||
|
height: 100vh; |
||||
|
} |
||||
|
.chat-container { |
||||
|
width: 800px; max-width: 95vw; height: 90vh; |
||||
|
background: #fff; border-radius: 12px; |
||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.1); |
||||
|
display: flex; flex-direction: column; overflow: hidden; |
||||
|
} |
||||
|
.chat-header { |
||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
color: #fff; padding: 16px 20px; |
||||
|
font-size: 18px; font-weight: 600; |
||||
|
} |
||||
|
.chat-header span { font-size: 13px; opacity: 0.8; margin-left: 8px; } |
||||
|
.chat-messages { |
||||
|
flex: 1; overflow-y: auto; padding: 20px; |
||||
|
display: flex; flex-direction: column; gap: 12px; |
||||
|
background: #fafbfc; |
||||
|
} |
||||
|
.message { |
||||
|
display: flex; gap: 10px; max-width: 80%; |
||||
|
animation: fadeIn 0.3s ease; |
||||
|
} |
||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } |
||||
|
.message.user { align-self: flex-end; } |
||||
|
.message.assistant { align-self: flex-start; } |
||||
|
.avatar { |
||||
|
width: 36px; height: 36px; border-radius: 50%; |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
font-size: 18px; flex-shrink: 0; |
||||
|
} |
||||
|
.message.user .avatar { background: #667eea; } |
||||
|
.message.assistant .avatar { background: #10b981; } |
||||
|
.bubble { |
||||
|
padding: 10px 16px; border-radius: 12px; |
||||
|
line-height: 1.6; font-size: 14px; word-break: break-word; |
||||
|
} |
||||
|
.message.user .bubble { background: #667eea; color: #fff; border-bottom-right-radius: 4px; } |
||||
|
.message.assistant .bubble { background: #fff; color: #333; border: 1px solid #e8e8e8; border-bottom-left-radius: 4px; } |
||||
|
.chat-input-area { |
||||
|
padding: 16px 20px; border-top: 1px solid #eee; |
||||
|
display: flex; gap: 10px; background: #fff; |
||||
|
} |
||||
|
.chat-input-area input { |
||||
|
flex: 1; padding: 12px 16px; |
||||
|
border: 1px solid #ddd; border-radius: 24px; |
||||
|
font-size: 14px; outline: none; transition: border-color 0.3s; |
||||
|
} |
||||
|
.chat-input-area input:focus { border-color: #667eea; } |
||||
|
.chat-input-area button { |
||||
|
padding: 12px 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
color: #fff; border: none; border-radius: 24px; |
||||
|
font-size: 14px; cursor: pointer; transition: opacity 0.3s; |
||||
|
} |
||||
|
.chat-input-area button:hover { opacity: 0.9; } |
||||
|
.chat-input-area button:disabled { opacity: 0.5; cursor: not-allowed; } |
||||
|
.typing-indicator { |
||||
|
display: flex; gap: 4px; padding: 10px 16px; |
||||
|
} |
||||
|
.typing-indicator span { |
||||
|
width: 8px; height: 8px; background: #999; |
||||
|
border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; |
||||
|
} |
||||
|
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; } |
||||
|
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; } |
||||
|
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } } |
||||
|
.info-bar { |
||||
|
padding: 8px 20px; background: #f0f2f5; font-size: 12px; |
||||
|
color: #999; display: flex; justify-content: space-between; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="chat-container"> |
||||
|
<div class="chat-header"> |
||||
|
🤖 AI 智能客服<span>基于通义千问 · 支持多轮对话</span> |
||||
|
</div> |
||||
|
<div class="info-bar"> |
||||
|
<span>会话ID: <strong id="chatIdDisplay">---</strong></span> |
||||
|
<span><a href="http://localhost:8080/doc.html" target="_blank" style="color:#667eea;">📖 API文档</a></span> |
||||
|
</div> |
||||
|
<div class="chat-messages" id="messages"> |
||||
|
<div class="message assistant"> |
||||
|
<div class="avatar">🤖</div> |
||||
|
<div class="bubble"> |
||||
|
您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。<br>请问有什么可以帮您的? |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="chat-input-area"> |
||||
|
<input type="text" id="userInput" placeholder="输入您的问题,按回车发送..." |
||||
|
onkeydown="if(event.key==='Enter') sendMessage()" autofocus> |
||||
|
<button id="sendBtn" onclick="sendMessage()">发送</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
// 生成唯一会话 ID |
||||
|
const chatId = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6); |
||||
|
document.getElementById('chatIdDisplay').textContent = chatId; |
||||
|
|
||||
|
const API_BASE = 'http://localhost:8080'; |
||||
|
|
||||
|
function addMessage(role, content) { |
||||
|
const div = document.createElement('div'); |
||||
|
div.className = 'message ' + role; |
||||
|
const avatar = role === 'user' ? '👤' : '🤖'; |
||||
|
if (role === 'user') { |
||||
|
div.innerHTML = `<div class="bubble">${escapeHtml(content)}</div><div class="avatar">${avatar}</div>`; |
||||
|
} else { |
||||
|
div.innerHTML = `<div class="avatar">${avatar}</div><div class="bubble">${escapeHtml(content)}</div>`; |
||||
|
} |
||||
|
document.getElementById('messages').appendChild(div); |
||||
|
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight; |
||||
|
} |
||||
|
|
||||
|
function addTyping() { |
||||
|
const div = document.createElement('div'); |
||||
|
div.className = 'message assistant'; |
||||
|
div.id = 'typing-msg'; |
||||
|
div.innerHTML = `<div class="avatar">🤖</div><div class="bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>`; |
||||
|
document.getElementById('messages').appendChild(div); |
||||
|
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight; |
||||
|
} |
||||
|
|
||||
|
function removeTyping() { |
||||
|
const el = document.getElementById('typing-msg'); |
||||
|
if (el) el.remove(); |
||||
|
} |
||||
|
|
||||
|
function escapeHtml(text) { |
||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
||||
|
} |
||||
|
|
||||
|
async function sendMessage() { |
||||
|
const input = document.getElementById('userInput'); |
||||
|
const btn = document.getElementById('sendBtn'); |
||||
|
const message = input.value.trim(); |
||||
|
if (!message) return; |
||||
|
|
||||
|
input.value = ''; |
||||
|
input.disabled = true; |
||||
|
btn.disabled = true; |
||||
|
|
||||
|
addMessage('user', message); |
||||
|
addTyping(); |
||||
|
|
||||
|
try { |
||||
|
const url = `${API_BASE}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`; |
||||
|
const res = await fetch(url); |
||||
|
removeTyping(); |
||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`); |
||||
|
const text = await res.text(); |
||||
|
addMessage('assistant', text); |
||||
|
} catch (err) { |
||||
|
removeTyping(); |
||||
|
addMessage('assistant', '抱歉,请求失败:' + err.message + '。请确认后端服务已启动。'); |
||||
|
} finally { |
||||
|
input.disabled = false; |
||||
|
btn.disabled = false; |
||||
|
input.focus(); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,684 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>AI 智能客服系统 - Support Bot</title> |
||||
|
<style> |
||||
|
:root { --bg:#f0f2f5; --card:#fff; --primary:#6366f1; --primary2:#8b5cf6; --success:#10b981; --warn:#f59e0b; --danger:#ef4444; --text:#1f2937; --sub:#6b7280; --border:#e5e7eb; --radius:10px; } |
||||
|
|
||||
|
* { margin:0; padding:0; box-sizing:border-box; } |
||||
|
body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(--bg); color:var(--text); min-height:100vh; } |
||||
|
|
||||
|
/* 顶部导航 */ |
||||
|
.topbar { background:linear-gradient(135deg, var(--primary) 0%, var(--primary2) 100%); color:#fff; padding:0 24px; height:56px; display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:100; } |
||||
|
.topbar .logo { font-size:20px; font-weight:700; } |
||||
|
.topbar .ver { font-size:12px; opacity:.7; margin-left:4px; } |
||||
|
.topbar .links { margin-left:auto; display:flex; gap:12px; } |
||||
|
.topbar .links a { color:#fff; opacity:.8; text-decoration:none; font-size:13px; transition:opacity .2s; } |
||||
|
.topbar .links a:hover { opacity:1; } |
||||
|
|
||||
|
/* Tab 导航 */ |
||||
|
.tabs { display:flex; gap:0; background:var(--card); border-bottom:1px solid var(--border); padding:0 24px; position:sticky; top:56px; z-index:99; } |
||||
|
.tab-btn { padding:14px 24px; border:none; background:none; font-size:14px; cursor:pointer; border-bottom:3px solid transparent; color:var(--sub); transition:all .2s; font-family:inherit; display:flex; align-items:center; gap:6px; } |
||||
|
.tab-btn:hover { color:var(--text); } |
||||
|
.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; } |
||||
|
.tab-icon { font-size:18px; } |
||||
|
|
||||
|
/* 内容区 */ |
||||
|
.main { max-width:1200px; 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);} } |
||||
|
|
||||
|
/* 卡片 */ |
||||
|
.card { background:var(--card); border-radius:var(--radius); padding:20px; margin-bottom:16px; border:1px solid var(--border); } |
||||
|
.card h2 { font-size:16px; margin-bottom:12px; display:flex; align-items:center; gap:8px; } |
||||
|
.card h3 { font-size:14px; margin-bottom:8px; color:var(--sub); } |
||||
|
|
||||
|
/* 表单 */ |
||||
|
.input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; } |
||||
|
.input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; } |
||||
|
.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); } |
||||
|
.input { flex:1; min-width:200px; } |
||||
|
.textarea { width:100%; resize:vertical; min-height:120px; } |
||||
|
.select { min-width:160px; } |
||||
|
.input-sm { width:120px; flex:none; } |
||||
|
|
||||
|
.btn { padding:10px 20px; border:none; border-radius:8px; font-size:14px; cursor:pointer; font-family:inherit; font-weight:500; transition:all .2s; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; } |
||||
|
.btn:disabled { opacity:.5; cursor:not-allowed; } |
||||
|
.btn-primary { background:var(--primary); color:#fff; } |
||||
|
.btn-primary:hover:not(:disabled) { background:#4f46e5; } |
||||
|
.btn-purple { background:var(--primary2); color:#fff; } |
||||
|
.btn-purple:hover:not(:disabled) { background:#7c3aed; } |
||||
|
.btn-success { background:var(--success); color:#fff; } |
||||
|
.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; } |
||||
|
|
||||
|
/* 消息区 */ |
||||
|
.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; } |
||||
|
.msg { display:flex; gap:10px; max-width:85%; animation: fadeIn .3s; } |
||||
|
.msg.user { align-self:flex-end; flex-direction:row-reverse; } |
||||
|
.msg.assistant { align-self:flex-start; } |
||||
|
.msg-avatar { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; flex-shrink:0; } |
||||
|
.msg.user .msg-avatar { background:var(--primary); color:#fff; } |
||||
|
.msg.assistant .msg-avatar { background:var(--success); color:#fff; } |
||||
|
.msg-bubble { padding:10px 14px; border-radius:12px; line-height:1.6; font-size:14px; word-break:break-word; white-space:pre-wrap; } |
||||
|
.msg.user .msg-bubble { background:var(--primary); color:#fff; border-bottom-right-radius:4px; } |
||||
|
.msg.assistant .msg-bubble { background:#fff; border:1px solid var(--border); border-bottom-left-radius:4px; } |
||||
|
.msg.streaming .msg-bubble { border-color:var(--primary); box-shadow:0 0 0 1px var(--primary); } |
||||
|
|
||||
|
/* 商品信息展示 */ |
||||
|
.result-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:12px; margin-top:12px; } |
||||
|
.result-item { background:#f9fafb; padding:12px 16px; border-radius:8px; border:1px solid var(--border); } |
||||
|
.result-item .label { font-size:12px; color:var(--sub); margin-bottom:4px; } |
||||
|
.result-item .value { font-size:14px; font-weight:500; } |
||||
|
.result-json { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:8px; font-family:'Fira Code',monospace; font-size:13px; white-space:pre-wrap; overflow-x:auto; } |
||||
|
|
||||
|
/* 文件上传区 */ |
||||
|
.upload-zone { border:2px dashed var(--border); border-radius:var(--radius); padding:32px; text-align:center; transition:all .2s; cursor:pointer; margin-bottom:12px; } |
||||
|
.upload-zone:hover, .upload-zone.drag-over { border-color:var(--primary); background:#eef2ff; } |
||||
|
.upload-zone p { color:var(--sub); } |
||||
|
.upload-zone .icon { font-size:40px; margin-bottom:8px; } |
||||
|
|
||||
|
/* Toast */ |
||||
|
.toast-container { position:fixed; top:70px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; } |
||||
|
.toast { padding:10px 18px; border-radius:8px; color:#fff; font-size:13px; animation:slideIn .3s; box-shadow:0 4px 12px rgba(0,0,0,.15); } |
||||
|
.toast.success { background:var(--success); } |
||||
|
.toast.error { background:var(--danger); } |
||||
|
.toast.info { background:var(--primary); } |
||||
|
@keyframes slideIn { from{opacity:0;transform:translateX(100px);} to{opacity:1;transform:translateX(0);} } |
||||
|
|
||||
|
/* 流式结果比较 */ |
||||
|
.stream-compare { display:grid; grid-template-columns:1fr 1fr; gap:16px; } |
||||
|
.stream-compare .card { margin-bottom:0; } |
||||
|
|
||||
|
/* 响应式 */ |
||||
|
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } } |
||||
|
|
||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; } |
||||
|
.badge-get { background:#dbeafe; color:#1d4ed8; } |
||||
|
.badge-post { background:#fef3c7; color:#b45309; } |
||||
|
.endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; } |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<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> |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="tabs" id="tabs"> |
||||
|
<button class="tab-btn active" data-tab="chat"><span class="tab-icon">💬</span>智能客服对话</button> |
||||
|
<button class="tab-btn" data-tab="product"><span class="tab-icon">🏷️</span>商品信息提取</button> |
||||
|
<button class="tab-btn" data-tab="document"><span class="tab-icon">📄</span>知识库文档管理</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="main"> |
||||
|
|
||||
|
<!-- ==================== Tab 1: AI 智能客服对话 ==================== --> |
||||
|
<div class="tab-panel active" id="panel-chat"> |
||||
|
<div class="card"> |
||||
|
<h2>💬 智能客服对话</h2> |
||||
|
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆</p> |
||||
|
|
||||
|
<div class="input-row"> |
||||
|
<input class="input input-sm" id="chatId" placeholder="会话ID" value=""> |
||||
|
<select class="select" id="modeSelect"> |
||||
|
<option value="sync">🔵 同步调用</option> |
||||
|
<option value="sse">🟢 SSE 流式 (Flux)</option> |
||||
|
<option value="sse2">🟡 ServerSentEvent</option> |
||||
|
<option value="sse3">🟣 SseEmitter</option> |
||||
|
</select> |
||||
|
<button class="btn btn-outline btn-sm" onclick="newChatId()">🔄 新会话</button> |
||||
|
<button class="btn btn-outline btn-sm" onclick="clearMessages()">🗑️ 清屏</button> |
||||
|
</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> |
||||
|
</div> |
||||
|
|
||||
|
<div class="input-row"> |
||||
|
<input class="input" id="userInput" placeholder="输入问题,Enter 发送..." onkeydown="if(event.key==='Enter')chatSend()"> |
||||
|
<button class="btn btn-primary" id="sendBtn" onclick="chatSend()">📨 发送</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ==================== Tab 2: 商品信息提取 ==================== --> |
||||
|
<div class="tab-panel" id="panel-product"> |
||||
|
<div class="card"> |
||||
|
<div class="endpoint-info"> |
||||
|
<span class="badge badge-get">GET</span> |
||||
|
<code style="font-size:13px;">/ai/product_info_app/chat/sync</code> |
||||
|
</div> |
||||
|
<h2>🏷️ 商品信息结构化提取</h2> |
||||
|
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类</p> |
||||
|
|
||||
|
<textarea class="textarea" id="productContent" placeholder="请输入商品网页内容或描述文本... |
||||
|
例如:Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类..."></textarea> |
||||
|
|
||||
|
<div class="input-row" style="margin-top:12px;"> |
||||
|
<button class="btn btn-primary" id="extractBtn" onclick="extractProduct()">🔍 提取商品信息</button> |
||||
|
<button class="btn btn-outline btn-sm" onclick="document.getElementById('productContent').value=''">清空</button> |
||||
|
<button class="btn btn-outline btn-sm" onclick="document.getElementById('productContent').value='Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类'">填入示例</button> |
||||
|
</div> |
||||
|
|
||||
|
<div id="productResult" style="margin-top:16px;display:none;"> |
||||
|
<h3>📊 提取结果</h3> |
||||
|
<div class="result-grid" id="productGrid"></div> |
||||
|
<div class="result-json" id="productJson" style="margin-top:12px;"></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ==================== Tab 3: 知识库文档管理 ==================== --> |
||||
|
<div class="tab-panel" id="panel-document"> |
||||
|
<div class="card"> |
||||
|
<h2>📄 知识库文档管理</h2> |
||||
|
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector,即可用于 AI 检索问答</p> |
||||
|
|
||||
|
<!-- 子 Tab --> |
||||
|
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;"> |
||||
|
<button class="btn btn-sm doc-sub-tab active" data-doc="file">📎 通用文件</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="string">📝 文本内容</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="markdown">📑 Markdown</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="jsonBasic">📋 JSON 基本</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="jsonFields">🔑 JSON 按字段</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="jsonPointer">📍 JSON 按指针</button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 通用文件 --> |
||||
|
<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="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')"> |
||||
|
</div> |
||||
|
<div id="fileFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<button class="btn btn-primary" id="btn-file" onclick="uploadDocument('file')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-file-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 文本内容 --> |
||||
|
<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="输入要加入知识库的文本内容... 例如:公司退换货政策、商品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="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')"> |
||||
|
</div> |
||||
|
<div id="mdFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<button class="btn btn-primary" id="btn-markdown" onclick="uploadDocument('markdown')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-markdown-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 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="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')"> |
||||
|
</div> |
||||
|
<div id="jsonBFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<button class="btn btn-primary" id="btn-jsonBasic" onclick="uploadDocument('jsonBasic')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-jsonBasic-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 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="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')"> |
||||
|
</div> |
||||
|
<div id="jsonFFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<input class="input" id="jsonFieldsInput" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;"> |
||||
|
<button class="btn btn-primary" id="btn-jsonFields" onclick="uploadDocument('jsonFields')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-jsonFields-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 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="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')"> |
||||
|
</div> |
||||
|
<div id="jsonPFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<input class="input" id="jsonPointerInput" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;"> |
||||
|
<button class="btn btn-primary" id="btn-jsonPointer" onclick="uploadDocument('jsonPointer')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-jsonPointer-result"></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 上传历史 --> |
||||
|
<div class="card"> |
||||
|
<h2>📋 最近上传记录</h2> |
||||
|
<div id="uploadLog" style="font-size:13px;color:var(--sub);">暂无上传记录</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
<div class="toast-container" id="toasts"></div> |
||||
|
|
||||
|
<script> |
||||
|
// ==================== 全局状态 ==================== |
||||
|
const API = 'http://localhost:8080'; |
||||
|
let currentChatId = ''; |
||||
|
|
||||
|
// ==================== 工具函数 ==================== |
||||
|
function toast(msg, type='info') { |
||||
|
const c = document.getElementById('toasts'); |
||||
|
const d = document.createElement('div'); |
||||
|
d.className = 'toast ' + type; |
||||
|
d.textContent = msg; |
||||
|
c.appendChild(d); |
||||
|
setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.remove(),300);},2500); |
||||
|
} |
||||
|
|
||||
|
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'; } |
||||
|
|
||||
|
// ==================== Tab 切换 ==================== |
||||
|
document.getElementById('tabs').addEventListener('click', function(e) { |
||||
|
const btn = e.target.closest('.tab-btn'); |
||||
|
if(!btn) return; |
||||
|
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); |
||||
|
btn.classList.add('active'); |
||||
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); |
||||
|
document.getElementById('panel-'+btn.dataset.tab).classList.add('active'); |
||||
|
}); |
||||
|
|
||||
|
// 子 Tab 切换 (文档管理) |
||||
|
document.addEventListener('click', function(e) { |
||||
|
const btn = e.target.closest('.doc-sub-tab'); |
||||
|
if(!btn) return; |
||||
|
document.querySelectorAll('.doc-sub-tab').forEach(b=>{b.classList.remove('active');b.style.background='';b.style.color='';}); |
||||
|
btn.classList.add('active'); |
||||
|
btn.style.background='var(--primary)'; btn.style.color='#fff'; |
||||
|
document.querySelectorAll('.doc-panel').forEach(p=>p.style.display='none'); |
||||
|
document.getElementById('doc-'+btn.dataset.doc).style.display='block'; |
||||
|
}); |
||||
|
|
||||
|
// ==================== 初始化 ==================== |
||||
|
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); |
||||
|
$('chatId').value = currentChatId; |
||||
|
return currentChatId; |
||||
|
} |
||||
|
function clearMessages() { |
||||
|
$('chatMessages').innerHTML = ''; |
||||
|
$('chatMessages').innerHTML = '<div class="msg assistant"><div class="msg-avatar">🤖</div><div class="msg-bubble">已清屏,开始新的对话吧~</div></div>'; |
||||
|
} |
||||
|
|
||||
|
// ==================== 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 area = $('chatMessages'); |
||||
|
area.scrollTop = area.scrollHeight; |
||||
|
return el.querySelector('.msg-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 { |
||||
|
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; |
||||
|
} |
||||
|
} catch(e) { |
||||
|
bubble.textContent = '请求失败:' + e.message; |
||||
|
toast('对话失败:' + e.message, 'error'); |
||||
|
} finally { |
||||
|
bubble.parentElement.classList.remove('streaming'); |
||||
|
input.disabled = false; $('sendBtn').disabled = false; |
||||
|
input.focus(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 接口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); |
||||
|
} |
||||
|
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); |
||||
|
} |
||||
|
const reader = res.body.getReader(); |
||||
|
const decoder = new TextDecoder(); |
||||
|
let buffer = ''; |
||||
|
while(true) { |
||||
|
const {done, value} = await reader.read(); |
||||
|
if(done) break; |
||||
|
buffer += decoder.decode(value, {stream:true}); |
||||
|
const lines = buffer.split('\n'); |
||||
|
buffer = lines.pop() || ''; |
||||
|
for(const line of lines) { |
||||
|
if(line.startsWith('data:')) { |
||||
|
const data = line.slice(5).trim(); |
||||
|
if(data && data !== '[DONE]') bubble.textContent += data; |
||||
|
} else if(line.trim() && !line.startsWith(':')) { |
||||
|
bubble.textContent += line; |
||||
|
} |
||||
|
} |
||||
|
$('chatMessages').scrollTop = $('chatMessages').scrollHeight; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 接口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); |
||||
|
} |
||||
|
const reader = res.body.getReader(); |
||||
|
const decoder = new TextDecoder(); |
||||
|
let buffer = ''; |
||||
|
while(true) { |
||||
|
const {done, value} = await reader.read(); |
||||
|
if(done) break; |
||||
|
buffer += decoder.decode(value, {stream:true}); |
||||
|
const lines = buffer.split('\n'); |
||||
|
buffer = lines.pop() || ''; |
||||
|
for(const line of lines) { |
||||
|
if(line.startsWith('data:')) { |
||||
|
const data = line.slice(5).trim(); |
||||
|
if(data && data !== '[DONE]') bubble.textContent += data; |
||||
|
} |
||||
|
} |
||||
|
$('chatMessages').scrollTop = $('chatMessages').scrollHeight; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 接口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); |
||||
|
} |
||||
|
const reader = res.body.getReader(); |
||||
|
const decoder = new TextDecoder(); |
||||
|
let buffer = ''; |
||||
|
while(true) { |
||||
|
const {done, value} = await reader.read(); |
||||
|
if(done) break; |
||||
|
buffer += decoder.decode(value, {stream:true}); |
||||
|
const lines = buffer.split('\n'); |
||||
|
buffer = lines.pop() || ''; |
||||
|
for(const line of lines) { |
||||
|
if(line.startsWith('data:')) { |
||||
|
const data = line.slice(5).trim(); |
||||
|
if(data && data !== '[DONE]') bubble.textContent += data; |
||||
|
} else if(line.trim() && !line.startsWith(':')) { |
||||
|
bubble.textContent += line; |
||||
|
} |
||||
|
} |
||||
|
$('chatMessages').scrollTop = $('chatMessages').scrollHeight; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ==================== 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] : '—'; |
||||
|
html += `<div class="result-item"><div class="label">${labels[f]}</div><div class="value">${val}</div></div>`; |
||||
|
} |
||||
|
$('productGrid').innerHTML = html; |
||||
|
$('productJson').textContent = JSON.stringify(data, null, 2); |
||||
|
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 = '🔍 提取商品信息'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ==================== 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)})`; |
||||
|
$(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; |
||||
|
|
||||
|
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 'jsonFields': |
||||
|
resultId = 'doc-jsonFields-result'; |
||||
|
{ const fieldsStr = $('jsonFieldsInput').value.trim(); |
||||
|
if(!fieldsStr) { toast('请输入要提取的字段名', 'error'); return; } |
||||
|
baseUrl = `${API}/document/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)}`; } |
||||
|
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; |
||||
|
|
||||
|
// 字符串上传:单次请求 |
||||
|
if(type === 'string') { |
||||
|
if(btn) { btn.disabled = true; btn.textContent = '⏳ 处理中...'; } |
||||
|
try { |
||||
|
const res = await fetch(baseUrl, { 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); } |
||||
|
} 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>`; |
||||
|
toast('上传失败:'+e.message, 'error'); |
||||
|
} finally { |
||||
|
if(btn) { btn.disabled = false; btn.textContent = '🚀 上传并向量化'; } |
||||
|
} |
||||
|
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}` : '⏳ 处理中...'; } |
||||
|
|
||||
|
let successCount = 0, failCount = 0, totalDocs = 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}`; |
||||
|
|
||||
|
try { |
||||
|
const formData = new FormData(); |
||||
|
formData.append('file', file); |
||||
|
const res = await fetch(baseUrl, { 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); |
||||
|
} else { |
||||
|
failCount++; |
||||
|
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}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 汇总结果 |
||||
|
const summary = successCount > 0 |
||||
|
? `✅ 上传完成:成功 <strong>${successCount}</strong> 个${failCount > 0 ? `,失败 <strong>${failCount}</strong> 个` : ''},共生成 <strong>${totalDocs}</strong> 个文档向量` |
||||
|
: `❌ 全部失败(${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'); |
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
// 拖放支持 |
||||
|
['zone-file','zone-md','zone-jsonB','zone-jsonF','zone-jsonP'].forEach(id => { |
||||
|
const zone = document.getElementById(id); |
||||
|
if(!zone) return; |
||||
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); |
||||
|
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); |
||||
|
zone.addEventListener('drop', e => { |
||||
|
e.preventDefault(); |
||||
|
zone.classList.remove('drag-over'); |
||||
|
const inputId = id.replace('zone-',''); |
||||
|
const inputMap = {file:'fileInput', md:'mdInput', jsonB:'jsonBInput', jsonF:'jsonFInput', jsonP:'jsonPInput'}; |
||||
|
const typeMap = {file:'file', md:'markdown', jsonB:'jsonBasic', jsonF:'jsonFields', jsonP:'jsonPointer'}; |
||||
|
const input = document.getElementById(inputMap[inputId]); |
||||
|
if(input && e.dataTransfer.files[0]) { |
||||
|
input.files = e.dataTransfer.files; |
||||
|
handleFileSelect(input, typeMap[inputId]); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// ==================== 启动 ==================== |
||||
|
init(); |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,117 @@ |
|||||
|
package com.wok.supportbot.config; |
||||
|
|
||||
|
import jakarta.annotation.PostConstruct; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
/** |
||||
|
* 数据库初始化配置 |
||||
|
* 应用启动时检查并创建必要的表 |
||||
|
*/ |
||||
|
@Component |
||||
|
@Slf4j |
||||
|
public class DatabaseInitConfig { |
||||
|
|
||||
|
@Autowired |
||||
|
private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@PostConstruct |
||||
|
public void init() { |
||||
|
try { |
||||
|
// 检查 knowledge_category 表是否存在 |
||||
|
boolean categoryTableExists = checkTableExists("knowledge_category"); |
||||
|
if (!categoryTableExists) { |
||||
|
log.info("创建知识库分类表 knowledge_category"); |
||||
|
createCategoryTable(); |
||||
|
} |
||||
|
|
||||
|
// 检查 knowledge_document 表是否存在 |
||||
|
boolean documentTableExists = checkTableExists("knowledge_document"); |
||||
|
if (!documentTableExists) { |
||||
|
log.info("创建知识文档表 knowledge_document"); |
||||
|
createDocumentTable(); |
||||
|
} else { |
||||
|
// 修复已存在表的 tags 默认值(从数组改为对象) |
||||
|
fixTagsDefaultValue(); |
||||
|
} |
||||
|
|
||||
|
log.info("数据库初始化完成"); |
||||
|
} catch (Exception e) { |
||||
|
log.error("数据库初始化失败", e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private boolean checkTableExists(String tableName) { |
||||
|
try { |
||||
|
String sql = "SELECT 1 FROM " + tableName + " LIMIT 1"; |
||||
|
jdbcTemplate.queryForObject(sql, Integer.class); |
||||
|
return true; |
||||
|
} catch (Exception e) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void createCategoryTable() { |
||||
|
String sql = """ |
||||
|
CREATE TABLE IF NOT EXISTS knowledge_category ( |
||||
|
id BIGSERIAL PRIMARY KEY, |
||||
|
name VARCHAR(100) NOT NULL, |
||||
|
description TEXT, |
||||
|
parent_id BIGINT DEFAULT 0 NOT NULL, |
||||
|
sort_order INTEGER DEFAULT 0 NOT NULL, |
||||
|
document_count INTEGER DEFAULT 0 NOT NULL, |
||||
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
|
is_delete BOOLEAN DEFAULT FALSE NOT NULL |
||||
|
) |
||||
|
"""; |
||||
|
jdbcTemplate.execute(sql); |
||||
|
|
||||
|
// 创建索引 |
||||
|
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_category_parent ON knowledge_category (parent_id)"); |
||||
|
} |
||||
|
|
||||
|
private void createDocumentTable() { |
||||
|
String sql = """ |
||||
|
CREATE TABLE IF NOT EXISTS knowledge_document ( |
||||
|
id BIGSERIAL PRIMARY KEY, |
||||
|
title VARCHAR(500) NOT NULL, |
||||
|
source_name VARCHAR(500), |
||||
|
file_type VARCHAR(20) NOT NULL, |
||||
|
file_size BIGINT DEFAULT 0 NOT NULL, |
||||
|
content TEXT, |
||||
|
category_id BIGINT DEFAULT 0 NOT NULL, |
||||
|
tags JSONB DEFAULT '{}' NOT NULL, |
||||
|
chunk_count INTEGER DEFAULT 0 NOT NULL, |
||||
|
status VARCHAR(20) DEFAULT 'PROCESSING' NOT NULL, |
||||
|
error_message TEXT, |
||||
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
|
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
|
is_delete BOOLEAN DEFAULT FALSE NOT NULL |
||||
|
) |
||||
|
"""; |
||||
|
jdbcTemplate.execute(sql); |
||||
|
|
||||
|
// 创建索引 |
||||
|
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id)"); |
||||
|
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status)"); |
||||
|
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC)"); |
||||
|
} |
||||
|
|
||||
|
private void fixTagsDefaultValue() { |
||||
|
try { |
||||
|
// 检查当前默认值是否为数组 |
||||
|
String checkSql = "SELECT column_default FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'tags'"; |
||||
|
String currentDefault = jdbcTemplate.queryForObject(checkSql, String.class); |
||||
|
if (currentDefault != null && currentDefault.contains("[]")) { |
||||
|
log.info("修复 knowledge_document.tags 默认值"); |
||||
|
jdbcTemplate.execute("ALTER TABLE knowledge_document ALTER COLUMN tags SET DEFAULT '{}'"); |
||||
|
// 将已有的 '[]' 更新为 '{}' |
||||
|
jdbcTemplate.execute("UPDATE knowledge_document SET tags = '{}' WHERE tags = '[]' OR tags IS NULL"); |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
log.warn("修复 tags 默认值时出错(可能已修复)", e); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package com.wok.supportbot.config; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; |
||||
|
import org.springframework.context.annotation.Bean; |
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
|
||||
|
/** |
||||
|
* MyBatis Plus 配置类 |
||||
|
*/ |
||||
|
@Configuration |
||||
|
public class MybatisPlusConfig { |
||||
|
|
||||
|
/** |
||||
|
* MyBatis Plus 拦截器 |
||||
|
* 当前版本分页插件通过手动方式实现 |
||||
|
*/ |
||||
|
@Bean |
||||
|
public MybatisPlusInterceptor mybatisPlusInterceptor() { |
||||
|
return new MybatisPlusInterceptor(); |
||||
|
} |
||||
|
} |
||||
@ -1,231 +1,491 @@ |
|||||
package com.wok.supportbot.controller; |
package com.wok.supportbot.controller; |
||||
|
|
||||
import com.wok.supportbot.document.extract.JsonDocumentLoader; |
|
||||
import com.wok.supportbot.document.extract.MarkdownDocumentLoader; |
|
||||
import com.wok.supportbot.document.extract.SimpleStringDocumentReader; |
|
||||
import com.wok.supportbot.document.extract.TikaDocumentReader; |
|
||||
import com.wok.supportbot.document.transform.MyKeywordEnricher; |
|
||||
import com.wok.supportbot.document.transform.MyTokenTextSplitter; |
|
||||
import org.springframework.ai.document.Document; |
|
||||
import org.springframework.ai.vectorstore.VectorStore; |
|
||||
|
import com.wok.supportbot.entity.CategoryNode; |
||||
|
import com.wok.supportbot.entity.KnowledgeCategory; |
||||
|
import com.wok.supportbot.entity.KnowledgeDocument; |
||||
|
import com.wok.supportbot.entity.SearchResult; |
||||
|
import com.wok.supportbot.service.DocumentService; |
||||
import org.springframework.beans.factory.annotation.Autowired; |
import org.springframework.beans.factory.annotation.Autowired; |
||||
import org.springframework.http.ResponseEntity; |
import org.springframework.http.ResponseEntity; |
||||
import org.springframework.web.bind.annotation.*; |
import org.springframework.web.bind.annotation.*; |
||||
import org.springframework.web.multipart.MultipartFile; |
import org.springframework.web.multipart.MultipartFile; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
import java.util.List; |
import java.util.List; |
||||
import java.util.Map; |
import java.util.Map; |
||||
|
|
||||
|
/** |
||||
|
* 知识库文档管理控制器 |
||||
|
* 提供文档上传、查询、管理、分类、搜索等完整功能 |
||||
|
*/ |
||||
@RestController |
@RestController |
||||
@RequestMapping("/document") |
|
||||
public class DocumentController { |
public class DocumentController { |
||||
|
|
||||
@Autowired |
@Autowired |
||||
private TikaDocumentReader tikaDocumentReader; |
|
||||
|
private DocumentService documentService; |
||||
|
|
||||
@Autowired |
|
||||
private SimpleStringDocumentReader simpleStringDocumentReader; |
|
||||
|
|
||||
@Autowired |
|
||||
private MarkdownDocumentLoader markdownDocumentLoader; |
|
||||
|
|
||||
@Autowired |
|
||||
private JsonDocumentLoader jsonDocumentLoader; |
|
||||
|
|
||||
@Autowired |
|
||||
private MyTokenTextSplitter myTokenTextSplitter; |
|
||||
|
|
||||
@Autowired |
|
||||
private MyKeywordEnricher myKeywordEnricher; |
|
||||
|
|
||||
@Autowired |
|
||||
private VectorStore pgVectorVectorStore; |
|
||||
|
// ==================== 文档上传 ==================== |
||||
|
|
||||
/** |
/** |
||||
* 上传普通文件(支持多种格式),用 Tika 解析 |
* 上传普通文件(支持多种格式),用 Tika 解析 |
||||
|
* |
||||
|
* @param file 文件 |
||||
|
* @param title 文档标题(可选,默认使用文件名) |
||||
|
* @param categoryId 分类ID(可选) |
||||
|
* @param tags 标签(可选) |
||||
|
* @return 上传结果 |
||||
*/ |
*/ |
||||
@PostMapping("/upload/file") |
@PostMapping("/upload/file") |
||||
public ResponseEntity<Map<String, Object>> uploadFile(@RequestParam("file") MultipartFile file) { |
|
||||
|
public ResponseEntity<Map<String, Object>> uploadFile( |
||||
|
@RequestParam("file") MultipartFile file, |
||||
|
@RequestParam(required = false) String title, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) List<String> tags) { |
||||
try { |
try { |
||||
List<Document> documents = tikaDocumentReader.read(file); |
|
||||
|
|
||||
// 拆分文档 |
|
||||
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
|
||||
|
|
||||
// 添加元数据 |
|
||||
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
|
||||
|
|
||||
// 转成向量并存入数据库 |
|
||||
pgVectorVectorStore.add(enrichedDocuments); |
|
||||
|
|
||||
|
KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags); |
||||
return ResponseEntity.ok(Map.of( |
return ResponseEntity.ok(Map.of( |
||||
"success", true, |
|
||||
"message", "文件上传并向量化成功", |
|
||||
"documentCount", enrichedDocuments.size() |
|
||||
|
"success", true, |
||||
|
"message", "文件上传并向量化成功", |
||||
|
"data", doc |
||||
)); |
)); |
||||
} catch (Exception e) { |
} catch (Exception e) { |
||||
return ResponseEntity.status(500).body(Map.of( |
return ResponseEntity.status(500).body(Map.of( |
||||
"success", false, |
|
||||
"message", "上传失败:" + e.getMessage() |
|
||||
|
"success", false, |
||||
|
"message", "上传失败:" + e.getMessage() |
||||
)); |
)); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/** |
/** |
||||
* 上传字符串内容 |
* 上传字符串内容 |
||||
|
* |
||||
|
* @param content 内容 |
||||
|
* @param title 标题 |
||||
|
* @param categoryId 分类ID(可选) |
||||
|
* @param tags 标签(可选) |
||||
|
* @return 上传结果 |
||||
*/ |
*/ |
||||
@PostMapping("/upload/string") |
@PostMapping("/upload/string") |
||||
public ResponseEntity<Map<String, Object>> uploadString(@RequestBody String content) { |
|
||||
|
public ResponseEntity<Map<String, Object>> uploadString( |
||||
|
@RequestBody String content, |
||||
|
@RequestParam String title, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) List<String> tags) { |
||||
try { |
try { |
||||
List<Document> documents = simpleStringDocumentReader.read(content); |
|
||||
|
|
||||
// 拆分文档 |
|
||||
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
|
||||
|
KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "文本内容上传并向量化成功", |
||||
|
"data", doc |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "上传失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 添加元数据 |
|
||||
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
|
||||
|
/** |
||||
|
* 上传 Markdown 文件 |
||||
|
*/ |
||||
|
@PostMapping("/upload/markdown") |
||||
|
public ResponseEntity<Map<String, Object>> uploadMarkdown( |
||||
|
@RequestParam("file") MultipartFile file, |
||||
|
@RequestParam(required = false) String title, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) List<String> tags) { |
||||
|
try { |
||||
|
KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "Markdown文件上传并向量化成功", |
||||
|
"data", doc |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "上传失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 转成向量并存入数据库 |
|
||||
pgVectorVectorStore.add(enrichedDocuments); |
|
||||
|
/** |
||||
|
* 上传 JSON 文件(基本方式) |
||||
|
*/ |
||||
|
@PostMapping("/upload/json/basic") |
||||
|
public ResponseEntity<Map<String, Object>> uploadJsonBasic( |
||||
|
@RequestParam("file") MultipartFile file, |
||||
|
@RequestParam(required = false) String title, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) List<String> tags) { |
||||
|
try { |
||||
|
KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "JSON文件(基本方式)上传并向量化成功", |
||||
|
"data", doc |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "上传失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 上传 JSON 文件(按字段提取) |
||||
|
*/ |
||||
|
@PostMapping("/upload/json/fields") |
||||
|
public ResponseEntity<Map<String, Object>> uploadJsonWithFields( |
||||
|
@RequestParam("file") MultipartFile file, |
||||
|
@RequestParam("fields") List<String> fields, |
||||
|
@RequestParam(required = false) String title, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) List<String> tags) { |
||||
|
try { |
||||
|
KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags); |
||||
return ResponseEntity.ok(Map.of( |
return ResponseEntity.ok(Map.of( |
||||
"success", true, |
|
||||
"message", "文本内容上传并向量化成功", |
|
||||
"documentCount", enrichedDocuments.size() |
|
||||
|
"success", true, |
||||
|
"message", "JSON文件(按字段)上传并向量化成功", |
||||
|
"data", doc, |
||||
|
"extractedFields", fields |
||||
)); |
)); |
||||
} catch (Exception e) { |
} catch (Exception e) { |
||||
return ResponseEntity.status(500).body(Map.of( |
return ResponseEntity.status(500).body(Map.of( |
||||
"success", false, |
|
||||
"message", "上传失败:" + e.getMessage() |
|
||||
|
"success", false, |
||||
|
"message", "上传失败:" + e.getMessage() |
||||
)); |
)); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/** |
/** |
||||
* 上传 Markdown 文件 |
|
||||
|
* 上传 JSON 文件(按指针拆分) |
||||
*/ |
*/ |
||||
@PostMapping("/upload/markdown") |
|
||||
public ResponseEntity<Map<String, Object>> uploadMarkdown(@RequestParam("file") MultipartFile file) { |
|
||||
|
@PostMapping("/upload/json/pointer") |
||||
|
public ResponseEntity<Map<String, Object>> uploadJsonWithPointer( |
||||
|
@RequestParam("file") MultipartFile file, |
||||
|
@RequestParam("pointer") String pointer, |
||||
|
@RequestParam(required = false) String title, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) List<String> tags) { |
||||
try { |
try { |
||||
List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(file); |
|
||||
|
KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "JSON文件(按指针)上传并向量化成功", |
||||
|
"data", doc, |
||||
|
"pointer", pointer |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "上传失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 拆分文档 |
|
||||
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
|
||||
|
// ==================== 文档管理 ==================== |
||||
|
|
||||
// 添加元数据 |
|
||||
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
|
||||
|
/** |
||||
|
* 查询文档列表(分页 + 过滤) |
||||
|
* |
||||
|
* @param page 页码(默认1) |
||||
|
* @param size 每页大小(默认10) |
||||
|
* @param categoryId 分类ID过滤(可选) |
||||
|
* @param status 状态过滤(PROCESSING/READY/FAILED,可选) |
||||
|
* @return 分页文档列表 |
||||
|
*/ |
||||
|
@GetMapping("/document/list") |
||||
|
public ResponseEntity<Map<String, Object>> listDocuments( |
||||
|
@RequestParam(defaultValue = "1") int page, |
||||
|
@RequestParam(defaultValue = "10") int size, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) String status) { |
||||
|
try { |
||||
|
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, status); |
||||
|
Map<String, Object> data = new HashMap<>(); |
||||
|
data.put("success", true); |
||||
|
data.put("data", result.get("records")); |
||||
|
data.put("total", result.get("total")); |
||||
|
data.put("page", result.get("page")); |
||||
|
data.put("size", result.get("size")); |
||||
|
data.put("pages", result.get("pages")); |
||||
|
return ResponseEntity.ok(data); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "查询失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文档详情 |
||||
|
*/ |
||||
|
@GetMapping("/document/{id}") |
||||
|
public ResponseEntity<Map<String, Object>> getDocumentDetail(@PathVariable Long id) { |
||||
|
try { |
||||
|
KnowledgeDocument doc = documentService.getDocumentDetail(id); |
||||
|
if (doc == null) { |
||||
|
return ResponseEntity.status(404).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "文档不存在" |
||||
|
)); |
||||
|
} |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"data", doc |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "查询失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 转成向量并存入数据库 |
|
||||
pgVectorVectorStore.add(enrichedDocuments); |
|
||||
|
/** |
||||
|
* 获取文档的所有分块 |
||||
|
*/ |
||||
|
@GetMapping("/document/{id}/chunks") |
||||
|
public ResponseEntity<Map<String, Object>> getDocumentChunks(@PathVariable Long id) { |
||||
|
try { |
||||
|
List<Map<String, Object>> chunks = documentService.getDocumentChunks(id); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"data", chunks, |
||||
|
"total", chunks.size() |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "查询失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除文档(逻辑删除 + 级联删除向量) |
||||
|
*/ |
||||
|
@DeleteMapping("/document/{id}") |
||||
|
public ResponseEntity<Map<String, Object>> deleteDocument(@PathVariable Long id) { |
||||
|
try { |
||||
|
int vectorCount = documentService.deleteDocument(id); |
||||
return ResponseEntity.ok(Map.of( |
return ResponseEntity.ok(Map.of( |
||||
"success", true, |
|
||||
"message", "Markdown文件上传并向量化成功", |
|
||||
"documentCount", enrichedDocuments.size() |
|
||||
|
"success", true, |
||||
|
"message", "删除成功", |
||||
|
"deletedVectors", vectorCount |
||||
)); |
)); |
||||
} catch (Exception e) { |
} catch (Exception e) { |
||||
return ResponseEntity.status(500).body(Map.of( |
return ResponseEntity.status(500).body(Map.of( |
||||
"success", false, |
|
||||
"message", "上传失败:" + e.getMessage() |
|
||||
|
"success", false, |
||||
|
"message", "删除失败:" + e.getMessage() |
||||
)); |
)); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/** |
/** |
||||
* 上传 JSON 文件(基本方式) |
|
||||
* 把 JSON 根节点当成一个整体文档 |
|
||||
|
* 更新文档元信息 |
||||
*/ |
*/ |
||||
@PostMapping("/upload/json/basic") |
|
||||
public ResponseEntity<Map<String, Object>> uploadJsonBasic(@RequestParam("file") MultipartFile file) { |
|
||||
|
@PutMapping("/document/{id}") |
||||
|
public ResponseEntity<Map<String, Object>> updateDocument( |
||||
|
@PathVariable Long id, |
||||
|
@RequestParam(required = false) String title, |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
@RequestParam(required = false) List<String> tags) { |
||||
try { |
try { |
||||
List<Document> documents = jsonDocumentLoader.loadBasicJson(file); |
|
||||
|
documentService.updateDocumentMetadata(id, title, categoryId, tags); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "更新成功" |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "更新失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 拆分文档 |
|
||||
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
|
||||
|
/** |
||||
|
* 重新处理文档(重新分块 + 向量化) |
||||
|
*/ |
||||
|
@PutMapping("/document/{id}/reprocess") |
||||
|
public ResponseEntity<Map<String, Object>> reprocessDocument(@PathVariable Long id) { |
||||
|
try { |
||||
|
KnowledgeDocument doc = documentService.reprocessDocument(id); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "重新处理成功", |
||||
|
"data", doc |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "重新处理失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 添加元数据 |
|
||||
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
|
||||
|
// ==================== 语义搜索 ==================== |
||||
|
|
||||
// 转成向量并存入数据库 |
|
||||
pgVectorVectorStore.add(enrichedDocuments); |
|
||||
|
/** |
||||
|
* 语义搜索 |
||||
|
* |
||||
|
* @param body 搜索参数 |
||||
|
* @return 搜索结果 |
||||
|
*/ |
||||
|
@PostMapping("/document/search") |
||||
|
public ResponseEntity<Map<String, Object>> searchDocuments(@RequestBody Map<String, Object> body) { |
||||
|
try { |
||||
|
String query = (String) body.get("query"); |
||||
|
int topK = body.get("topK") != null ? ((Number) body.get("topK")).intValue() : 5; |
||||
|
double similarityThreshold = body.get("similarityThreshold") != null |
||||
|
? ((Number) body.get("similarityThreshold")).doubleValue() : 0.5; |
||||
|
Long categoryId = body.get("categoryId") != null ? ((Number) body.get("categoryId")).longValue() : null; |
||||
|
|
||||
|
List<SearchResult> results = documentService.searchDocuments(query, topK, similarityThreshold, categoryId); |
||||
return ResponseEntity.ok(Map.of( |
return ResponseEntity.ok(Map.of( |
||||
"success", true, |
|
||||
"message", "JSON文件(基本方式)上传并向量化成功", |
|
||||
"documentCount", enrichedDocuments.size() |
|
||||
|
"success", true, |
||||
|
"data", results, |
||||
|
"total", results.size() |
||||
)); |
)); |
||||
} catch (Exception e) { |
} catch (Exception e) { |
||||
return ResponseEntity.status(500).body(Map.of( |
return ResponseEntity.status(500).body(Map.of( |
||||
"success", false, |
|
||||
"message", "上传失败:" + e.getMessage() |
|
||||
|
"success", false, |
||||
|
"message", "搜索失败:" + e.getMessage() |
||||
)); |
)); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
|
// ==================== 统计 ==================== |
||||
|
|
||||
/** |
/** |
||||
* 上传 JSON 文件(按字段提取) |
|
||||
* 用于提取指定字段文本 |
|
||||
|
* 知识库统计面板 |
||||
*/ |
*/ |
||||
@PostMapping("/upload/json/fields") |
|
||||
public ResponseEntity<Map<String, Object>> uploadJsonWithFields( |
|
||||
@RequestParam("file") MultipartFile file, |
|
||||
@RequestParam("fields") List<String> fields) { |
|
||||
|
@GetMapping("/document/stats") |
||||
|
public ResponseEntity<Map<String, Object>> getStats() { |
||||
try { |
try { |
||||
List<Document> documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0])); |
|
||||
|
|
||||
// 拆分文档 |
|
||||
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
|
||||
|
Map<String, Object> stats = documentService.getStats(); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"data", stats |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "查询统计失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 添加元数据 |
|
||||
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
|
||||
|
// ==================== 分类管理 ==================== |
||||
|
|
||||
// 转成向量并存入数据库 |
|
||||
pgVectorVectorStore.add(enrichedDocuments); |
|
||||
|
/** |
||||
|
* 获取分类树 |
||||
|
*/ |
||||
|
@GetMapping("/category/tree") |
||||
|
public ResponseEntity<Map<String, Object>> getCategoryTree() { |
||||
|
try { |
||||
|
List<CategoryNode> tree = documentService.getCategoryTree(); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"data", tree |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "获取分类树失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取分类列表 |
||||
|
*/ |
||||
|
@GetMapping("/category/list") |
||||
|
public ResponseEntity<Map<String, Object>> listCategories() { |
||||
|
try { |
||||
|
List<KnowledgeCategory> list = documentService.listCategories(); |
||||
return ResponseEntity.ok(Map.of( |
return ResponseEntity.ok(Map.of( |
||||
"success", true, |
|
||||
"message", "JSON文件(按字段)上传并向量化成功", |
|
||||
"documentCount", enrichedDocuments.size(), |
|
||||
"extractedFields", fields |
|
||||
|
"success", true, |
||||
|
"data", list |
||||
)); |
)); |
||||
} catch (Exception e) { |
} catch (Exception e) { |
||||
return ResponseEntity.status(500).body(Map.of( |
return ResponseEntity.status(500).body(Map.of( |
||||
"success", false, |
|
||||
"message", "上传失败:" + e.getMessage() |
|
||||
|
"success", false, |
||||
|
"message", "获取分类列表失败:" + e.getMessage() |
||||
)); |
)); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/** |
/** |
||||
* 上传 JSON 文件(按指针拆分) |
|
||||
* 用于拆分数组元素,常用来分段成多文档 |
|
||||
|
* 创建分类 |
||||
*/ |
*/ |
||||
@PostMapping("/upload/json/pointer") |
|
||||
public ResponseEntity<Map<String, Object>> uploadJsonWithPointer( |
|
||||
@RequestParam("file") MultipartFile file, |
|
||||
@RequestParam("pointer") String pointer) { |
|
||||
|
@PostMapping("/category") |
||||
|
public ResponseEntity<Map<String, Object>> createCategory(@RequestBody Map<String, Object> body) { |
||||
try { |
try { |
||||
List<Document> documents = jsonDocumentLoader.loadJsonByPointer(file, pointer); |
|
||||
|
String name = (String) body.get("name"); |
||||
|
String description = (String) body.get("description"); |
||||
|
Long parentId = body.get("parentId") != null ? ((Number) body.get("parentId")).longValue() : null; |
||||
|
Integer sortOrder = body.get("sortOrder") != null ? ((Number) body.get("sortOrder")).intValue() : null; |
||||
|
|
||||
// 拆分文档 |
|
||||
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
|
||||
|
KnowledgeCategory category = documentService.createCategory(name, description, parentId, sortOrder); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "分类创建成功", |
||||
|
"data", category |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "创建分类失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 添加元数据 |
|
||||
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
|
||||
|
/** |
||||
|
* 更新分类 |
||||
|
*/ |
||||
|
@PutMapping("/category/{id}") |
||||
|
public ResponseEntity<Map<String, Object>> updateCategory( |
||||
|
@PathVariable Long id, |
||||
|
@RequestBody Map<String, Object> body) { |
||||
|
try { |
||||
|
String name = (String) body.get("name"); |
||||
|
String description = (String) body.get("description"); |
||||
|
Integer sortOrder = body.get("sortOrder") != null ? ((Number) body.get("sortOrder")).intValue() : null; |
||||
|
|
||||
// 转成向量并存入数据库 |
|
||||
pgVectorVectorStore.add(enrichedDocuments); |
|
||||
|
documentService.updateCategory(id, name, description, sortOrder); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "分类更新成功" |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "更新分类失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除分类 |
||||
|
*/ |
||||
|
@DeleteMapping("/category/{id}") |
||||
|
public ResponseEntity<Map<String, Object>> deleteCategory(@PathVariable Long id) { |
||||
|
try { |
||||
|
documentService.deleteCategory(id); |
||||
return ResponseEntity.ok(Map.of( |
return ResponseEntity.ok(Map.of( |
||||
"success", true, |
|
||||
"message", "JSON文件(按指针)上传并向量化成功", |
|
||||
"documentCount", enrichedDocuments.size(), |
|
||||
"pointer", pointer |
|
||||
|
"success", true, |
||||
|
"message", "分类删除成功" |
||||
)); |
)); |
||||
} catch (Exception e) { |
} catch (Exception e) { |
||||
return ResponseEntity.status(500).body(Map.of( |
return ResponseEntity.status(500).body(Map.of( |
||||
"success", false, |
|
||||
"message", "上传失败:" + e.getMessage() |
|
||||
|
"success", false, |
||||
|
"message", "删除分类失败:" + e.getMessage() |
||||
)); |
)); |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,12 @@ |
|||||
|
package com.wok.supportbot.dao; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.wok.supportbot.entity.KnowledgeCategory; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
/** |
||||
|
* 知识库分类 Mapper |
||||
|
*/ |
||||
|
@Mapper |
||||
|
public interface KnowledgeCategoryMapper extends BaseMapper<KnowledgeCategory> { |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
package com.wok.supportbot.dao; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.wok.supportbot.entity.KnowledgeDocument; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
/** |
||||
|
* 知识文档 Mapper |
||||
|
*/ |
||||
|
@Mapper |
||||
|
public interface KnowledgeDocumentMapper extends BaseMapper<KnowledgeDocument> { |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
package com.wok.supportbot.entity; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.io.Serial; |
||||
|
import java.io.Serializable; |
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 分类树节点 - 用于返回树形结构 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@AllArgsConstructor |
||||
|
@NoArgsConstructor |
||||
|
public class CategoryNode implements Serializable { |
||||
|
|
||||
|
@Serial |
||||
|
private static final long serialVersionUID = 1L; |
||||
|
|
||||
|
/** |
||||
|
* 分类ID |
||||
|
*/ |
||||
|
private Long id; |
||||
|
|
||||
|
/** |
||||
|
* 分类名称 |
||||
|
*/ |
||||
|
private String name; |
||||
|
|
||||
|
/** |
||||
|
* 分类描述 |
||||
|
*/ |
||||
|
private String description; |
||||
|
|
||||
|
/** |
||||
|
* 父分类ID |
||||
|
*/ |
||||
|
private Long parentId; |
||||
|
|
||||
|
/** |
||||
|
* 排序权重 |
||||
|
*/ |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
/** |
||||
|
* 关联文档数量 |
||||
|
*/ |
||||
|
private Integer documentCount; |
||||
|
|
||||
|
/** |
||||
|
* 子分类列表 |
||||
|
*/ |
||||
|
private List<CategoryNode> children; |
||||
|
} |
||||
@ -0,0 +1,72 @@ |
|||||
|
package com.wok.supportbot.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.*; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.io.Serial; |
||||
|
import java.io.Serializable; |
||||
|
import java.util.Date; |
||||
|
|
||||
|
/** |
||||
|
* 知识库分类表 - 支持树形结构的知识库分类 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@AllArgsConstructor |
||||
|
@NoArgsConstructor |
||||
|
@TableName("knowledge_category") |
||||
|
public class KnowledgeCategory implements Serializable { |
||||
|
|
||||
|
@Serial |
||||
|
@TableField(exist = false) |
||||
|
private static final long serialVersionUID = 1L; |
||||
|
|
||||
|
@TableId(value = "id", type = IdType.ASSIGN_ID) |
||||
|
private Long id; |
||||
|
|
||||
|
/** |
||||
|
* 分类名称 |
||||
|
*/ |
||||
|
@TableField("name") |
||||
|
private String name; |
||||
|
|
||||
|
/** |
||||
|
* 分类描述 |
||||
|
*/ |
||||
|
@TableField("description") |
||||
|
private String description; |
||||
|
|
||||
|
/** |
||||
|
* 父分类ID - 0表示顶级分类 |
||||
|
*/ |
||||
|
@TableField("parent_id") |
||||
|
private Long parentId; |
||||
|
|
||||
|
/** |
||||
|
* 排序权重 - 数值越大越靠前 |
||||
|
*/ |
||||
|
@TableField("sort_order") |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
/** |
||||
|
* 关联文档数量 - 冗余字段,定期更新 |
||||
|
*/ |
||||
|
@TableField("document_count") |
||||
|
private Integer documentCount; |
||||
|
|
||||
|
/** |
||||
|
* 创建时间 |
||||
|
*/ |
||||
|
@TableField(value = "create_time", fill = FieldFill.INSERT) |
||||
|
private Date createTime; |
||||
|
|
||||
|
/** |
||||
|
* 删除标志 - false:未删除, true:已删除(逻辑删除) |
||||
|
*/ |
||||
|
@TableField("is_delete") |
||||
|
@TableLogic |
||||
|
private boolean isDelete; |
||||
|
} |
||||
@ -0,0 +1,110 @@ |
|||||
|
package com.wok.supportbot.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.*; |
||||
|
import com.wok.supportbot.handler.PostgresJsonTypeHandler; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.io.Serial; |
||||
|
import java.io.Serializable; |
||||
|
import java.util.Date; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
/** |
||||
|
* 知识文档表 - 记录上传的文档元信息 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@AllArgsConstructor |
||||
|
@NoArgsConstructor |
||||
|
@TableName(value = "knowledge_document", autoResultMap = true) |
||||
|
public class KnowledgeDocument implements Serializable { |
||||
|
|
||||
|
@Serial |
||||
|
@TableField(exist = false) |
||||
|
private static final long serialVersionUID = 1L; |
||||
|
|
||||
|
@TableId(value = "id", type = IdType.ASSIGN_ID) |
||||
|
private Long id; |
||||
|
|
||||
|
/** |
||||
|
* 文档标题 |
||||
|
*/ |
||||
|
@TableField("title") |
||||
|
private String title; |
||||
|
|
||||
|
/** |
||||
|
* 原始文件名 |
||||
|
*/ |
||||
|
@TableField("source_name") |
||||
|
private String sourceName; |
||||
|
|
||||
|
/** |
||||
|
* 文件类型 - pdf/md/json/txt/word/excel 等 |
||||
|
*/ |
||||
|
@TableField("file_type") |
||||
|
private String fileType; |
||||
|
|
||||
|
/** |
||||
|
* 文件大小(字节) |
||||
|
*/ |
||||
|
@TableField("file_size") |
||||
|
private Long fileSize; |
||||
|
|
||||
|
/** |
||||
|
* 原文内容(截断预览) |
||||
|
*/ |
||||
|
@TableField("content") |
||||
|
private String content; |
||||
|
|
||||
|
/** |
||||
|
* 所属分类ID - 0表示未分类 |
||||
|
*/ |
||||
|
@TableField("category_id") |
||||
|
private Long categoryId; |
||||
|
|
||||
|
/** |
||||
|
* 标签列表(JSON数组) |
||||
|
*/ |
||||
|
@TableField(value = "tags", typeHandler = PostgresJsonTypeHandler.class) |
||||
|
private Map<String, Object> tags; |
||||
|
|
||||
|
/** |
||||
|
* 分块数量 |
||||
|
*/ |
||||
|
@TableField("chunk_count") |
||||
|
private Integer chunkCount; |
||||
|
|
||||
|
/** |
||||
|
* 处理状态 - PROCESSING/READY/FAILED |
||||
|
*/ |
||||
|
@TableField("status") |
||||
|
private String status; |
||||
|
|
||||
|
/** |
||||
|
* 处理失败时的错误信息 |
||||
|
*/ |
||||
|
@TableField("error_message") |
||||
|
private String errorMessage; |
||||
|
|
||||
|
/** |
||||
|
* 创建时间 |
||||
|
*/ |
||||
|
@TableField(value = "create_time", fill = FieldFill.INSERT) |
||||
|
private Date createTime; |
||||
|
|
||||
|
/** |
||||
|
* 更新时间 |
||||
|
*/ |
||||
|
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) |
||||
|
private Date updateTime; |
||||
|
|
||||
|
/** |
||||
|
* 删除标志 - false:未删除, true:已删除(逻辑删除) |
||||
|
*/ |
||||
|
@TableField("is_delete") |
||||
|
@TableLogic |
||||
|
private boolean isDelete; |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
package com.wok.supportbot.entity; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.io.Serial; |
||||
|
import java.io.Serializable; |
||||
|
|
||||
|
/** |
||||
|
* 语义搜索结果 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@AllArgsConstructor |
||||
|
@NoArgsConstructor |
||||
|
public class SearchResult implements Serializable { |
||||
|
|
||||
|
@Serial |
||||
|
private static final long serialVersionUID = 1L; |
||||
|
|
||||
|
/** |
||||
|
* 向量记录ID |
||||
|
*/ |
||||
|
private String id; |
||||
|
|
||||
|
/** |
||||
|
* 分块内容 |
||||
|
*/ |
||||
|
private String content; |
||||
|
|
||||
|
/** |
||||
|
* 相似度得分 |
||||
|
*/ |
||||
|
private Double score; |
||||
|
|
||||
|
/** |
||||
|
* 原始文件名 |
||||
|
*/ |
||||
|
private String sourceName; |
||||
|
|
||||
|
/** |
||||
|
* 文档标题 |
||||
|
*/ |
||||
|
private String title; |
||||
|
|
||||
|
/** |
||||
|
* 分块序号 |
||||
|
*/ |
||||
|
private Integer chunkIndex; |
||||
|
|
||||
|
/** |
||||
|
* 关联的文档ID |
||||
|
*/ |
||||
|
private String documentId; |
||||
|
|
||||
|
/** |
||||
|
* 原始元数据 |
||||
|
*/ |
||||
|
private Object metadata; |
||||
|
} |
||||
@ -0,0 +1,630 @@ |
|||||
|
package com.wok.supportbot.service; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
||||
|
import com.wok.supportbot.dao.KnowledgeCategoryMapper; |
||||
|
import com.wok.supportbot.dao.KnowledgeDocumentMapper; |
||||
|
import com.wok.supportbot.document.extract.JsonDocumentLoader; |
||||
|
import com.wok.supportbot.document.extract.MarkdownDocumentLoader; |
||||
|
import com.wok.supportbot.document.extract.SimpleStringDocumentReader; |
||||
|
import com.wok.supportbot.document.extract.TikaDocumentReader; |
||||
|
import com.wok.supportbot.document.transform.MyKeywordEnricher; |
||||
|
import com.wok.supportbot.document.transform.MyTokenTextSplitter; |
||||
|
import com.wok.supportbot.entity.CategoryNode; |
||||
|
import com.wok.supportbot.entity.KnowledgeCategory; |
||||
|
import com.wok.supportbot.entity.KnowledgeDocument; |
||||
|
import com.wok.supportbot.entity.SearchResult; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.ai.document.Document; |
||||
|
import org.springframework.ai.vectorstore.SearchRequest; |
||||
|
import org.springframework.ai.vectorstore.VectorStore; |
||||
|
import org.springframework.ai.vectorstore.filter.Filter; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import org.springframework.web.multipart.MultipartFile; |
||||
|
|
||||
|
import java.util.*; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 知识库文档管理服务 |
||||
|
* 统一管理文档的上传、删除、搜索、统计、分类等操作 |
||||
|
*/ |
||||
|
@Service |
||||
|
@Slf4j |
||||
|
public class DocumentService { |
||||
|
|
||||
|
@Autowired |
||||
|
private KnowledgeDocumentMapper documentMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private KnowledgeCategoryMapper categoryMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@Autowired |
||||
|
private VectorStore pgVectorVectorStore; |
||||
|
|
||||
|
@Autowired |
||||
|
private MyTokenTextSplitter myTokenTextSplitter; |
||||
|
|
||||
|
@Autowired |
||||
|
private MyKeywordEnricher myKeywordEnricher; |
||||
|
|
||||
|
@Autowired |
||||
|
private TikaDocumentReader tikaDocumentReader; |
||||
|
|
||||
|
@Autowired |
||||
|
private SimpleStringDocumentReader simpleStringDocumentReader; |
||||
|
|
||||
|
@Autowired |
||||
|
private MarkdownDocumentLoader markdownDocumentLoader; |
||||
|
|
||||
|
@Autowired |
||||
|
private JsonDocumentLoader jsonDocumentLoader; |
||||
|
|
||||
|
// ==================== 文档上传 ==================== |
||||
|
|
||||
|
/** |
||||
|
* 统一文档上传流程:创建记录 -> 分块 -> 关键词 -> 向量化 -> 更新状态 |
||||
|
* |
||||
|
* @param documents 解析后的文档列表 |
||||
|
* @param title 文档标题 |
||||
|
* @param sourceName 源文件名 |
||||
|
* @param fileType 文件类型 |
||||
|
* @param fileSize 文件大小 |
||||
|
* @param content 原文内容(截断预览) |
||||
|
* @param categoryId 分类ID |
||||
|
* @param tags 标签列表 |
||||
|
* @return 创建完成的文档记录 |
||||
|
*/ |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName, |
||||
|
String fileType, Long fileSize, String content, |
||||
|
Long categoryId, List<String> tags) { |
||||
|
// 1. 创建文档记录(状态 PROCESSING) |
||||
|
KnowledgeDocument docRecord = KnowledgeDocument.builder() |
||||
|
.title(title != null ? title : sourceName) |
||||
|
.sourceName(sourceName) |
||||
|
.fileType(fileType) |
||||
|
.fileSize(fileSize != null ? fileSize : 0L) |
||||
|
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) |
||||
|
.categoryId(categoryId != null ? categoryId : 0L) |
||||
|
.tags(tags != null ? Map.of("tags", tags) : null) |
||||
|
.status("PROCESSING") |
||||
|
.chunkCount(0) |
||||
|
.build(); |
||||
|
documentMapper.insert(docRecord); |
||||
|
|
||||
|
try { |
||||
|
// 2. 分块处理 |
||||
|
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
||||
|
|
||||
|
// 3. 为每个分块设置 documentId 等元数据 |
||||
|
for (int i = 0; i < splitDocuments.size(); i++) { |
||||
|
Document doc = splitDocuments.get(i); |
||||
|
Map<String, Object> meta = new HashMap<>(doc.getMetadata()); |
||||
|
meta.put("documentId", String.valueOf(docRecord.getId())); |
||||
|
meta.put("chunkIndex", i); |
||||
|
meta.put("sourceName", sourceName); |
||||
|
meta.put("title", title != null ? title : sourceName); |
||||
|
if (categoryId != null) { |
||||
|
meta.put("categoryId", String.valueOf(categoryId)); |
||||
|
} |
||||
|
if (tags != null && !tags.isEmpty()) { |
||||
|
meta.put("tags", tags); |
||||
|
} |
||||
|
splitDocuments.set(i, new Document(doc.getId(), doc.getText(), meta)); |
||||
|
} |
||||
|
|
||||
|
// 4. 关键词提取 |
||||
|
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
||||
|
|
||||
|
// 5. 向量化存储 |
||||
|
pgVectorVectorStore.add(enrichedDocuments); |
||||
|
|
||||
|
// 6. 更新文档状态为 READY |
||||
|
docRecord.setStatus("READY"); |
||||
|
docRecord.setChunkCount(enrichedDocuments.size()); |
||||
|
documentMapper.updateById(docRecord); |
||||
|
|
||||
|
log.info("文档上传成功: id={}, title={}, chunks={}", docRecord.getId(), docRecord.getTitle(), enrichedDocuments.size()); |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
// 标记为失败 |
||||
|
docRecord.setStatus("FAILED"); |
||||
|
docRecord.setErrorMessage(e.getMessage()); |
||||
|
documentMapper.updateById(docRecord); |
||||
|
log.error("文档上传失败: id={}, title={}", docRecord.getId(), docRecord.getTitle(), e); |
||||
|
throw new RuntimeException("文档处理失败: " + e.getMessage(), e); |
||||
|
} |
||||
|
|
||||
|
return docRecord; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析文件并上传 |
||||
|
*/ |
||||
|
public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List<String> tags) { |
||||
|
List<Document> documents = tikaDocumentReader.read(file); |
||||
|
String fileType = getFileExtension(file.getOriginalFilename()); |
||||
|
return uploadDocument(documents, |
||||
|
title != null ? title : file.getOriginalFilename(), |
||||
|
file.getOriginalFilename(), |
||||
|
fileType, |
||||
|
file.getSize(), |
||||
|
documents.get(0).getText(), |
||||
|
categoryId, |
||||
|
tags); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析字符串并上传 |
||||
|
*/ |
||||
|
public KnowledgeDocument uploadString(String content, String title, Long categoryId, List<String> tags) { |
||||
|
List<Document> documents = simpleStringDocumentReader.read(content); |
||||
|
return uploadDocument(documents, title, title, "txt", |
||||
|
(long) content.length(), content, categoryId, tags); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析 Markdown 文件并上传 |
||||
|
*/ |
||||
|
public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List<String> tags) { |
||||
|
List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(file); |
||||
|
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); |
||||
|
return uploadDocument(documents, |
||||
|
title != null ? title : file.getOriginalFilename(), |
||||
|
file.getOriginalFilename(), |
||||
|
"md", |
||||
|
file.getSize(), |
||||
|
content, |
||||
|
categoryId, |
||||
|
tags); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析 JSON 文件(基本方式)并上传 |
||||
|
*/ |
||||
|
public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List<String> tags) { |
||||
|
List<Document> documents = jsonDocumentLoader.loadBasicJson(file); |
||||
|
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); |
||||
|
return uploadDocument(documents, |
||||
|
title != null ? title : file.getOriginalFilename(), |
||||
|
file.getOriginalFilename(), |
||||
|
"json", |
||||
|
file.getSize(), |
||||
|
content, |
||||
|
categoryId, |
||||
|
tags); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析 JSON 文件(按字段)并上传 |
||||
|
*/ |
||||
|
public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title, Long categoryId, List<String> tags) { |
||||
|
List<Document> documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0])); |
||||
|
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); |
||||
|
return uploadDocument(documents, |
||||
|
title != null ? title : file.getOriginalFilename(), |
||||
|
file.getOriginalFilename(), |
||||
|
"json", |
||||
|
file.getSize(), |
||||
|
content, |
||||
|
categoryId, |
||||
|
tags); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析 JSON 文件(按指针)并上传 |
||||
|
*/ |
||||
|
public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, Long categoryId, List<String> tags) { |
||||
|
List<Document> documents = jsonDocumentLoader.loadJsonByPointer(file, pointer); |
||||
|
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); |
||||
|
return uploadDocument(documents, |
||||
|
title != null ? title : file.getOriginalFilename(), |
||||
|
file.getOriginalFilename(), |
||||
|
"json", |
||||
|
file.getSize(), |
||||
|
content, |
||||
|
categoryId, |
||||
|
tags); |
||||
|
} |
||||
|
|
||||
|
// ==================== 文档管理 ==================== |
||||
|
|
||||
|
/** |
||||
|
* 分页查询文档列表(手动分页) |
||||
|
*/ |
||||
|
public Map<String, Object> listDocuments(int page, int size, Long categoryId, String status) { |
||||
|
// 构建基础条件(用于 count 和 list) |
||||
|
QueryWrapper<KnowledgeDocument> countWrapper = new QueryWrapper<>(); |
||||
|
if (categoryId != null && categoryId > 0) { |
||||
|
countWrapper.eq("category_id", categoryId); |
||||
|
} |
||||
|
if (status != null && !status.isEmpty()) { |
||||
|
countWrapper.eq("status", status); |
||||
|
} |
||||
|
|
||||
|
// 先查询总数(不加 ORDER BY) |
||||
|
Long total = documentMapper.selectCount(countWrapper); |
||||
|
|
||||
|
// 构建列表查询条件 |
||||
|
QueryWrapper<KnowledgeDocument> listWrapper = new QueryWrapper<>(); |
||||
|
if (categoryId != null && categoryId > 0) { |
||||
|
listWrapper.eq("category_id", categoryId); |
||||
|
} |
||||
|
if (status != null && !status.isEmpty()) { |
||||
|
listWrapper.eq("status", status); |
||||
|
} |
||||
|
listWrapper.orderByDesc("create_time"); |
||||
|
listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); |
||||
|
List<KnowledgeDocument> records = documentMapper.selectList(listWrapper); |
||||
|
|
||||
|
Map<String, Object> result = new HashMap<>(); |
||||
|
result.put("records", records); |
||||
|
result.put("total", total); |
||||
|
result.put("page", page); |
||||
|
result.put("size", size); |
||||
|
result.put("pages", (total + size - 1) / size); |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文档详情 |
||||
|
*/ |
||||
|
public KnowledgeDocument getDocumentDetail(Long id) { |
||||
|
return documentMapper.selectById(id); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文档的所有分块 |
||||
|
*/ |
||||
|
public List<Map<String, Object>> getDocumentChunks(Long id) { |
||||
|
String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " + |
||||
|
"WHERE metadata->>'documentId' = ? ORDER BY (metadata->>'chunkIndex')::int"; |
||||
|
return jdbcTemplate.queryForList(sql, String.valueOf(id)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除文档(逻辑删除 + 级联删除向量) |
||||
|
*/ |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public int deleteDocument(Long id) { |
||||
|
KnowledgeDocument doc = documentMapper.selectById(id); |
||||
|
if (doc == null) { |
||||
|
throw new RuntimeException("文档不存在"); |
||||
|
} |
||||
|
// 删除关联的向量 |
||||
|
int vectorCount = deleteVectorsByDocumentId(String.valueOf(id)); |
||||
|
// 逻辑删除文档记录 |
||||
|
documentMapper.deleteById(id); |
||||
|
log.info("删除文档: id={}, title={}, 删除向量数={}", id, doc.getTitle(), vectorCount); |
||||
|
return vectorCount; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 重新处理文档(重新分块 + 向量化) |
||||
|
*/ |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public KnowledgeDocument reprocessDocument(Long id) { |
||||
|
KnowledgeDocument doc = documentMapper.selectById(id); |
||||
|
if (doc == null) { |
||||
|
throw new RuntimeException("文档不存在"); |
||||
|
} |
||||
|
if (doc.getContent() == null || doc.getContent().isEmpty()) { |
||||
|
throw new RuntimeException("文档无内容,无法重新处理"); |
||||
|
} |
||||
|
|
||||
|
// 删除旧向量 |
||||
|
deleteVectorsByDocumentId(String.valueOf(id)); |
||||
|
|
||||
|
// 重新解析并处理 |
||||
|
List<Document> documents = simpleStringDocumentReader.read(doc.getContent()); |
||||
|
|
||||
|
doc.setStatus("PROCESSING"); |
||||
|
doc.setChunkCount(0); |
||||
|
doc.setErrorMessage(null); |
||||
|
documentMapper.updateById(doc); |
||||
|
|
||||
|
try { |
||||
|
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents); |
||||
|
|
||||
|
for (int i = 0; i < splitDocuments.size(); i++) { |
||||
|
Document d = splitDocuments.get(i); |
||||
|
Map<String, Object> meta = new HashMap<>(d.getMetadata()); |
||||
|
meta.put("documentId", String.valueOf(doc.getId())); |
||||
|
meta.put("chunkIndex", i); |
||||
|
meta.put("sourceName", doc.getSourceName()); |
||||
|
meta.put("title", doc.getTitle()); |
||||
|
if (doc.getCategoryId() != null && doc.getCategoryId() > 0) { |
||||
|
meta.put("categoryId", String.valueOf(doc.getCategoryId())); |
||||
|
} |
||||
|
if (doc.getTags() != null && doc.getTags().containsKey("tags")) { |
||||
|
meta.put("tags", doc.getTags().get("tags")); |
||||
|
} |
||||
|
splitDocuments.set(i, new Document(d.getId(), d.getText(), meta)); |
||||
|
} |
||||
|
|
||||
|
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); |
||||
|
pgVectorVectorStore.add(enrichedDocuments); |
||||
|
|
||||
|
doc.setStatus("READY"); |
||||
|
doc.setChunkCount(enrichedDocuments.size()); |
||||
|
documentMapper.updateById(doc); |
||||
|
|
||||
|
log.info("重新处理文档成功: id={}, title={}, chunks={}", doc.getId(), doc.getTitle(), enrichedDocuments.size()); |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
doc.setStatus("FAILED"); |
||||
|
doc.setErrorMessage(e.getMessage()); |
||||
|
documentMapper.updateById(doc); |
||||
|
log.error("重新处理文档失败: id={}, title={}", doc.getId(), doc.getTitle(), e); |
||||
|
throw new RuntimeException("重新处理失败: " + e.getMessage(), e); |
||||
|
} |
||||
|
|
||||
|
return doc; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新文档元信息 |
||||
|
*/ |
||||
|
public void updateDocumentMetadata(Long id, String title, Long categoryId, List<String> tags) { |
||||
|
KnowledgeDocument doc = documentMapper.selectById(id); |
||||
|
if (doc == null) { |
||||
|
throw new RuntimeException("文档不存在"); |
||||
|
} |
||||
|
if (title != null && !title.isEmpty()) { |
||||
|
doc.setTitle(title); |
||||
|
} |
||||
|
if (categoryId != null) { |
||||
|
doc.setCategoryId(categoryId); |
||||
|
} |
||||
|
if (tags != null) { |
||||
|
doc.setTags(Map.of("tags", tags)); |
||||
|
} |
||||
|
documentMapper.updateById(doc); |
||||
|
|
||||
|
// 同步更新 vector_store 中对应的 metadata |
||||
|
// 注意:Spring AI 当前没有直接更新 metadata 的 API |
||||
|
// 这里我们先更新文档记录,metadata 的同步留到后续优化 |
||||
|
log.info("更新文档元信息: id={}, title={}", id, doc.getTitle()); |
||||
|
} |
||||
|
|
||||
|
// ==================== 语义搜索 ==================== |
||||
|
|
||||
|
/** |
||||
|
* 语义搜索 |
||||
|
*/ |
||||
|
public List<SearchResult> searchDocuments(String query, int topK, double similarityThreshold, Long categoryId) { |
||||
|
SearchRequest.Builder searchBuilder = SearchRequest.builder() |
||||
|
.query(query) |
||||
|
.topK(topK) |
||||
|
.similarityThreshold(similarityThreshold); |
||||
|
|
||||
|
// 如果指定了分类,添加过滤条件(当前 Spring AI 1.0.0-M6 的 filter 支持有限) |
||||
|
// 这里先不做分类过滤,后续升级 Spring AI 版本后再完善 |
||||
|
|
||||
|
List<Document> results = pgVectorVectorStore.similaritySearch(searchBuilder.build()); |
||||
|
|
||||
|
List<SearchResult> searchResults = new ArrayList<>(); |
||||
|
for (Document doc : results) { |
||||
|
Map<String, Object> metadata = doc.getMetadata(); |
||||
|
SearchResult result = SearchResult.builder() |
||||
|
.id(doc.getId()) |
||||
|
.content(doc.getText()) |
||||
|
.score(metadata.containsKey("distance") ? ((Number) metadata.get("distance")).doubleValue() : null) |
||||
|
.sourceName(getStringFromMetadata(metadata, "sourceName")) |
||||
|
.title(getStringFromMetadata(metadata, "title")) |
||||
|
.chunkIndex(getIntegerFromMetadata(metadata, "chunkIndex")) |
||||
|
.documentId(getStringFromMetadata(metadata, "documentId")) |
||||
|
.metadata(metadata) |
||||
|
.build(); |
||||
|
searchResults.add(result); |
||||
|
} |
||||
|
|
||||
|
return searchResults; |
||||
|
} |
||||
|
|
||||
|
// ==================== 统计 ==================== |
||||
|
|
||||
|
/** |
||||
|
* 获取知识库统计信息 |
||||
|
*/ |
||||
|
public Map<String, Object> getStats() { |
||||
|
// 文档统计 |
||||
|
Long totalDocuments = documentMapper.selectCount(null); |
||||
|
|
||||
|
// 按文件类型统计 |
||||
|
String typeSql = "SELECT file_type, COUNT(*) as count FROM knowledge_document WHERE is_delete = false GROUP BY file_type"; |
||||
|
List<Map<String, Object>> typeStats = jdbcTemplate.queryForList(typeSql); |
||||
|
Map<String, Long> byFileType = typeStats.stream() |
||||
|
.collect(Collectors.toMap( |
||||
|
r -> (String) r.get("file_type"), |
||||
|
r -> ((Number) r.get("count")).longValue() |
||||
|
)); |
||||
|
|
||||
|
// 按分类统计 |
||||
|
String catSql = "SELECT c.name, COUNT(d.id) as count FROM knowledge_document d " + |
||||
|
"LEFT JOIN knowledge_category c ON d.category_id = c.id " + |
||||
|
"WHERE d.is_delete = false GROUP BY c.name"; |
||||
|
List<Map<String, Object>> catStats; |
||||
|
try { |
||||
|
catStats = jdbcTemplate.queryForList(catSql); |
||||
|
} catch (Exception e) { |
||||
|
catStats = new ArrayList<>(); |
||||
|
} |
||||
|
|
||||
|
// 向量总数 |
||||
|
String vectorSql = "SELECT COUNT(*) FROM vector_store"; |
||||
|
Long totalVectors; |
||||
|
try { |
||||
|
totalVectors = jdbcTemplate.queryForObject(vectorSql, Long.class); |
||||
|
} catch (Exception e) { |
||||
|
totalVectors = 0L; |
||||
|
} |
||||
|
|
||||
|
// 最近上传时间 |
||||
|
String lastUploadSql = "SELECT MAX(create_time) FROM knowledge_document WHERE is_delete = false"; |
||||
|
Date lastUploadTime = jdbcTemplate.queryForObject(lastUploadSql, Date.class); |
||||
|
|
||||
|
Map<String, Object> stats = new LinkedHashMap<>(); |
||||
|
stats.put("totalDocuments", totalDocuments); |
||||
|
stats.put("totalVectors", totalVectors); |
||||
|
stats.put("lastUploadTime", lastUploadTime); |
||||
|
stats.put("byFileType", byFileType); |
||||
|
stats.put("byCategory", catStats); |
||||
|
|
||||
|
return stats; |
||||
|
} |
||||
|
|
||||
|
// ==================== 分类管理 ==================== |
||||
|
|
||||
|
/** |
||||
|
* 获取分类树 |
||||
|
*/ |
||||
|
public List<CategoryNode> getCategoryTree() { |
||||
|
List<KnowledgeCategory> categories = categoryMapper.selectList( |
||||
|
new QueryWrapper<KnowledgeCategory>().orderByAsc("sort_order")); |
||||
|
|
||||
|
Map<Long, CategoryNode> nodeMap = new LinkedHashMap<>(); |
||||
|
List<CategoryNode> rootNodes = new ArrayList<>(); |
||||
|
|
||||
|
for (KnowledgeCategory cat : categories) { |
||||
|
CategoryNode node = CategoryNode.builder() |
||||
|
.id(cat.getId()) |
||||
|
.name(cat.getName()) |
||||
|
.description(cat.getDescription()) |
||||
|
.parentId(cat.getParentId()) |
||||
|
.sortOrder(cat.getSortOrder()) |
||||
|
.documentCount(cat.getDocumentCount()) |
||||
|
.children(new ArrayList<>()) |
||||
|
.build(); |
||||
|
nodeMap.put(cat.getId(), node); |
||||
|
} |
||||
|
|
||||
|
for (CategoryNode node : nodeMap.values()) { |
||||
|
if (node.getParentId() == null || node.getParentId() == 0) { |
||||
|
rootNodes.add(node); |
||||
|
} else { |
||||
|
CategoryNode parent = nodeMap.get(node.getParentId()); |
||||
|
if (parent != null) { |
||||
|
parent.getChildren().add(node); |
||||
|
} else { |
||||
|
rootNodes.add(node); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return rootNodes; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取分类列表 |
||||
|
*/ |
||||
|
public List<KnowledgeCategory> listCategories() { |
||||
|
return categoryMapper.selectList( |
||||
|
new QueryWrapper<KnowledgeCategory>().orderByAsc("sort_order")); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建分类 |
||||
|
*/ |
||||
|
public KnowledgeCategory createCategory(String name, String description, Long parentId, Integer sortOrder) { |
||||
|
KnowledgeCategory category = KnowledgeCategory.builder() |
||||
|
.name(name) |
||||
|
.description(description) |
||||
|
.parentId(parentId != null ? parentId : 0L) |
||||
|
.sortOrder(sortOrder != null ? sortOrder : 0) |
||||
|
.documentCount(0) |
||||
|
.build(); |
||||
|
categoryMapper.insert(category); |
||||
|
return category; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新分类 |
||||
|
*/ |
||||
|
public void updateCategory(Long id, String name, String description, Integer sortOrder) { |
||||
|
KnowledgeCategory category = categoryMapper.selectById(id); |
||||
|
if (category == null) { |
||||
|
throw new RuntimeException("分类不存在"); |
||||
|
} |
||||
|
if (name != null && !name.isEmpty()) { |
||||
|
category.setName(name); |
||||
|
} |
||||
|
if (description != null) { |
||||
|
category.setDescription(description); |
||||
|
} |
||||
|
if (sortOrder != null) { |
||||
|
category.setSortOrder(sortOrder); |
||||
|
} |
||||
|
categoryMapper.updateById(category); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除分类(不删除文档,仅清空关联) |
||||
|
*/ |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void deleteCategory(Long id) { |
||||
|
// 将关联的文档 category_id 设为 0 |
||||
|
KnowledgeDocument updateDoc = new KnowledgeDocument(); |
||||
|
updateDoc.setCategoryId(0L); |
||||
|
documentMapper.update(updateDoc, new QueryWrapper<KnowledgeDocument>().eq("category_id", id)); |
||||
|
|
||||
|
// 逻辑删除分类 |
||||
|
categoryMapper.deleteById(id); |
||||
|
} |
||||
|
|
||||
|
// ==================== 内部方法 ==================== |
||||
|
|
||||
|
/** |
||||
|
* 根据文档ID删除 vector_store 中关联的所有向量 |
||||
|
*/ |
||||
|
private int deleteVectorsByDocumentId(String documentId) { |
||||
|
String sql = "SELECT id::text FROM vector_store WHERE metadata->>'documentId' = ?"; |
||||
|
List<String> ids = jdbcTemplate.queryForList(sql, String.class, documentId); |
||||
|
|
||||
|
if (!ids.isEmpty()) { |
||||
|
pgVectorVectorStore.delete(ids); |
||||
|
log.debug("删除向量: documentId={}, count={}", documentId, ids.size()); |
||||
|
} |
||||
|
return ids.size(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文件扩展名 |
||||
|
*/ |
||||
|
private String getFileExtension(String filename) { |
||||
|
if (filename == null || !filename.contains(".")) { |
||||
|
return "unknown"; |
||||
|
} |
||||
|
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 从 metadata 中安全获取字符串值 |
||||
|
*/ |
||||
|
private String getStringFromMetadata(Map<String, Object> metadata, String key) { |
||||
|
Object value = metadata.get(key); |
||||
|
return value != null ? value.toString() : null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 从 metadata 中安全获取整数值 |
||||
|
*/ |
||||
|
private Integer getIntegerFromMetadata(Map<String, Object> metadata, String key) { |
||||
|
Object value = metadata.get(key); |
||||
|
if (value == null) return null; |
||||
|
if (value instanceof Number) { |
||||
|
return ((Number) value).intValue(); |
||||
|
} |
||||
|
try { |
||||
|
return Integer.parseInt(value.toString()); |
||||
|
} catch (NumberFormatException e) { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
-- 重新设置 chat_message 表注释 |
||||
|
COMMENT ON TABLE chat_message IS '聊天消息表(存储用户与AI助手的对话历史)'; |
||||
|
|
||||
|
COMMENT ON COLUMN chat_message.id IS '主键ID(自增长整型)'; |
||||
|
COMMENT ON COLUMN chat_message.conversation_id IS '会话ID(标识同一次对话的唯一标识符)'; |
||||
|
COMMENT ON COLUMN chat_message.message_type IS '消息类型:USER(用户消息) / ASSISTANT(AI回复) / SYSTEM(系统消息)'; |
||||
|
COMMENT ON COLUMN chat_message.content IS '消息内容(实际的对话文本)'; |
||||
|
COMMENT ON COLUMN chat_message.metadata IS '元数据(JSON格式,存储消息的额外信息)'; |
||||
|
COMMENT ON COLUMN chat_message.create_time IS '创建时间(消息创建的时间戳)'; |
||||
|
COMMENT ON COLUMN chat_message.update_time IS '更新时间(消息最后更新的时间戳)'; |
||||
|
COMMENT ON COLUMN chat_message.is_delete IS '删除标志:false-未删除 / true-已删除(逻辑删除)'; |
||||
|
|
||||
|
-- 重新设置 vector_store 表注释 |
||||
|
COMMENT ON TABLE vector_store IS '向量存储表(存储文档的向量表示用于语义搜索)'; |
||||
|
|
||||
|
COMMENT ON COLUMN vector_store.id IS '主键ID(UUID格式的唯一标识符)'; |
||||
|
COMMENT ON COLUMN vector_store.content IS '文档内容(原始的文本内容)'; |
||||
|
COMMENT ON COLUMN vector_store.metadata IS '元数据(JSON格式,存储文档来源、标题、标签等附加信息)'; |
||||
|
COMMENT ON COLUMN vector_store.embedding IS '向量嵌入(1536维向量表示,适配OpenAI embedding模型)'; |
||||
|
COMMENT ON COLUMN vector_store.create_time IS '创建时间'; |
||||
|
COMMENT ON COLUMN vector_store.update_time IS '更新时间'; |
||||
@ -0,0 +1,78 @@ |
|||||
|
-- ================================================================ |
||||
|
-- 知识库管理增强 - 数据库变更脚本 |
||||
|
-- 说明: 为知识库管理功能添加分类表和文档管理表 |
||||
|
-- ================================================================ |
||||
|
|
||||
|
-- ================================================================ |
||||
|
-- 知识库分类表 - 支持树形结构的知识库分类 |
||||
|
-- ================================================================ |
||||
|
CREATE TABLE IF NOT EXISTS knowledge_category ( |
||||
|
id BIGSERIAL PRIMARY KEY, |
||||
|
name VARCHAR(100) NOT NULL, |
||||
|
description TEXT, |
||||
|
parent_id BIGINT DEFAULT 0 NOT NULL, |
||||
|
sort_order INTEGER DEFAULT 0 NOT NULL, |
||||
|
document_count INTEGER DEFAULT 0 NOT NULL, |
||||
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
|
is_delete BOOLEAN DEFAULT FALSE NOT NULL |
||||
|
); |
||||
|
|
||||
|
-- 添加表注释 |
||||
|
COMMENT ON TABLE knowledge_category IS '知识库分类表 - 支持树形结构的知识库分类'; |
||||
|
|
||||
|
-- 添加字段注释 |
||||
|
COMMENT ON COLUMN knowledge_category.id IS '主键ID - 雪花算法'; |
||||
|
COMMENT ON COLUMN knowledge_category.name IS '分类名称'; |
||||
|
COMMENT ON COLUMN knowledge_category.description IS '分类描述'; |
||||
|
COMMENT ON COLUMN knowledge_category.parent_id IS '父分类ID - 0表示顶级分类'; |
||||
|
COMMENT ON COLUMN knowledge_category.sort_order IS '排序权重 - 数值越大越靠前'; |
||||
|
COMMENT ON COLUMN knowledge_category.document_count IS '关联文档数量 - 冗余字段,定期更新'; |
||||
|
COMMENT ON COLUMN knowledge_category.create_time IS '创建时间'; |
||||
|
COMMENT ON COLUMN knowledge_category.is_delete IS '删除标志 - false:未删除, true:已删除(逻辑删除)'; |
||||
|
|
||||
|
-- 创建索引 |
||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_category_parent ON knowledge_category (parent_id); |
||||
|
|
||||
|
-- ================================================================ |
||||
|
-- 知识文档表 - 记录上传的文档元信息 |
||||
|
-- ================================================================ |
||||
|
CREATE TABLE IF NOT EXISTS knowledge_document ( |
||||
|
id BIGSERIAL PRIMARY KEY, |
||||
|
title VARCHAR(500) NOT NULL, |
||||
|
source_name VARCHAR(500), |
||||
|
file_type VARCHAR(20) NOT NULL, |
||||
|
file_size BIGINT DEFAULT 0 NOT NULL, |
||||
|
content TEXT, |
||||
|
category_id BIGINT DEFAULT 0 NOT NULL, |
||||
|
tags JSONB DEFAULT '{}' NOT NULL, |
||||
|
chunk_count INTEGER DEFAULT 0 NOT NULL, |
||||
|
status VARCHAR(20) DEFAULT 'PROCESSING' NOT NULL, |
||||
|
error_message TEXT, |
||||
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
|
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
|
is_delete BOOLEAN DEFAULT FALSE NOT NULL |
||||
|
); |
||||
|
|
||||
|
-- 添加表注释 |
||||
|
COMMENT ON TABLE knowledge_document IS '知识文档表 - 记录上传的文档元信息'; |
||||
|
|
||||
|
-- 添加字段注释 |
||||
|
COMMENT ON COLUMN knowledge_document.id IS '主键ID - 雪花算法'; |
||||
|
COMMENT ON COLUMN knowledge_document.title IS '文档标题'; |
||||
|
COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名'; |
||||
|
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型 - pdf/md/json/txt/word/excel 等'; |
||||
|
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)'; |
||||
|
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)'; |
||||
|
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID - 0表示未分类'; |
||||
|
COMMENT ON COLUMN knowledge_document.tags IS '标签列表(JSON数组)'; |
||||
|
COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量'; |
||||
|
COMMENT ON COLUMN knowledge_document.status IS '处理状态 - PROCESSING/READY/FAILED'; |
||||
|
COMMENT ON COLUMN knowledge_document.error_message IS '处理失败时的错误信息'; |
||||
|
COMMENT ON COLUMN knowledge_document.create_time IS '创建时间'; |
||||
|
COMMENT ON COLUMN knowledge_document.update_time IS '更新时间'; |
||||
|
COMMENT ON COLUMN knowledge_document.is_delete IS '删除标志 - false:未删除, true:已删除(逻辑删除)'; |
||||
|
|
||||
|
-- 创建索引 |
||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id); |
||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status); |
||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC); |
||||
@ -0,0 +1,178 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>AI 智能客服 - 对话窗口</title> |
||||
|
<style> |
||||
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
||||
|
body { |
||||
|
font-family: -apple-system, "Microsoft YaHei", sans-serif; |
||||
|
background: #f0f2f5; |
||||
|
display: flex; justify-content: center; align-items: center; |
||||
|
height: 100vh; |
||||
|
} |
||||
|
.chat-container { |
||||
|
width: 800px; max-width: 95vw; height: 90vh; |
||||
|
background: #fff; border-radius: 12px; |
||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.1); |
||||
|
display: flex; flex-direction: column; overflow: hidden; |
||||
|
} |
||||
|
.chat-header { |
||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
color: #fff; padding: 16px 20px; |
||||
|
font-size: 18px; font-weight: 600; |
||||
|
} |
||||
|
.chat-header span { font-size: 13px; opacity: 0.8; margin-left: 8px; } |
||||
|
.chat-messages { |
||||
|
flex: 1; overflow-y: auto; padding: 20px; |
||||
|
display: flex; flex-direction: column; gap: 12px; |
||||
|
background: #fafbfc; |
||||
|
} |
||||
|
.message { |
||||
|
display: flex; gap: 10px; max-width: 80%; |
||||
|
animation: fadeIn 0.3s ease; |
||||
|
} |
||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } |
||||
|
.message.user { align-self: flex-end; } |
||||
|
.message.assistant { align-self: flex-start; } |
||||
|
.avatar { |
||||
|
width: 36px; height: 36px; border-radius: 50%; |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
font-size: 18px; flex-shrink: 0; |
||||
|
} |
||||
|
.message.user .avatar { background: #667eea; } |
||||
|
.message.assistant .avatar { background: #10b981; } |
||||
|
.bubble { |
||||
|
padding: 10px 16px; border-radius: 12px; |
||||
|
line-height: 1.6; font-size: 14px; word-break: break-word; |
||||
|
} |
||||
|
.message.user .bubble { background: #667eea; color: #fff; border-bottom-right-radius: 4px; } |
||||
|
.message.assistant .bubble { background: #fff; color: #333; border: 1px solid #e8e8e8; border-bottom-left-radius: 4px; } |
||||
|
.chat-input-area { |
||||
|
padding: 16px 20px; border-top: 1px solid #eee; |
||||
|
display: flex; gap: 10px; background: #fff; |
||||
|
} |
||||
|
.chat-input-area input { |
||||
|
flex: 1; padding: 12px 16px; |
||||
|
border: 1px solid #ddd; border-radius: 24px; |
||||
|
font-size: 14px; outline: none; transition: border-color 0.3s; |
||||
|
} |
||||
|
.chat-input-area input:focus { border-color: #667eea; } |
||||
|
.chat-input-area button { |
||||
|
padding: 12px 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
color: #fff; border: none; border-radius: 24px; |
||||
|
font-size: 14px; cursor: pointer; transition: opacity 0.3s; |
||||
|
} |
||||
|
.chat-input-area button:hover { opacity: 0.9; } |
||||
|
.chat-input-area button:disabled { opacity: 0.5; cursor: not-allowed; } |
||||
|
.typing-indicator { |
||||
|
display: flex; gap: 4px; padding: 10px 16px; |
||||
|
} |
||||
|
.typing-indicator span { |
||||
|
width: 8px; height: 8px; background: #999; |
||||
|
border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; |
||||
|
} |
||||
|
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; } |
||||
|
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; } |
||||
|
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } } |
||||
|
.info-bar { |
||||
|
padding: 8px 20px; background: #f0f2f5; font-size: 12px; |
||||
|
color: #999; display: flex; justify-content: space-between; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="chat-container"> |
||||
|
<div class="chat-header"> |
||||
|
🤖 AI 智能客服<span>基于通义千问 · 支持多轮对话</span> |
||||
|
</div> |
||||
|
<div class="info-bar"> |
||||
|
<span>会话ID: <strong id="chatIdDisplay">---</strong></span> |
||||
|
<span><a href="http://localhost:8080/doc.html" target="_blank" style="color:#667eea;">📖 API文档</a></span> |
||||
|
</div> |
||||
|
<div class="chat-messages" id="messages"> |
||||
|
<div class="message assistant"> |
||||
|
<div class="avatar">🤖</div> |
||||
|
<div class="bubble"> |
||||
|
您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。<br>请问有什么可以帮您的? |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="chat-input-area"> |
||||
|
<input type="text" id="userInput" placeholder="输入您的问题,按回车发送..." |
||||
|
onkeydown="if(event.key==='Enter') sendMessage()" autofocus> |
||||
|
<button id="sendBtn" onclick="sendMessage()">发送</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
// 生成唯一会话 ID |
||||
|
const chatId = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6); |
||||
|
document.getElementById('chatIdDisplay').textContent = chatId; |
||||
|
|
||||
|
const API_BASE = 'http://localhost:8080'; |
||||
|
|
||||
|
function addMessage(role, content) { |
||||
|
const div = document.createElement('div'); |
||||
|
div.className = 'message ' + role; |
||||
|
const avatar = role === 'user' ? '👤' : '🤖'; |
||||
|
if (role === 'user') { |
||||
|
div.innerHTML = `<div class="bubble">${escapeHtml(content)}</div><div class="avatar">${avatar}</div>`; |
||||
|
} else { |
||||
|
div.innerHTML = `<div class="avatar">${avatar}</div><div class="bubble">${escapeHtml(content)}</div>`; |
||||
|
} |
||||
|
document.getElementById('messages').appendChild(div); |
||||
|
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight; |
||||
|
} |
||||
|
|
||||
|
function addTyping() { |
||||
|
const div = document.createElement('div'); |
||||
|
div.className = 'message assistant'; |
||||
|
div.id = 'typing-msg'; |
||||
|
div.innerHTML = `<div class="avatar">🤖</div><div class="bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>`; |
||||
|
document.getElementById('messages').appendChild(div); |
||||
|
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight; |
||||
|
} |
||||
|
|
||||
|
function removeTyping() { |
||||
|
const el = document.getElementById('typing-msg'); |
||||
|
if (el) el.remove(); |
||||
|
} |
||||
|
|
||||
|
function escapeHtml(text) { |
||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
||||
|
} |
||||
|
|
||||
|
async function sendMessage() { |
||||
|
const input = document.getElementById('userInput'); |
||||
|
const btn = document.getElementById('sendBtn'); |
||||
|
const message = input.value.trim(); |
||||
|
if (!message) return; |
||||
|
|
||||
|
input.value = ''; |
||||
|
input.disabled = true; |
||||
|
btn.disabled = true; |
||||
|
|
||||
|
addMessage('user', message); |
||||
|
addTyping(); |
||||
|
|
||||
|
try { |
||||
|
const url = `${API_BASE}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`; |
||||
|
const res = await fetch(url); |
||||
|
removeTyping(); |
||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`); |
||||
|
const text = await res.text(); |
||||
|
addMessage('assistant', text); |
||||
|
} catch (err) { |
||||
|
removeTyping(); |
||||
|
addMessage('assistant', '抱歉,请求失败:' + err.message + '。请确认后端服务已启动。'); |
||||
|
} finally { |
||||
|
input.disabled = false; |
||||
|
btn.disabled = false; |
||||
|
input.focus(); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,770 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>AI 智能客服系统 - Support Bot</title> |
||||
|
<style> |
||||
|
:root { --bg:#f0f2f5; --card:#fff; --primary:#6366f1; --primary2:#8b5cf6; --success:#10b981; --warn:#f59e0b; --danger:#ef4444; --text:#1f2937; --sub:#6b7280; --border:#e5e7eb; --radius:10px; } |
||||
|
|
||||
|
* { margin:0; padding:0; box-sizing:border-box; } |
||||
|
body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(--bg); color:var(--text); min-height:100vh; } |
||||
|
|
||||
|
/* 顶部导航 */ |
||||
|
.topbar { background:linear-gradient(135deg, var(--primary) 0%, var(--primary2) 100%); color:#fff; padding:0 24px; height:56px; display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:100; } |
||||
|
.topbar .logo { font-size:20px; font-weight:700; } |
||||
|
.topbar .ver { font-size:12px; opacity:.7; margin-left:4px; } |
||||
|
.topbar .links { margin-left:auto; display:flex; gap:12px; } |
||||
|
.topbar .links a { color:#fff; opacity:.8; text-decoration:none; font-size:13px; transition:opacity .2s; } |
||||
|
.topbar .links a:hover { opacity:1; } |
||||
|
|
||||
|
/* Tab 导航 */ |
||||
|
.tabs { display:flex; gap:0; background:var(--card); border-bottom:1px solid var(--border); padding:0 24px; position:sticky; top:56px; z-index:99; } |
||||
|
.tab-btn { padding:14px 24px; border:none; background:none; font-size:14px; cursor:pointer; border-bottom:3px solid transparent; color:var(--sub); transition:all .2s; font-family:inherit; display:flex; align-items:center; gap:6px; } |
||||
|
.tab-btn:hover { color:var(--text); } |
||||
|
.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; } |
||||
|
.tab-icon { font-size:18px; } |
||||
|
|
||||
|
/* 内容区 */ |
||||
|
.main { max-width:1200px; 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);} } |
||||
|
|
||||
|
/* 卡片 */ |
||||
|
.card { background:var(--card); border-radius:var(--radius); padding:20px; margin-bottom:16px; border:1px solid var(--border); } |
||||
|
.card h2 { font-size:16px; margin-bottom:12px; display:flex; align-items:center; gap:8px; } |
||||
|
.card h3 { font-size:14px; margin-bottom:8px; color:var(--sub); } |
||||
|
|
||||
|
/* 表单 */ |
||||
|
.input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; } |
||||
|
.input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; } |
||||
|
.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); } |
||||
|
.input { flex:1; min-width:200px; } |
||||
|
.textarea { width:100%; resize:vertical; min-height:120px; } |
||||
|
.select { min-width:160px; } |
||||
|
.input-sm { width:120px; flex:none; } |
||||
|
|
||||
|
.btn { padding:10px 20px; border:none; border-radius:8px; font-size:14px; cursor:pointer; font-family:inherit; font-weight:500; transition:all .2s; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; } |
||||
|
.btn:disabled { opacity:.5; cursor:not-allowed; } |
||||
|
.btn-primary { background:var(--primary); color:#fff; } |
||||
|
.btn-primary:hover:not(:disabled) { background:#4f46e5; } |
||||
|
.btn-purple { background:var(--primary2); color:#fff; } |
||||
|
.btn-purple:hover:not(:disabled) { background:#7c3aed; } |
||||
|
.btn-success { background:var(--success); color:#fff; } |
||||
|
.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; } |
||||
|
|
||||
|
/* 消息区 */ |
||||
|
.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; } |
||||
|
.msg { display:flex; gap:10px; max-width:85%; animation: fadeIn .3s; } |
||||
|
.msg.user { align-self:flex-end; flex-direction:row-reverse; } |
||||
|
.msg.assistant { align-self:flex-start; } |
||||
|
.msg-avatar { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; flex-shrink:0; } |
||||
|
.msg.user .msg-avatar { background:var(--primary); color:#fff; } |
||||
|
.msg.assistant .msg-avatar { background:var(--success); color:#fff; } |
||||
|
.msg-bubble { padding:10px 14px; border-radius:12px; line-height:1.6; font-size:14px; word-break:break-word; white-space:pre-wrap; } |
||||
|
.msg.user .msg-bubble { background:var(--primary); color:#fff; border-bottom-right-radius:4px; } |
||||
|
.msg.assistant .msg-bubble { background:#fff; border:1px solid var(--border); border-bottom-left-radius:4px; } |
||||
|
.msg.streaming .msg-bubble { border-color:var(--primary); box-shadow:0 0 0 1px var(--primary); } |
||||
|
|
||||
|
/* 商品信息展示 */ |
||||
|
.result-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:12px; margin-top:12px; } |
||||
|
.result-item { background:#f9fafb; padding:12px 16px; border-radius:8px; border:1px solid var(--border); } |
||||
|
.result-item .label { font-size:12px; color:var(--sub); margin-bottom:4px; } |
||||
|
.result-item .value { font-size:14px; font-weight:500; } |
||||
|
.result-json { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:8px; font-family:'Fira Code',monospace; font-size:13px; white-space:pre-wrap; overflow-x:auto; } |
||||
|
|
||||
|
/* 文件上传区 */ |
||||
|
.upload-zone { border:2px dashed var(--border); border-radius:var(--radius); padding:32px; text-align:center; transition:all .2s; cursor:pointer; margin-bottom:12px; } |
||||
|
.upload-zone:hover, .upload-zone.drag-over { border-color:var(--primary); background:#eef2ff; } |
||||
|
.upload-zone p { color:var(--sub); } |
||||
|
.upload-zone .icon { font-size:40px; margin-bottom:8px; } |
||||
|
|
||||
|
/* Toast */ |
||||
|
.toast-container { position:fixed; top:70px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; } |
||||
|
.toast { padding:10px 18px; border-radius:8px; color:#fff; font-size:13px; animation:slideIn .3s; box-shadow:0 4px 12px rgba(0,0,0,.15); } |
||||
|
.toast.success { background:var(--success); } |
||||
|
.toast.error { background:var(--danger); } |
||||
|
.toast.info { background:var(--primary); } |
||||
|
@keyframes slideIn { from{opacity:0;transform:translateX(100px);} to{opacity:1;transform:translateX(0);} } |
||||
|
|
||||
|
/* 流式结果比较 */ |
||||
|
.stream-compare { display:grid; grid-template-columns:1fr 1fr; gap:16px; } |
||||
|
.stream-compare .card { margin-bottom:0; } |
||||
|
|
||||
|
/* 响应式 */ |
||||
|
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } } |
||||
|
|
||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; } |
||||
|
.badge-get { background:#dbeafe; color:#1d4ed8; } |
||||
|
.badge-post { background:#fef3c7; color:#b45309; } |
||||
|
.endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; } |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="topbar"> |
||||
|
<span class="logo">🤖 Support Bot</span><span class="ver">AI 智能客服系统</span> |
||||
|
<span class="links"> |
||||
|
<a href="/doc.html" target="_blank">📖 API 文档</a> |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="tabs" id="tabs"> |
||||
|
<button class="tab-btn active" data-tab="chat"><span class="tab-icon">💬</span>智能客服对话</button> |
||||
|
<button class="tab-btn" data-tab="product"><span class="tab-icon">🏷️</span>商品信息提取</button> |
||||
|
<button class="tab-btn" data-tab="document"><span class="tab-icon">📄</span>知识库文档管理</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="main"> |
||||
|
|
||||
|
<!-- ==================== Tab 1: AI 智能客服对话 ==================== --> |
||||
|
<div class="tab-panel active" id="panel-chat"> |
||||
|
<div class="card"> |
||||
|
<h2>💬 智能客服对话</h2> |
||||
|
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆</p> |
||||
|
|
||||
|
<div class="input-row"> |
||||
|
<input class="input input-sm" id="chatId" placeholder="会话ID" value=""> |
||||
|
<select class="select" id="modeSelect"> |
||||
|
<option value="sync">🔵 同步调用</option> |
||||
|
<option value="sse">🟢 SSE 流式 (Flux)</option> |
||||
|
<option value="sse2">🟡 ServerSentEvent</option> |
||||
|
<option value="sse3">🟣 SseEmitter</option> |
||||
|
</select> |
||||
|
<button class="btn btn-outline btn-sm" onclick="newChatId()">🔄 新会话</button> |
||||
|
<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>💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。<br>📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="input-row"> |
||||
|
<input class="input" id="userInput" placeholder="输入问题,Enter 发送..." onkeydown="if(event.key==='Enter')chatSend()"> |
||||
|
<button class="btn btn-primary" id="sendBtn" onclick="chatSend()">📨 发送</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ==================== Tab 2: 商品信息提取 ==================== --> |
||||
|
<div class="tab-panel" id="panel-product"> |
||||
|
<div class="card"> |
||||
|
<div class="endpoint-info"> |
||||
|
<span class="badge badge-get">GET</span> |
||||
|
<code style="font-size:13px;">/ai/product_info_app/chat/sync</code> |
||||
|
</div> |
||||
|
<h2>🏷️ 商品信息结构化提取</h2> |
||||
|
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类</p> |
||||
|
|
||||
|
<textarea class="textarea" id="productContent" placeholder="请输入商品网页内容或描述文本... |
||||
|
例如:Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类..."></textarea> |
||||
|
|
||||
|
<div class="input-row" style="margin-top:12px;"> |
||||
|
<button class="btn btn-primary" id="extractBtn" onclick="extractProduct()">🔍 提取商品信息</button> |
||||
|
<button class="btn btn-outline btn-sm" onclick="document.getElementById('productContent').value=''">清空</button> |
||||
|
<button class="btn btn-outline btn-sm" onclick="document.getElementById('productContent').value='Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类'">填入示例</button> |
||||
|
</div> |
||||
|
|
||||
|
<div id="productResult" style="margin-top:16px;display:none;"> |
||||
|
<h3>📊 提取结果</h3> |
||||
|
<div class="result-grid" id="productGrid"></div> |
||||
|
<div class="result-json" id="productJson" style="margin-top:12px;"></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ==================== Tab 3: 知识库文档管理 ==================== --> |
||||
|
<div class="tab-panel" id="panel-document"> |
||||
|
<div class="card"> |
||||
|
<h2>📄 知识库文档管理</h2> |
||||
|
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector,即可用于 AI 检索问答</p> |
||||
|
|
||||
|
<!-- 子 Tab --> |
||||
|
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;"> |
||||
|
<button class="btn btn-sm doc-sub-tab active" data-doc="file">📎 通用文件</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="string">📝 文本内容</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="markdown">📑 Markdown</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="jsonBasic">📋 JSON 基本</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="jsonFields">🔑 JSON 按字段</button> |
||||
|
<button class="btn btn-sm doc-sub-tab" data-doc="jsonPointer">📍 JSON 按指针</button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 通用文件 --> |
||||
|
<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="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')"> |
||||
|
</div> |
||||
|
<div id="fileFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<button class="btn btn-primary" id="btn-file" onclick="uploadDocument('file')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-file-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 文本内容 --> |
||||
|
<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="输入要加入知识库的文本内容... 例如:公司退换货政策、商品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="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')"> |
||||
|
</div> |
||||
|
<div id="mdFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<button class="btn btn-primary" id="btn-markdown" onclick="uploadDocument('markdown')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-markdown-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 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="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')"> |
||||
|
</div> |
||||
|
<div id="jsonBFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<button class="btn btn-primary" id="btn-jsonBasic" onclick="uploadDocument('jsonBasic')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-jsonBasic-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 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="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')"> |
||||
|
</div> |
||||
|
<div id="jsonFFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<input class="input" id="jsonFieldsInput" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;"> |
||||
|
<button class="btn btn-primary" id="btn-jsonFields" onclick="uploadDocument('jsonFields')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-jsonFields-result"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 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="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')"> |
||||
|
</div> |
||||
|
<div id="jsonPFileInfo" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
||||
|
<input class="input" id="jsonPointerInput" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;"> |
||||
|
<button class="btn btn-primary" id="btn-jsonPointer" onclick="uploadDocument('jsonPointer')" disabled>🚀 上传并向量化</button> |
||||
|
<div id="doc-jsonPointer-result"></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 上传历史 --> |
||||
|
<div class="card"> |
||||
|
<h2>📋 最近上传记录</h2> |
||||
|
<div id="uploadLog" style="font-size:13px;color:var(--sub);">暂无上传记录</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
<div class="toast-container" id="toasts"></div> |
||||
|
|
||||
|
<script> |
||||
|
// ==================== 全局状态 ==================== |
||||
|
const API = 'http://localhost:9090'; |
||||
|
let currentChatId = ''; |
||||
|
let isRagMode = false; |
||||
|
|
||||
|
// ==================== 工具函数 ==================== |
||||
|
function toast(msg, type='info') { |
||||
|
const c = document.getElementById('toasts'); |
||||
|
const d = document.createElement('div'); |
||||
|
d.className = 'toast ' + type; |
||||
|
d.textContent = msg; |
||||
|
c.appendChild(d); |
||||
|
setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.remove(),300);},2500); |
||||
|
} |
||||
|
|
||||
|
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>),将从向量数据库中检索相关信息以增强回答质量。`; |
||||
|
} |
||||
|
|
||||
|
// ==================== Tab 切换 ==================== |
||||
|
document.getElementById('tabs').addEventListener('click', function(e) { |
||||
|
const btn = e.target.closest('.tab-btn'); |
||||
|
if(!btn) return; |
||||
|
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); |
||||
|
btn.classList.add('active'); |
||||
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); |
||||
|
document.getElementById('panel-'+btn.dataset.tab).classList.add('active'); |
||||
|
}); |
||||
|
|
||||
|
// 子 Tab 切换 (文档管理) |
||||
|
document.addEventListener('click', function(e) { |
||||
|
const btn = e.target.closest('.doc-sub-tab'); |
||||
|
if(!btn) return; |
||||
|
document.querySelectorAll('.doc-sub-tab').forEach(b=>{b.classList.remove('active');b.style.background='';b.style.color='';}); |
||||
|
btn.classList.add('active'); |
||||
|
btn.style.background='var(--primary)'; btn.style.color='#fff'; |
||||
|
document.querySelectorAll('.doc-panel').forEach(p=>p.style.display='none'); |
||||
|
document.getElementById('doc-'+btn.dataset.doc).style.display='block'; |
||||
|
}); |
||||
|
|
||||
|
// ==================== 初始化 ==================== |
||||
|
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() { |
||||
|
currentChatId = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2,6); |
||||
|
$('chatId').value = currentChatId; |
||||
|
return currentChatId; |
||||
|
} |
||||
|
function clearMessages() { |
||||
|
$('chatMessages').innerHTML = ''; |
||||
|
$('chatMessages').innerHTML = '<div class="msg assistant"><div class="msg-avatar">🤖</div><div class="msg-bubble">已清屏,开始新的对话吧~</div></div>'; |
||||
|
} |
||||
|
|
||||
|
// ==================== 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 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 { |
||||
|
// 如果启用了 RAG 模式且是同步模式,使用 RAG 接口 |
||||
|
if (isRagMode && mode === 'sync') { |
||||
|
await chatRagSync(message, chatId, bubble); |
||||
|
} else if (isRagMode && mode !== 'sync') { |
||||
|
// RAG 模式仅支持同步调用 |
||||
|
bubble.textContent = '⚠️ RAG 知识库模式目前仅支持同步调用,请切换到"🔵 同步调用"模式,或关闭 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; |
||||
|
} |
||||
|
} |
||||
|
} catch(e) { |
||||
|
bubble.textContent = '请求失败:' + e.message; |
||||
|
toast('对话失败:' + e.message, 'error'); |
||||
|
} finally { |
||||
|
bubble.parentElement.classList.remove('streaming'); |
||||
|
input.disabled = false; $('sendBtn').disabled = false; |
||||
|
input.focus(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 接口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); |
||||
|
} |
||||
|
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}]`; |
||||
|
} |
||||
|
|
||||
|
// 接口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); |
||||
|
} |
||||
|
const reader = res.body.getReader(); |
||||
|
const decoder = new TextDecoder(); |
||||
|
let buffer = ''; |
||||
|
while(true) { |
||||
|
const {done, value} = await reader.read(); |
||||
|
if(done) break; |
||||
|
buffer += decoder.decode(value, {stream:true}); |
||||
|
const lines = buffer.split('\n'); |
||||
|
buffer = lines.pop() || ''; |
||||
|
for(const line of lines) { |
||||
|
if(line.startsWith('data:')) { |
||||
|
const data = line.slice(5).trim(); |
||||
|
if(data && data !== '[DONE]') bubble.textContent += data; |
||||
|
} else if(line.trim() && !line.startsWith(':')) { |
||||
|
bubble.textContent += line; |
||||
|
} |
||||
|
} |
||||
|
$('chatMessages').scrollTop = $('chatMessages').scrollHeight; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 接口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); |
||||
|
} |
||||
|
const reader = res.body.getReader(); |
||||
|
const decoder = new TextDecoder(); |
||||
|
let buffer = ''; |
||||
|
while(true) { |
||||
|
const {done, value} = await reader.read(); |
||||
|
if(done) break; |
||||
|
buffer += decoder.decode(value, {stream:true}); |
||||
|
const lines = buffer.split('\n'); |
||||
|
buffer = lines.pop() || ''; |
||||
|
for(const line of lines) { |
||||
|
if(line.startsWith('data:')) { |
||||
|
const data = line.slice(5).trim(); |
||||
|
if(data && data !== '[DONE]') bubble.textContent += data; |
||||
|
} |
||||
|
} |
||||
|
$('chatMessages').scrollTop = $('chatMessages').scrollHeight; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 接口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); |
||||
|
} |
||||
|
const reader = res.body.getReader(); |
||||
|
const decoder = new TextDecoder(); |
||||
|
let buffer = ''; |
||||
|
while(true) { |
||||
|
const {done, value} = await reader.read(); |
||||
|
if(done) break; |
||||
|
buffer += decoder.decode(value, {stream:true}); |
||||
|
const lines = buffer.split('\n'); |
||||
|
buffer = lines.pop() || ''; |
||||
|
for(const line of lines) { |
||||
|
if(line.startsWith('data:')) { |
||||
|
const data = line.slice(5).trim(); |
||||
|
if(data && data !== '[DONE]') bubble.textContent += data; |
||||
|
} else if(line.trim() && !line.startsWith(':')) { |
||||
|
bubble.textContent += line; |
||||
|
} |
||||
|
} |
||||
|
$('chatMessages').scrollTop = $('chatMessages').scrollHeight; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ==================== 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] : '—'; |
||||
|
html += `<div class="result-item"><div class="label">${labels[f]}</div><div class="value">${val}</div></div>`; |
||||
|
} |
||||
|
$('productGrid').innerHTML = html; |
||||
|
$('productJson').textContent = JSON.stringify(data, null, 2); |
||||
|
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 = '🔍 提取商品信息'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ==================== 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)})`; |
||||
|
$(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; |
||||
|
|
||||
|
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 'jsonFields': |
||||
|
resultId = 'doc-jsonFields-result'; |
||||
|
{ const fieldsStr = $('jsonFieldsInput').value.trim(); |
||||
|
if(!fieldsStr) { toast('请输入要提取的字段名', 'error'); return; } |
||||
|
baseUrl = `${API}/document/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)}`; } |
||||
|
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; |
||||
|
|
||||
|
// 字符串上传:单次请求 |
||||
|
if(type === 'string') { |
||||
|
if(btn) { btn.disabled = true; btn.textContent = '⏳ 处理中...'; } |
||||
|
try { |
||||
|
const res = await fetch(baseUrl, { 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); } |
||||
|
} 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>`; |
||||
|
toast('上传失败:'+e.message, 'error'); |
||||
|
} finally { |
||||
|
if(btn) { btn.disabled = false; btn.textContent = '🚀 上传并向量化'; } |
||||
|
} |
||||
|
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}` : '⏳ 处理中...'; } |
||||
|
|
||||
|
let successCount = 0, failCount = 0, totalDocs = 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}`; |
||||
|
|
||||
|
try { |
||||
|
const formData = new FormData(); |
||||
|
formData.append('file', file); |
||||
|
const res = await fetch(baseUrl, { 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); |
||||
|
} else { |
||||
|
failCount++; |
||||
|
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}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 汇总结果 |
||||
|
const summary = successCount > 0 |
||||
|
? `✅ 上传完成:成功 <strong>${successCount}</strong> 个${failCount > 0 ? `,失败 <strong>${failCount}</strong> 个` : ''},共生成 <strong>${totalDocs}</strong> 个文档向量` |
||||
|
: `❌ 全部失败(${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'); |
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
// 拖放支持 |
||||
|
['zone-file','zone-md','zone-jsonB','zone-jsonF','zone-jsonP'].forEach(id => { |
||||
|
const zone = document.getElementById(id); |
||||
|
if(!zone) return; |
||||
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); |
||||
|
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); |
||||
|
zone.addEventListener('drop', e => { |
||||
|
e.preventDefault(); |
||||
|
zone.classList.remove('drag-over'); |
||||
|
const inputId = id.replace('zone-',''); |
||||
|
const inputMap = {file:'fileInput', md:'mdInput', jsonB:'jsonBInput', jsonF:'jsonFInput', jsonP:'jsonPInput'}; |
||||
|
const typeMap = {file:'file', md:'markdown', jsonB:'jsonBasic', jsonF:'jsonFields', jsonP:'jsonPointer'}; |
||||
|
const input = document.getElementById(inputMap[inputId]); |
||||
|
if(input && e.dataTransfer.files[0]) { |
||||
|
input.files = e.dataTransfer.files; |
||||
|
handleFileSelect(input, typeMap[inputId]); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// ==================== 启动 ==================== |
||||
|
init(); |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue