20 changed files with 1611 additions and 2556 deletions
-
13CLAUDE.md
-
31README.md
-
178chat.html
-
1098frontend.html
-
178src/main/resources/static/chat.html
-
76src/main/resources/static/components/CategoryManager.js
-
140src/main/resources/static/components/ChatPanel.js
-
61src/main/resources/static/components/DocDetail.js
-
145src/main/resources/static/components/DocList.js
-
71src/main/resources/static/components/DocSearch.js
-
39src/main/resources/static/components/DocStats.js
-
264src/main/resources/static/components/DocUpload.js
-
80src/main/resources/static/components/ProductPanel.js
-
136src/main/resources/static/css/main.css
-
1098src/main/resources/static/frontend.html
-
20src/main/resources/static/index.html
-
259src/main/resources/static/js/api.js
-
95src/main/resources/static/js/app.js
-
86src/main/resources/static/js/store.js
-
99src/main/resources/static/js/utils.js
@ -1,178 +0,0 @@ |
|||
<!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> |
|||
1098
frontend.html
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1,178 +0,0 @@ |
|||
<!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,76 @@ |
|||
/** |
|||
* 🏷️ 分类管理 |
|||
*/ |
|||
import { ref } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { createCategory, deleteCategory } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>🏷️ 分类管理</h2> |
|||
<div class="input-row"> |
|||
<input class="input" v-model="name" placeholder="分类名称"> |
|||
<input class="input" v-model="description" placeholder="分类描述(可选)"> |
|||
<input class="input input-sm" v-model.number="sortOrder" placeholder="排序" type="number"> |
|||
<button class="btn btn-success" @click="create">➕ 创建分类</button> |
|||
<button class="btn btn-outline btn-sm" @click="store.loadCategories()">🔄 刷新</button> |
|||
</div> |
|||
|
|||
<div v-if="store.categories.length === 0" style="margin-top:12px;font-size:13px;">暂无分类</div> |
|||
<div v-else style="margin-top:12px;font-size:13px;"> |
|||
<div v-for="c in store.categories" :key="c.id" style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;"> |
|||
<span><strong>{{ c.name }}</strong> <span style="color:var(--sub);font-size:12px;">{{ c.description || '' }}</span></span> |
|||
<button class="btn btn-danger btn-sm" @click="remove(c.id)">删除</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const name = ref('') |
|||
const description = ref('') |
|||
const sortOrder = ref(0) |
|||
|
|||
async function create() { |
|||
if (!name.value.trim()) { |
|||
toast('请输入分类名称', 'error') |
|||
return |
|||
} |
|||
try { |
|||
const json = await createCategory({ |
|||
name: name.value, |
|||
description: description.value, |
|||
sortOrder: sortOrder.value |
|||
}) |
|||
if (json.success) { |
|||
toast('分类创建成功', 'success') |
|||
name.value = '' |
|||
description.value = '' |
|||
store.loadCategories() |
|||
} else { |
|||
toast(json.message || '创建失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('创建分类失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function remove(id) { |
|||
if (!confirm('确定删除此分类?关联文档将变为未分类')) return |
|||
try { |
|||
const json = await deleteCategory(id) |
|||
if (json.success) { |
|||
toast('分类已删除', 'success') |
|||
store.loadCategories() |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除分类失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
return { name, description, sortOrder, store, create, remove } |
|||
} |
|||
} |
|||
@ -0,0 +1,140 @@ |
|||
/** |
|||
* 💬 智能客服对话面板 |
|||
*/ |
|||
import { ref, nextTick } from 'vue' |
|||
import { chatSync, chatRagSync, chatSSEUrl } from '../js/api.js' |
|||
import { toast, readSSEStream } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<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" v-model="chatId" placeholder="会话ID"> |
|||
<select class="select" v-model="mode"> |
|||
<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" @click="newChatId">🔄 新会话</button> |
|||
<button class="btn btn-outline btn-sm" @click="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" v-model="isRagMode" style="width:16px;height:16px;cursor:pointer;"> |
|||
<span style="font-weight:600;color:var(--primary);">📚 启用 RAG 知识库检索</span> |
|||
</label> |
|||
<select class="select" v-model="ragStrategy" v-show="isRagMode" style="margin-left:auto;"> |
|||
<option value="NONE">无重写</option> |
|||
<option value="REWRITE">查询重写</option> |
|||
<option value="TRANSLATION">翻译扩展</option> |
|||
<option value="COMPRESSION">查询压缩</option> |
|||
<option value="MULTI_QUERY">多路扩展</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div v-show="isRagMode" style="margin-bottom:12px;padding:8px 12px;background:#eef2ff;border-radius:6px;font-size:12px;color:var(--primary);border-left:3px solid var(--primary);"> |
|||
💡 当前已启用 RAG 知识库检索(策略:<strong>{{ ragStrategyNames[ragStrategy] || ragStrategy }}</strong>) |
|||
</div> |
|||
|
|||
<div class="msg-area" ref="msgArea"> |
|||
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']"> |
|||
<div class="msg-avatar">{{ m.role === 'user' ? '👤' : '🤖' }}</div> |
|||
<div class="msg-bubble">{{ m.content }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="input-row"> |
|||
<input class="input" v-model="userInput" placeholder="输入问题,Enter 发送..." @keydown.enter="send" :disabled="isSending"> |
|||
<button class="btn btn-primary" @click="send" :disabled="isSending">📨 发送</button> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const chatId = ref('') |
|||
const mode = ref('sync') |
|||
const isRagMode = ref(false) |
|||
const ragStrategy = ref('REWRITE') |
|||
const userInput = ref('') |
|||
const isSending = ref(false) |
|||
const messages = ref([ |
|||
{ role: 'assistant', content: '您好!我是电商智能客服助手。\n可以帮您解答商品、订单、支付、物流和售后问题。\n\n💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。\n📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。', streaming: false } |
|||
]) |
|||
const msgArea = ref(null) |
|||
|
|||
const ragStrategyNames = { |
|||
NONE: '无重写', REWRITE: '查询重写', TRANSLATION: '翻译扩展', |
|||
COMPRESSION: '查询压缩', MULTI_QUERY: '多路扩展' |
|||
} |
|||
|
|||
function newChatId() { |
|||
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6) |
|||
} |
|||
|
|||
function clearMessages() { |
|||
messages.value = [{ role: 'assistant', content: '已清屏,开始新的对话吧~', streaming: false }] |
|||
} |
|||
|
|||
async function send() { |
|||
const text = userInput.value.trim() |
|||
if (!text || isSending.value) return |
|||
userInput.value = '' |
|||
isSending.value = true |
|||
|
|||
// 添加用户消息
|
|||
messages.value.push({ role: 'user', content: text, streaming: false }) |
|||
|
|||
// 添加助手占位
|
|||
const assistantMsg = { role: 'assistant', content: '', streaming: true } |
|||
messages.value.push(assistantMsg) |
|||
|
|||
const cid = chatId.value || ('web_' + Date.now()) |
|||
chatId.value = cid |
|||
|
|||
try { |
|||
if (isRagMode.value && mode.value === 'sync') { |
|||
// RAG 同步
|
|||
const result = await chatRagSync(text, cid, ragStrategy.value) |
|||
assistantMsg.content = result |
|||
} else if (isRagMode.value && mode.value !== 'sync') { |
|||
assistantMsg.content = '⚠️ RAG 模式仅支持同步调用' |
|||
toast('RAG 模式暂不支持流式调用', 'error') |
|||
} else if (mode.value === 'sync') { |
|||
// 普通同步
|
|||
const result = await chatSync(text, cid) |
|||
assistantMsg.content = result |
|||
} else { |
|||
// SSE 流式
|
|||
const url = chatSSEUrl(text, cid, mode.value) |
|||
await readSSEStream(url, |
|||
(chunk) => { assistantMsg.content += chunk }, |
|||
() => {} |
|||
) |
|||
} |
|||
} catch (e) { |
|||
assistantMsg.content = '请求失败:' + e.message |
|||
toast('对话失败:' + e.message, 'error') |
|||
} finally { |
|||
assistantMsg.streaming = false |
|||
isSending.value = false |
|||
// 滚动到底部
|
|||
nextTick(() => { |
|||
if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// 初始化会话 ID
|
|||
newChatId() |
|||
|
|||
return { |
|||
chatId, mode, isRagMode, ragStrategy, ragStrategyNames, |
|||
userInput, isSending, messages, msgArea, |
|||
newChatId, clearMessages, send |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
/** |
|||
* 📄 文档详情弹窗 |
|||
*/ |
|||
import { computed } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { formatBytes, formatDate } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div :class="['modal-overlay', store.detailModal.visible ? 'active' : '']" @click.self="store.closeDetail()"> |
|||
<div class="modal-box"> |
|||
<button class="modal-close" @click="store.closeDetail()">×</button> |
|||
<h2>📄 文档详情</h2> |
|||
|
|||
<template v-if="store.detailModal.doc"> |
|||
<div style="margin-bottom:16px;"> |
|||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;"> |
|||
<div class="result-item"><div class="label">文档标题</div><div class="value">{{ store.detailModal.doc.title }}</div></div> |
|||
<div class="result-item"><div class="label">原始文件名</div><div class="value">{{ store.detailModal.doc.sourceName || '-' }}</div></div> |
|||
<div class="result-item"><div class="label">文件类型</div><div class="value">{{ store.detailModal.doc.fileType }}</div></div> |
|||
<div class="result-item"><div class="label">文件大小</div><div class="value">{{ formatBytes(store.detailModal.doc.fileSize || 0) }}</div></div> |
|||
<div class="result-item"><div class="label">状态</div><div class="value"><span :class="statusClass">{{ store.detailModal.doc.status }}</span></div></div> |
|||
<div class="result-item"><div class="label">分块数</div><div class="value">{{ store.detailModal.doc.chunkCount }}</div></div> |
|||
<div class="result-item"><div class="label">分类</div><div class="value">{{ store.getCategoryName(store.detailModal.doc.categoryId) }}</div></div> |
|||
<div class="result-item"><div class="label">创建时间</div><div class="value">{{ formatDate(store.detailModal.doc.createTime) }}</div></div> |
|||
</div> |
|||
<h3>📄 原文内容</h3> |
|||
<div style="background:#f9fafb;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:200px;overflow-y:auto;">{{ store.detailModal.doc.content || '-' }}</div> |
|||
</div> |
|||
<h3>🧩 分块详情({{ store.detailModal.chunks.length }} 个)</h3> |
|||
|
|||
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result"> |
|||
<div class="meta">#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}</div> |
|||
<div class="content">{{ chunk.content || '' }}</div> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const statusClass = computed(() => { |
|||
const status = store.detailModal.doc?.status |
|||
return status === 'READY' ? 'status-ready' |
|||
: status === 'PROCESSING' ? 'status-processing' |
|||
: 'status-failed' |
|||
}) |
|||
|
|||
function getKeywords(chunk) { |
|||
let meta = chunk.metadata |
|||
if (typeof meta === 'object' && meta && meta.value) meta = meta.value |
|||
try { |
|||
const m = typeof meta === 'string' ? JSON.parse(meta) : meta |
|||
return m.excerpt_keywords || '' |
|||
} catch (e) { |
|||
return '' |
|||
} |
|||
} |
|||
|
|||
return { store, statusClass, getKeywords, formatBytes, formatDate } |
|||
} |
|||
} |
|||
@ -0,0 +1,145 @@ |
|||
/** |
|||
* 📋 文档列表 + 分页 |
|||
*/ |
|||
import { ref } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { listDocuments, deleteDocument, reprocessDocument } from '../js/api.js' |
|||
import { toast, formatDate } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>📋 文档列表</h2> |
|||
<div class="input-row"> |
|||
<select class="select" v-model="filterCategory" @change="load()"> |
|||
<option value="">全部分类</option> |
|||
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option> |
|||
</select> |
|||
<select class="select" v-model="filterStatus" @change="load()"> |
|||
<option value="">全部状态</option> |
|||
<option value="READY">✅ 已完成</option> |
|||
<option value="PROCESSING">⏳ 处理中</option> |
|||
<option value="FAILED">❌ 失败</option> |
|||
</select> |
|||
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button> |
|||
</div> |
|||
|
|||
<div style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>ID</th> |
|||
<th>标题</th> |
|||
<th>类型</th> |
|||
<th>状态</th> |
|||
<th>分块数</th> |
|||
<th>创建时间</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="documents.length === 0"> |
|||
<td colspan="7" style="text-align:center;color:var(--sub);">暂无文档</td> |
|||
</tr> |
|||
<tr v-for="d in documents" :key="d.id"> |
|||
<td>{{ d.id }}</td> |
|||
<td> |
|||
<strong>{{ d.title }}</strong><br> |
|||
<span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span> |
|||
</td> |
|||
<td><span class="category-tag">{{ d.fileType }}</span></td> |
|||
<td><span :class="statusClass(d.status)">{{ d.status }}</span></td> |
|||
<td>{{ d.chunkCount }}</td> |
|||
<td>{{ formatDate(d.createTime) }}</td> |
|||
<td> |
|||
<button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button> |
|||
<button class="btn btn-sm btn-warn" @click="reprocess(d.id)">重新处理</button> |
|||
<button class="btn btn-sm btn-danger" @click="remove(d.id)">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<div class="pagination" v-if="pages > 1"> |
|||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button> |
|||
<template v-for="i in pages" :key="i"> |
|||
<button v-if="i === 1 || i === pages || (i >= page - 2 && i <= page + 2)" |
|||
:class="{ active: i === page }" @click="load(i)">{{ i }}</button> |
|||
<span v-else-if="i === page - 3 || i === page + 3" style="padding:6px;">...</span> |
|||
</template> |
|||
<button :disabled="page >= pages" @click="load(page + 1)">下一页</button> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const documents = ref([]) |
|||
const page = ref(1) |
|||
const pages = ref(1) |
|||
const total = ref(0) |
|||
const filterCategory = ref('') |
|||
const filterStatus = ref('') |
|||
|
|||
function statusClass(status) { |
|||
return status === 'READY' ? 'status-ready' |
|||
: status === 'PROCESSING' ? 'status-processing' |
|||
: 'status-failed' |
|||
} |
|||
|
|||
async function load(p = 1) { |
|||
page.value = p |
|||
try { |
|||
const json = await listDocuments(p, 10, filterCategory.value || undefined, filterStatus.value || undefined) |
|||
if (json.success) { |
|||
documents.value = json.data || [] |
|||
total.value = json.total || 0 |
|||
pages.value = json.pages || 1 |
|||
} else { |
|||
toast(json.message || '查询失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载文档失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
function viewDetail(id) { |
|||
store.openDetail(id) |
|||
} |
|||
|
|||
async function remove(id) { |
|||
if (!confirm('确定删除此文档?关联的向量也将被删除')) return |
|||
try { |
|||
const json = await deleteDocument(id) |
|||
if (json.success) { |
|||
toast(`已删除,连带删除 ${json.deletedVectors || 0} 个向量`, 'success') |
|||
load(page.value) |
|||
store.loadStats() |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function reprocess(id) { |
|||
if (!confirm('确定重新处理此文档?将重新分块并向量化')) return |
|||
try { |
|||
const json = await reprocessDocument(id) |
|||
if (json.success) { |
|||
toast('重新处理成功', 'success') |
|||
load(page.value) |
|||
} else { |
|||
toast(json.message || '重新处理失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('重新处理失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
return { |
|||
documents, page, pages, total, filterCategory, filterStatus, |
|||
store, statusClass, load, viewDetail, remove, reprocess, formatDate |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
/** |
|||
* 🔍 语义搜索测试 |
|||
*/ |
|||
import { ref } from 'vue' |
|||
import { searchDocuments } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>🔍 语义搜索测试</h2> |
|||
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句,测试知识库检索效果</p> |
|||
<div class="input-row"> |
|||
<input class="input" v-model="query" placeholder="输入查询,如:Spring Boot 是什么?" @keydown.enter="search"> |
|||
<input class="input input-sm" v-model.number="topK" placeholder="TopK" type="number" min="1" max="20"> |
|||
<input class="input input-sm" v-model.number="threshold" placeholder="阈值" type="number" min="0" max="1" step="0.1"> |
|||
<button class="btn btn-primary" @click="search" :disabled="isSearching">{{ isSearching ? '⏳ 搜索中...' : '🔍 搜索' }}</button> |
|||
</div> |
|||
|
|||
<div v-if="isSearching" style="text-align:center;padding:20px;color:var(--sub);">⏳ 搜索中...</div> |
|||
|
|||
<div v-else-if="searched && results.length === 0" style="text-align:center;padding:20px;color:var(--sub);">未找到相关结果</div> |
|||
|
|||
<div v-else-if="searched && errorMsg" style="color:var(--danger);">搜索失败:{{ errorMsg }}</div> |
|||
|
|||
<div v-else> |
|||
<div v-for="(r, i) in results" :key="i" class="search-result"> |
|||
<div class="score">相似度得分: {{ r.score !== null ? (1 - r.score).toFixed(4) : '-' }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }}</div> |
|||
<div class="content">{{ r.content || '' }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const query = ref('') |
|||
const topK = ref(5) |
|||
const threshold = ref(0.5) |
|||
const results = ref([]) |
|||
const isSearching = ref(false) |
|||
const searched = ref(false) |
|||
const errorMsg = ref('') |
|||
|
|||
async function search() { |
|||
if (!query.value.trim()) { |
|||
toast('请输入查询内容', 'error') |
|||
return |
|||
} |
|||
isSearching.value = true |
|||
searched.value = false |
|||
errorMsg.value = '' |
|||
try { |
|||
const json = await searchDocuments(query.value, topK.value, threshold.value) |
|||
if (!json.success) { |
|||
errorMsg.value = json.message |
|||
results.value = [] |
|||
} else { |
|||
results.value = json.data || [] |
|||
} |
|||
searched.value = true |
|||
} catch (e) { |
|||
errorMsg.value = e.message |
|||
results.value = [] |
|||
searched.value = true |
|||
} finally { |
|||
isSearching.value = false |
|||
} |
|||
} |
|||
|
|||
return { query, topK, threshold, results, isSearching, searched, errorMsg, search } |
|||
} |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
/** |
|||
* 📊 知识库统计面板 |
|||
*/ |
|||
import { computed } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { formatDate } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>📊 知识库概览</h2> |
|||
<div class="stat-grid"> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ store.stats?.totalDocuments || 0 }}</div> |
|||
<div class="label">文档总数</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ store.stats?.totalVectors || 0 }}</div> |
|||
<div class="label">向量总数</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ store.stats?.lastUploadTime ? formatDate(store.stats.lastUploadTime) : '-' }}</div> |
|||
<div class="label">最近上传</div> |
|||
</div> |
|||
</div> |
|||
<div style="font-size:13px;color:var(--sub);">{{ fileTypeDetail }}</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const fileTypeDetail = computed(() => { |
|||
if (!store.stats?.byFileType) return '' |
|||
const entries = Object.entries(store.stats.byFileType) |
|||
if (entries.length === 0) return '' |
|||
return '文件类型: ' + entries.map(([k, v]) => `${k}: ${v}`).join(', ') |
|||
}) |
|||
|
|||
return { store, fileTypeDetail, formatDate } |
|||
} |
|||
} |
|||
@ -0,0 +1,264 @@ |
|||
/** |
|||
* 📤 文档上传面板 |
|||
* 支持 6 种上传格式:通用文件、文本、Markdown、JSON(3种模式) |
|||
*/ |
|||
import { ref, reactive } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js' |
|||
import { toast, formatBytes } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>📤 文档上传</h2> |
|||
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector</p> |
|||
|
|||
<!-- 上传元信息 --> |
|||
<div class="input-row" style="padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;"> |
|||
<select class="select" v-model="uploadCategory"> |
|||
<option value="">选择分类(可选)</option> |
|||
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option> |
|||
</select> |
|||
<input class="input" v-model="uploadTags" placeholder="标签,逗号分隔(可选)"> |
|||
</div> |
|||
|
|||
<!-- 子 Tab --> |
|||
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;"> |
|||
<button v-for="t in subTabs" :key="t.key" |
|||
:class="['btn', 'btn-sm', activeSubTab === t.key ? 'btn-primary' : '']" |
|||
@click="activeSubTab = t.key">{{ t.icon }} {{ t.label }}</button> |
|||
</div> |
|||
|
|||
<!-- 通用文件 --> |
|||
<div v-show="activeSubTab === 'file'"> |
|||
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">(Tika 多格式解析)</span></div> |
|||
<div class="upload-zone" @click="$refs.fileInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'file')"> |
|||
<div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)</p> |
|||
<input type="file" ref="fileInput" style="display:none" multiple @change="handleFileSelect($event, 'file')"> |
|||
</div> |
|||
<div v-html="fileInfo.file" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
|||
<button class="btn btn-primary" @click="doUpload('file')" :disabled="!fileData.file">🚀 上传并向量化</button> |
|||
<div v-html="results.file"></div> |
|||
</div> |
|||
|
|||
<!-- 文本内容 --> |
|||
<div v-show="activeSubTab === 'string'"> |
|||
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/string</code><span style="font-size:12px;color:var(--sub);">(直接上传文本)</span></div> |
|||
<input class="input" v-model="stringTitle" placeholder="文档标题" style="margin-bottom:8px;"> |
|||
<textarea class="textarea" v-model="stringContent" placeholder="输入要加入知识库的文本内容... |
|||
例如:公司退换货政策、商品FAQ、物流说明等"></textarea> |
|||
<button class="btn btn-primary" style="margin-top:12px;" @click="doUpload('string')">🚀 上传并向量化</button> |
|||
<div v-html="results.string"></div> |
|||
</div> |
|||
|
|||
<!-- Markdown --> |
|||
<div v-show="activeSubTab === 'markdown'"> |
|||
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/markdown</code></div> |
|||
<div class="upload-zone" @click="$refs.mdInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'markdown')"> |
|||
<div class="icon">📑</div><p>点击或拖拽上传,支持多文件(Markdown .md)</p> |
|||
<input type="file" ref="mdInput" style="display:none" accept=".md" multiple @change="handleFileSelect($event, 'markdown')"> |
|||
</div> |
|||
<div v-html="fileInfo.markdown" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
|||
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="!fileData.markdown">🚀 上传并向量化</button> |
|||
<div v-html="results.markdown"></div> |
|||
</div> |
|||
|
|||
<!-- JSON 基本 --> |
|||
<div v-show="activeSubTab === 'jsonBasic'"> |
|||
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/basic</code><span style="font-size:12px;color:var(--sub);">(整体解析)</span></div> |
|||
<div class="upload-zone" @click="$refs.jsonBInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonBasic')"> |
|||
<div class="icon">📋</div><p>点击或拖拽上传,支持多文件(JSON .json)</p> |
|||
<input type="file" ref="jsonBInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonBasic')"> |
|||
</div> |
|||
<div v-html="fileInfo.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
|||
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="!fileData.jsonBasic">🚀 上传并向量化</button> |
|||
<div v-html="results.jsonBasic"></div> |
|||
</div> |
|||
|
|||
<!-- JSON 按字段 --> |
|||
<div v-show="activeSubTab === 'jsonFields'"> |
|||
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/fields</code><span style="font-size:12px;color:var(--sub);">(按字段名提取)</span></div> |
|||
<div class="upload-zone" @click="$refs.jsonFInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonFields')"> |
|||
<div class="icon">🔑</div><p>点击或拖拽上传,支持多文件(JSON .json)</p> |
|||
<input type="file" ref="jsonFInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonFields')"> |
|||
</div> |
|||
<div v-html="fileInfo.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
|||
<input class="input" v-model="jsonFieldsStr" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;"> |
|||
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="!fileData.jsonFields">🚀 上传并向量化</button> |
|||
<div v-html="results.jsonFields"></div> |
|||
</div> |
|||
|
|||
<!-- JSON 按指针 --> |
|||
<div v-show="activeSubTab === 'jsonPointer'"> |
|||
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">(JSON Pointer 路径拆分)</span></div> |
|||
<div class="upload-zone" @click="$refs.jsonPInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonPointer')"> |
|||
<div class="icon">📍</div><p>点击或拖拽上传,支持多文件(JSON .json)</p> |
|||
<input type="file" ref="jsonPInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonPointer')"> |
|||
</div> |
|||
<div v-html="fileInfo.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> |
|||
<input class="input" v-model="jsonPointerStr" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;"> |
|||
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="!fileData.jsonPointer">🚀 上传并向量化</button> |
|||
<div v-html="results.jsonPointer"></div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const activeSubTab = ref('file') |
|||
const uploadCategory = ref('') |
|||
const uploadTags = ref('') |
|||
const stringTitle = ref('') |
|||
const stringContent = ref('') |
|||
const jsonFieldsStr = ref('') |
|||
const jsonPointerStr = ref('') |
|||
|
|||
const fileData = reactive({ |
|||
file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null |
|||
}) |
|||
|
|||
const fileInfo = reactive({ |
|||
file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' |
|||
}) |
|||
|
|||
const results = reactive({ |
|||
file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' |
|||
}) |
|||
|
|||
const subTabs = [ |
|||
{ key: 'file', icon: '📎', label: '通用文件' }, |
|||
{ key: 'string', icon: '📝', label: '文本内容' }, |
|||
{ key: 'markdown', icon: '📑', label: 'Markdown' }, |
|||
{ key: 'jsonBasic', icon: '📋', label: 'JSON 基本' }, |
|||
{ key: 'jsonFields', icon: '🔑', label: 'JSON 按字段' }, |
|||
{ key: 'jsonPointer', icon: '📍', label: 'JSON 按指针' } |
|||
] |
|||
|
|||
function handleFileSelect(event, type) { |
|||
const input = event.target |
|||
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 label = fileData[type].length > 1 |
|||
? `已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)})` |
|||
: `已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})` |
|||
fileInfo[type] = label |
|||
} |
|||
|
|||
function handleDrop(event, type) { |
|||
event.currentTarget.classList.remove('drag-over') |
|||
const refMap = { file: 'fileInput', markdown: 'mdInput', jsonBasic: 'jsonBInput', jsonFields: 'jsonFInput', jsonPointer: 'jsonPInput' } |
|||
// 模拟文件选择
|
|||
const dt = new DataTransfer() |
|||
for (const f of event.dataTransfer.files) dt.items.add(f) |
|||
// 通过 handleFileSelect 处理
|
|||
const fakeEvent = { target: { files: dt.files } } |
|||
handleFileSelect(fakeEvent, type) |
|||
} |
|||
|
|||
async function doUpload(type) { |
|||
const catId = uploadCategory.value |
|||
const tagsStr = uploadTags.value.trim() |
|||
|
|||
// 文本内容上传
|
|||
if (type === 'string') { |
|||
if (!stringContent.value.trim()) { |
|||
toast('请输入文本内容', 'error') |
|||
return |
|||
} |
|||
try { |
|||
const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30) |
|||
const json = await uploadString(stringContent.value, title, catId || undefined, tagsStr || undefined) |
|||
if (json.success) { |
|||
results.string = `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">${json.message} | 分块数:<strong>${json.data.chunkCount}</strong> | 状态:<strong>${json.data.status}</strong></div>` |
|||
toast(json.message, 'success') |
|||
store.loadCategories() |
|||
store.loadStats() |
|||
} else { |
|||
results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">${json.message}</div>` |
|||
} |
|||
} catch (e) { |
|||
results.string = `<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') |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 文件类上传
|
|||
const files = fileData[type] |
|||
if (!files || files.length === 0) { |
|||
toast('请先选择文件', 'error') |
|||
return |
|||
} |
|||
|
|||
let successCount = 0, failCount = 0 |
|||
const resultsHtml = [] |
|||
|
|||
for (let i = 0; i < files.length; i++) { |
|||
const file = files[i] |
|||
try { |
|||
const formData = new FormData() |
|||
formData.append('file', file) |
|||
if (catId) formData.append('categoryId', catId) |
|||
if (tagsStr) formData.append('tags', tagsStr) |
|||
|
|||
let json |
|||
switch (type) { |
|||
case 'file': |
|||
json = await uploadFile(formData) |
|||
break |
|||
case 'markdown': |
|||
json = await uploadMarkdown(formData) |
|||
break |
|||
case 'jsonBasic': |
|||
json = await uploadJsonBasic(formData) |
|||
break |
|||
case 'jsonFields': { |
|||
if (!jsonFieldsStr.value.trim()) { |
|||
toast('请输入要提取的字段名', 'error') |
|||
return |
|||
} |
|||
json = await uploadJsonFields(formData, jsonFieldsStr.value.trim()) |
|||
break |
|||
} |
|||
case 'jsonPointer': { |
|||
if (!jsonPointerStr.value.trim()) { |
|||
toast('请输入 JSON Pointer 路径', 'error') |
|||
return |
|||
} |
|||
json = await uploadJsonPointer(formData, jsonPointerStr.value.trim()) |
|||
break |
|||
} |
|||
} |
|||
|
|||
if (json.success) { |
|||
successCount++ |
|||
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${json.data.chunkCount || 0} 分块`) |
|||
} else { |
|||
failCount++ |
|||
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${json.message || '错误'}`) |
|||
} |
|||
} catch (e) { |
|||
failCount++ |
|||
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${e.message}`) |
|||
} |
|||
} |
|||
|
|||
const summary = successCount > 0 |
|||
? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}` |
|||
: `全部失败(${failCount} 个文件)` |
|||
results[type] = `<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(summary, successCount > 0 ? 'success' : 'error') |
|||
|
|||
if (successCount > 0) { |
|||
store.loadCategories() |
|||
store.loadStats() |
|||
} |
|||
} |
|||
|
|||
return { |
|||
activeSubTab, uploadCategory, uploadTags, subTabs, |
|||
stringTitle, stringContent, jsonFieldsStr, jsonPointerStr, |
|||
fileData, fileInfo, results, store, |
|||
handleFileSelect, handleDrop, doUpload, formatBytes |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
/** |
|||
* 🏷️ 商品信息结构化提取面板 |
|||
*/ |
|||
import { ref } from 'vue' |
|||
import { extractProduct } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<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" v-model="content" 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" @click="extract" :disabled="isExtracting">{{ isExtracting ? '⏳ 提取中...' : '🔍 提取商品信息' }}</button> |
|||
<button class="btn btn-outline btn-sm" @click="content=''">清空</button> |
|||
<button class="btn btn-outline btn-sm" @click="fillExample">填入示例</button> |
|||
</div> |
|||
|
|||
<div v-if="result" style="margin-top:16px;"> |
|||
<h3>📊 提取结果</h3> |
|||
<div class="result-grid"> |
|||
<div class="result-item" v-for="f in fields" :key="f.key"> |
|||
<div class="label">{{ f.label }}</div> |
|||
<div class="value">{{ result[f.key] !== null && result[f.key] !== undefined ? result[f.key] : '—' }}</div> |
|||
</div> |
|||
</div> |
|||
<div class="result-json" style="margin-top:12px;">{{ jsonText }}</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const content = ref('') |
|||
const result = ref(null) |
|||
const jsonText = ref('') |
|||
const isExtracting = ref(false) |
|||
|
|||
const fields = [ |
|||
{ key: 'title', label: '商品标题' }, |
|||
{ key: 'description', label: '描述' }, |
|||
{ key: 'price', label: '价格' }, |
|||
{ key: 'rating', label: '评分' }, |
|||
{ key: 'reviewCount', label: '评论数' }, |
|||
{ key: 'brand', label: '品牌' }, |
|||
{ key: 'category', label: '分类' } |
|||
] |
|||
|
|||
function fillExample() { |
|||
content.value = 'Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类' |
|||
} |
|||
|
|||
async function extract() { |
|||
if (!content.value.trim()) { |
|||
toast('请输入商品描述内容', 'error') |
|||
return |
|||
} |
|||
isExtracting.value = true |
|||
try { |
|||
const text = await extractProduct(content.value) |
|||
const data = JSON.parse(text) |
|||
result.value = data |
|||
jsonText.value = JSON.stringify(data, null, 2) |
|||
toast('商品信息提取成功!', 'success') |
|||
} catch (e) { |
|||
toast('提取失败:' + e.message, 'error') |
|||
} finally { |
|||
isExtracting.value = false |
|||
} |
|||
} |
|||
|
|||
return { content, result, jsonText, isExtracting, fields, extract, fillExample } |
|||
} |
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
/* ==================== CSS 变量 ==================== */ |
|||
: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:1400px; margin:0 auto; padding:20px; } |
|||
@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; } |
|||
.btn-warn { background:var(--warn); color:#fff; } |
|||
|
|||
/* ==================== 消息区 ==================== */ |
|||
.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fafbfc; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; } |
|||
.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; } |
|||
|
|||
/* ==================== 表格样式 ==================== */ |
|||
.data-table { width:100%; border-collapse:collapse; font-size:13px; } |
|||
.data-table th { background:#f9fafb; padding:10px 12px; text-align:left; font-weight:600; border-bottom:2px solid var(--border); white-space:nowrap; } |
|||
.data-table td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:middle; } |
|||
.data-table tr:hover { background:#f9fafb; } |
|||
.data-table .status-ready { color:var(--success); font-weight:600; } |
|||
.data-table .status-processing { color:var(--warn); font-weight:600; } |
|||
.data-table .status-failed { color:var(--danger); font-weight:600; } |
|||
|
|||
/* ==================== 分页 ==================== */ |
|||
.pagination { display:flex; gap:4px; justify-content:center; margin-top:12px; } |
|||
.pagination button { padding:6px 12px; border:1px solid var(--border); background:#fff; border-radius:6px; cursor:pointer; font-size:12px; } |
|||
.pagination button:hover:not(:disabled) { background:var(--primary); color:#fff; border-color:var(--primary); } |
|||
.pagination button:disabled { opacity:.5; cursor:not-allowed; } |
|||
.pagination button.active { background:var(--primary); color:#fff; border-color:var(--primary); } |
|||
|
|||
/* ==================== 统计卡片 ==================== */ |
|||
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; } |
|||
.stat-card { background:#f9fafb; border-radius:8px; padding:16px; text-align:center; border:1px solid var(--border); } |
|||
.stat-card .number { font-size:28px; font-weight:700; color:var(--primary); } |
|||
.stat-card .label { font-size:12px; color:var(--sub); margin-top:4px; } |
|||
|
|||
/* ==================== 弹窗 ==================== */ |
|||
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:200; display:none; align-items:center; justify-content:center; } |
|||
.modal-overlay.active { display:flex; } |
|||
.modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; } |
|||
.modal-box h2 { margin-bottom:16px; font-size:18px; } |
|||
.modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; } |
|||
.modal-close:hover { color:var(--text); } |
|||
|
|||
/* ==================== 分类标签 ==================== */ |
|||
.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#eef2ff; color:var(--primary); } |
|||
|
|||
/* ==================== 搜索结果 ==================== */ |
|||
.search-result { background:#f9fafb; border-radius:8px; padding:12px; margin-bottom:8px; border:1px solid var(--border); } |
|||
.search-result .score { font-size:12px; color:var(--success); font-weight:600; } |
|||
.search-result .meta { font-size:12px; color:var(--sub); margin-top:4px; } |
|||
.search-result .content { font-size:13px; margin-top:6px; line-height:1.5; } |
|||
|
|||
/* ==================== 端点标签 ==================== */ |
|||
.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; } |
|||
|
|||
/* ==================== 响应式 ==================== */ |
|||
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } } |
|||
1098
src/main/resources/static/frontend.html
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,20 @@ |
|||
<!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> |
|||
<link rel="stylesheet" href="css/main.css"> |
|||
<script type="importmap"> |
|||
{ |
|||
"imports": { |
|||
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js" |
|||
} |
|||
} |
|||
</script> |
|||
</head> |
|||
<body> |
|||
<div id="app"></div> |
|||
<script type="module" src="js/app.js"></script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,259 @@ |
|||
/** |
|||
* 统一 API 请求层 |
|||
* 封装所有后端接口调用,组件只需调用函数处理业务逻辑 |
|||
*/ |
|||
import { API_BASE } from './utils.js' |
|||
|
|||
// ==================== 通用请求 ====================
|
|||
|
|||
/** |
|||
* GET 请求,返回 JSON |
|||
*/ |
|||
async function getJSON(path) { |
|||
const res = await fetch(API_BASE + path) |
|||
return res.json() |
|||
} |
|||
|
|||
/** |
|||
* POST JSON 请求,返回 JSON |
|||
*/ |
|||
async function postJSON(path, body) { |
|||
const res = await fetch(API_BASE + path, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify(body) |
|||
}) |
|||
return res.json() |
|||
} |
|||
|
|||
/** |
|||
* DELETE 请求,返回 JSON |
|||
*/ |
|||
async function deleteJSON(path) { |
|||
const res = await fetch(API_BASE + path, { method: 'DELETE' }) |
|||
return res.json() |
|||
} |
|||
|
|||
/** |
|||
* PUT 请求,返回 JSON |
|||
*/ |
|||
async function putJSON(path, params) { |
|||
let url = API_BASE + path |
|||
if (params) { |
|||
const qs = Object.entries(params) |
|||
.filter(([, v]) => v !== undefined && v !== null && v !== '') |
|||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`) |
|||
.join('&') |
|||
if (qs) url += (url.includes('?') ? '&' : '?') + qs |
|||
} |
|||
const res = await fetch(url, { method: 'PUT' }) |
|||
return res.json() |
|||
} |
|||
|
|||
/** |
|||
* POST FormData 请求,返回 JSON |
|||
*/ |
|||
async function postForm(path, formData) { |
|||
const res = await fetch(API_BASE + path, { |
|||
method: 'POST', |
|||
body: formData |
|||
}) |
|||
return res.json() |
|||
} |
|||
|
|||
// ==================== AI 对话 ====================
|
|||
|
|||
/** |
|||
* 同步对话 |
|||
*/ |
|||
export function chatSync(message, chatId) { |
|||
return fetch(API_BASE + `/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`) |
|||
.then(res => res.text()) |
|||
} |
|||
|
|||
/** |
|||
* RAG 同步对话 |
|||
*/ |
|||
export function chatRagSync(message, chatId, strategy) { |
|||
return fetch(API_BASE + `/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`) |
|||
.then(res => res.text()) |
|||
} |
|||
|
|||
/** |
|||
* 获取 SSE 流式对话 URL |
|||
* @param {'sse'|'sse2'|'sse3'} mode |
|||
* @returns {string} |
|||
*/ |
|||
export function chatSSEUrl(message, chatId, mode) { |
|||
const pathMap = { |
|||
sse: '/ai/assistant_app/chat/sse', |
|||
sse2: '/ai/assistant_app/chat/server_sent_event', |
|||
sse3: '/ai/assistant_app/chat/sse_emitter' |
|||
} |
|||
return API_BASE + `${pathMap[mode]}?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` |
|||
} |
|||
|
|||
// ==================== 商品信息提取 ====================
|
|||
|
|||
/** |
|||
* 提取商品信息 |
|||
*/ |
|||
export function extractProduct(content) { |
|||
return fetch(API_BASE + `/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`) |
|||
.then(res => res.text()) |
|||
} |
|||
|
|||
// ==================== 文档管理 ====================
|
|||
|
|||
/** |
|||
* 文档列表(分页 + 过滤) |
|||
*/ |
|||
export function listDocuments(page = 1, size = 10, categoryId, status) { |
|||
let path = `/document/list?page=${page}&size=${size}` |
|||
if (categoryId) path += `&categoryId=${categoryId}` |
|||
if (status) path += `&status=${status}` |
|||
return getJSON(path) |
|||
} |
|||
|
|||
/** |
|||
* 文档详情 |
|||
*/ |
|||
export function getDocumentDetail(id) { |
|||
return getJSON(`/document/${id}`) |
|||
} |
|||
|
|||
/** |
|||
* 文档分块列表 |
|||
*/ |
|||
export function getDocumentChunks(id) { |
|||
return getJSON(`/document/${id}/chunks`) |
|||
} |
|||
|
|||
/** |
|||
* 删除文档 |
|||
*/ |
|||
export function deleteDocument(id) { |
|||
return deleteJSON(`/document/${id}`) |
|||
} |
|||
|
|||
/** |
|||
* 更新文档元信息 |
|||
*/ |
|||
export function updateDocument(id, title, categoryId, tags) { |
|||
return putJSON(`/document/${id}`, { title, categoryId, tags }) |
|||
} |
|||
|
|||
/** |
|||
* 重新处理文档 |
|||
*/ |
|||
export function reprocessDocument(id) { |
|||
return putJSON(`/document/${id}/reprocess`) |
|||
} |
|||
|
|||
// ==================== 语义搜索 ====================
|
|||
|
|||
/** |
|||
* 语义搜索 |
|||
*/ |
|||
export function searchDocuments(query, topK, similarityThreshold, categoryId) { |
|||
const body = { query, topK, similarityThreshold } |
|||
if (categoryId) body.categoryId = categoryId |
|||
return postJSON('/document/search', body) |
|||
} |
|||
|
|||
// ==================== 统计 ====================
|
|||
|
|||
/** |
|||
* 知识库统计 |
|||
*/ |
|||
export function getStats() { |
|||
return getJSON('/document/stats') |
|||
} |
|||
|
|||
// ==================== 上传 ====================
|
|||
|
|||
/** |
|||
* 上传普通文件 |
|||
*/ |
|||
export function uploadFile(formData) { |
|||
return postForm('/upload/file', formData) |
|||
} |
|||
|
|||
/** |
|||
* 上传文本内容 |
|||
*/ |
|||
export function uploadString(content, title, categoryId, tags) { |
|||
let url = `/upload/string?title=${encodeURIComponent(title)}` |
|||
if (categoryId) url += `&categoryId=${categoryId}` |
|||
if (tags) url += `&tags=${encodeURIComponent(tags)}` |
|||
return fetch(API_BASE + url, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'text/plain' }, |
|||
body: content |
|||
}).then(res => res.json()) |
|||
} |
|||
|
|||
/** |
|||
* 上传 Markdown |
|||
*/ |
|||
export function uploadMarkdown(formData) { |
|||
return postForm('/upload/markdown', formData) |
|||
} |
|||
|
|||
/** |
|||
* 上传 JSON(基本方式) |
|||
*/ |
|||
export function uploadJsonBasic(formData) { |
|||
return postForm('/upload/json/basic', formData) |
|||
} |
|||
|
|||
/** |
|||
* 上传 JSON(按字段提取) |
|||
*/ |
|||
export function uploadJsonFields(formData, fields) { |
|||
return postForm(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData) |
|||
} |
|||
|
|||
/** |
|||
* 上传 JSON(按指针拆分) |
|||
*/ |
|||
export function uploadJsonPointer(formData, pointer) { |
|||
return postForm(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData) |
|||
} |
|||
|
|||
// ==================== 分类 ====================
|
|||
|
|||
/** |
|||
* 分类树 |
|||
*/ |
|||
export function getCategoryTree() { |
|||
return getJSON('/category/tree') |
|||
} |
|||
|
|||
/** |
|||
* 分类列表 |
|||
*/ |
|||
export function getCategoryList() { |
|||
return getJSON('/category/list') |
|||
} |
|||
|
|||
/** |
|||
* 创建分类 |
|||
*/ |
|||
export function createCategory(data) { |
|||
return postJSON('/category', data) |
|||
} |
|||
|
|||
/** |
|||
* 更新分类 |
|||
*/ |
|||
export function updateCategory(id, data) { |
|||
return putJSON(`/category/${id}`, data) |
|||
} |
|||
|
|||
/** |
|||
* 删除分类 |
|||
*/ |
|||
export function deleteCategory(id) { |
|||
return deleteJSON(`/category/${id}`) |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
/** |
|||
* Vue 应用入口 |
|||
* 组合所有组件,管理 Tab 切换和顶层布局 |
|||
*/ |
|||
import { createApp, ref } from 'vue' |
|||
import { store } from './store.js' |
|||
|
|||
// 导入组件
|
|||
import ChatPanel from '../components/ChatPanel.js' |
|||
import ProductPanel from '../components/ProductPanel.js' |
|||
import DocStats from '../components/DocStats.js' |
|||
import DocSearch from '../components/DocSearch.js' |
|||
import CategoryManager from '../components/CategoryManager.js' |
|||
import DocList from '../components/DocList.js' |
|||
import DocUpload from '../components/DocUpload.js' |
|||
import DocDetail from '../components/DocDetail.js' |
|||
|
|||
const app = createApp({ |
|||
setup() { |
|||
const activeTab = ref('chat') |
|||
|
|||
// 切换 Tab,进入知识库 Tab 时自动加载数据
|
|||
function switchTab(tab) { |
|||
activeTab.value = tab |
|||
if (tab === 'document') { |
|||
store.loadCategories() |
|||
store.loadStats() |
|||
} |
|||
} |
|||
|
|||
return { activeTab, switchTab, store } |
|||
}, |
|||
template: `
|
|||
<!-- 顶部导航 --> |
|||
<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> |
|||
|
|||
<!-- Tab 导航 --> |
|||
<div class="tabs"> |
|||
<button :class="['tab-btn', activeTab === 'chat' ? 'active' : '']" @click="switchTab('chat')"> |
|||
<span class="tab-icon">💬</span>智能客服对话 |
|||
</button> |
|||
<button :class="['tab-btn', activeTab === 'product' ? 'active' : '']" @click="switchTab('product')"> |
|||
<span class="tab-icon">🏷️</span>商品信息提取 |
|||
</button> |
|||
<button :class="['tab-btn', activeTab === 'document' ? 'active' : '']" @click="switchTab('document')"> |
|||
<span class="tab-icon">📄</span>知识库文档管理 |
|||
</button> |
|||
</div> |
|||
|
|||
<!-- 内容区 --> |
|||
<div class="main"> |
|||
<!-- Tab 1: 智能客服对话 --> |
|||
<div v-if="activeTab === 'chat'" style="animation: fadeIn .3s ease;"> |
|||
<chat-panel></chat-panel> |
|||
</div> |
|||
|
|||
<!-- Tab 2: 商品信息提取 --> |
|||
<div v-if="activeTab === 'product'" style="animation: fadeIn .3s ease;"> |
|||
<product-panel></product-panel> |
|||
</div> |
|||
|
|||
<!-- Tab 3: 知识库文档管理 --> |
|||
<div v-if="activeTab === 'document'" style="animation: fadeIn .3s ease;"> |
|||
<doc-stats></doc-stats> |
|||
<doc-search></doc-search> |
|||
<category-manager></category-manager> |
|||
<doc-list></doc-list> |
|||
<doc-upload></doc-upload> |
|||
</div> |
|||
|
|||
<!-- 文档详情弹窗 --> |
|||
<doc-detail></doc-detail> |
|||
</div> |
|||
|
|||
<!-- Toast 容器 --> |
|||
<div class="toast-container" id="toasts"></div> |
|||
`
|
|||
}) |
|||
|
|||
// 注册组件
|
|||
app.component('chat-panel', ChatPanel) |
|||
app.component('product-panel', ProductPanel) |
|||
app.component('doc-stats', DocStats) |
|||
app.component('doc-search', DocSearch) |
|||
app.component('category-manager', CategoryManager) |
|||
app.component('doc-list', DocList) |
|||
app.component('doc-upload', DocUpload) |
|||
app.component('doc-detail', DocDetail) |
|||
|
|||
app.mount('#app') |
|||
@ -0,0 +1,86 @@ |
|||
/** |
|||
* 共享响应式状态 |
|||
* 跨组件共享的分类数据、统计数据、弹窗状态 |
|||
*/ |
|||
import { reactive } from 'vue' |
|||
import * as api from './api.js' |
|||
|
|||
export const store = reactive({ |
|||
// ==================== 分类数据 ====================
|
|||
categories: [], |
|||
categoryMap: {}, |
|||
|
|||
/** |
|||
* 加载分类列表,同步更新 categoryMap |
|||
*/ |
|||
async loadCategories() { |
|||
try { |
|||
const json = await api.getCategoryList() |
|||
if (json.success) { |
|||
this.categories = json.data || [] |
|||
this.categoryMap = {} |
|||
this.categories.forEach(c => { this.categoryMap[c.id] = c.name }) |
|||
} |
|||
} catch (e) { |
|||
console.error('加载分类失败', e) |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* 获取分类名称 |
|||
*/ |
|||
getCategoryName(categoryId) { |
|||
return this.categoryMap[categoryId] || (categoryId && categoryId !== '0' ? '未知' : '未分类') |
|||
}, |
|||
|
|||
// ==================== 统计数据 ====================
|
|||
stats: null, |
|||
|
|||
/** |
|||
* 加载统计数据 |
|||
*/ |
|||
async loadStats() { |
|||
try { |
|||
const json = await api.getStats() |
|||
if (json.success) { |
|||
this.stats = json.data |
|||
} |
|||
} catch (e) { |
|||
console.error('加载统计失败', e) |
|||
} |
|||
}, |
|||
|
|||
// ==================== 文档详情弹窗 ====================
|
|||
detailModal: { |
|||
visible: false, |
|||
doc: null, |
|||
chunks: [] |
|||
}, |
|||
|
|||
/** |
|||
* 打开文档详情弹窗 |
|||
*/ |
|||
async openDetail(docId) { |
|||
try { |
|||
const [detailRes, chunksRes] = await Promise.all([ |
|||
api.getDocumentDetail(docId), |
|||
api.getDocumentChunks(docId) |
|||
]) |
|||
if (!detailRes.success) return |
|||
this.detailModal.doc = detailRes.data |
|||
this.detailModal.chunks = chunksRes.success ? (chunksRes.data || []) : [] |
|||
this.detailModal.visible = true |
|||
} catch (e) { |
|||
console.error('加载文档详情失败', e) |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* 关闭文档详情弹窗 |
|||
*/ |
|||
closeDetail() { |
|||
this.detailModal.visible = false |
|||
this.detailModal.doc = null |
|||
this.detailModal.chunks = [] |
|||
} |
|||
}) |
|||
@ -0,0 +1,99 @@ |
|||
/** |
|||
* 工具函数模块 |
|||
* 提供 Toast 提示、格式化、SSE 流式读取等通用能力 |
|||
*/ |
|||
|
|||
// API 基址:空字符串 = 同源部署,由 Spring Boot 直接服务前端
|
|||
export const API_BASE = '' |
|||
|
|||
// ==================== Toast 提示 ====================
|
|||
|
|||
let _toastContainer = null |
|||
|
|||
/** |
|||
* 获取或创建 Toast 容器 |
|||
*/ |
|||
function getToastContainer() { |
|||
if (!_toastContainer) { |
|||
_toastContainer = document.getElementById('toasts') |
|||
} |
|||
return _toastContainer |
|||
} |
|||
|
|||
/** |
|||
* 显示 Toast 提示 |
|||
* @param {string} msg 消息内容 |
|||
* @param {'info'|'success'|'error'} type 类型 |
|||
*/ |
|||
export function toast(msg, type = 'info') { |
|||
const container = getToastContainer() |
|||
if (!container) return |
|||
const el = document.createElement('div') |
|||
el.className = 'toast ' + type |
|||
el.textContent = msg |
|||
container.appendChild(el) |
|||
setTimeout(() => { |
|||
el.style.opacity = '0' |
|||
el.style.transition = 'opacity .3s' |
|||
setTimeout(() => el.remove(), 300) |
|||
}, 2500) |
|||
} |
|||
|
|||
// ==================== 格式化工具 ====================
|
|||
|
|||
/** |
|||
* 格式化文件大小 |
|||
* @param {number} b 字节数 |
|||
* @returns {string} |
|||
*/ |
|||
export function formatBytes(b) { |
|||
return b < 1024 ? b + ' B' |
|||
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB' |
|||
: (b / 1048576).toFixed(1) + ' MB' |
|||
} |
|||
|
|||
/** |
|||
* 格式化日期 |
|||
* @param {string|Date} d 日期 |
|||
* @returns {string} |
|||
*/ |
|||
export function formatDate(d) { |
|||
if (!d) return '-' |
|||
const date = new Date(d) |
|||
return date.toLocaleString('zh-CN') |
|||
} |
|||
|
|||
// ==================== SSE 流式读取 ====================
|
|||
|
|||
/** |
|||
* 通用 SSE 流式读取 |
|||
* 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口 |
|||
* |
|||
* @param {string} url 请求地址 |
|||
* @param {function(string): void} onChunk 每收到一段文本的回调 |
|||
* @param {function(): void} onDone 流结束的回调 |
|||
*/ |
|||
export async function readSSEStream(url, onChunk, onDone) { |
|||
const res = await fetch(url) |
|||
if (!res.ok) throw new Error('HTTP ' + res.status) |
|||
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]') onChunk(data) |
|||
} else if (line.trim() && !line.startsWith(':')) { |
|||
// Flux<String> 模式,非 SSE 标准格式,直接作为内容
|
|||
onChunk(line) |
|||
} |
|||
} |
|||
} |
|||
if (onDone) onDone() |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue