You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
684 lines
35 KiB
684 lines
35 KiB
<!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>
|