705 lines
30 KiB
HTML
705 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>语料采集系统 · Corpus Agent</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
|
||
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
|
||
|
||
/* Header */
|
||
.header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: white; padding: 32px 20px; text-align: center; border-radius: 12px; margin-bottom: 24px; }
|
||
.header h1 { font-size: 26px; margin-bottom: 6px; }
|
||
.header p { color: #8899aa; font-size: 13px; }
|
||
.header .version { color: #556; font-size: 11px; margin-top: 4px; }
|
||
|
||
/* Cards */
|
||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||
.card h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
|
||
|
||
/* Login */
|
||
.login-box { text-align: center; padding: 40px; }
|
||
.login-box input { padding: 12px 16px; width: 320px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; margin-bottom: 12px; }
|
||
.login-box input:focus { outline: none; border-color: #4a6cf7; }
|
||
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: 0.2s; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; }
|
||
.btn-primary { background: #4a6cf7; color: white; }
|
||
.btn-primary:hover { background: #3b5de7; }
|
||
.btn-success { background: #10b981; color: white; }
|
||
.btn-success:hover { background: #059669; }
|
||
.btn-danger { background: #ef4444; color: white; }
|
||
.btn-danger:hover { background: #dc2626; }
|
||
.btn-outline { background: transparent; color: #666; border: 1px solid #d0d0d0; }
|
||
.btn-outline:hover { background: #f5f5f5; }
|
||
.btn-sm { padding: 6px 14px; font-size: 12px; }
|
||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||
|
||
.user-info { display: flex; justify-content: space-between; align-items: center; }
|
||
.user-info .badge { background: #e8f0fe; color: #4a6cf7; padding: 4px 12px; border-radius: 20px; font-size: 13px; }
|
||
|
||
/* Stats */
|
||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 16px 0; }
|
||
.stat-card { background: #f8f9fc; border-radius: 8px; padding: 14px; text-align: center; }
|
||
.stat-card .num { font-size: 24px; font-weight: 700; color: #1a1a2e; }
|
||
.stat-card .label { font-size: 11px; color: #8899aa; margin-top: 4px; }
|
||
|
||
/* Sample list */
|
||
.sample-list { max-height: 400px; overflow-y: auto; }
|
||
.sample-item { padding: 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: 0.1s; }
|
||
.sample-item:hover { background: #f8f9fc; }
|
||
.sample-item:last-child { border: none; }
|
||
.sample-item .msg { font-size: 13px; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sample-item .meta { font-size: 11px; color: #999; margin-top: 4px; }
|
||
.sample-item .tag { display: inline-block; background: #e8f0fe; color: #4a6cf7; padding: 2px 8px; border-radius: 10px; font-size: 11px; margin-right: 4px; }
|
||
|
||
/* Tabs */
|
||
.tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
|
||
.tab { padding: 8px 18px; border-radius: 8px; cursor: pointer; font-size: 13px; background: #f0f2f5; color: #666; transition: 0.2s; border: none; }
|
||
.tab.active { background: #4a6cf7; color: white; }
|
||
.tab:hover:not(.active) { background: #e0e2e5; }
|
||
|
||
.hidden { display: none !important; }
|
||
|
||
/* Chat Styles */
|
||
.chat-container { display: flex; flex-direction: column; height: 550px; }
|
||
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; background: #f8f9fc; border-radius: 8px 8px 0 0; border: 1px solid #e8e8e8; border-bottom: none; }
|
||
.chat-message { margin-bottom: 16px; animation: fadeIn 0.2s ease; }
|
||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||
.chat-message .role { font-size: 11px; font-weight: 600; color: #8899aa; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
|
||
.chat-message .role .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||
.chat-message .role .dot.user { background: #4a6cf7; }
|
||
.chat-message .role .dot.assistant { background: #10b981; }
|
||
.chat-message .role .dot.system { background: #f59e0b; }
|
||
.chat-message .bubble { padding: 10px 14px; border-radius: 8px; font-size: 14px; line-height: 1.7; max-width: 85%; }
|
||
.chat-message.user { text-align: right; }
|
||
.chat-message.user .bubble { background: #4a6cf7; color: white; display: inline-block; text-align: left; }
|
||
.chat-message.assistant .bubble { background: white; border: 1px solid #e8e8e8; display: inline-block; }
|
||
.chat-message.system { text-align: center; }
|
||
.chat-message.system .bubble { background: #fef3c7; color: #92400e; font-size: 13px; display: inline-block; max-width: 90%; }
|
||
.chat-message .typing-indicator { display: inline-flex; gap: 4px; padding: 4px 0; }
|
||
.chat-message .typing-indicator span { width: 8px; height: 8px; background: #bbb; border-radius: 50%; animation: typing 1.2s infinite ease-in-out; }
|
||
.chat-message .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||
.chat-message .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||
@keyframes typing { 0%, 80%, 100% { transform: scale(0.6); } 40% { transform: scale(1); } }
|
||
|
||
.chat-input-area { display: flex; gap: 8px; padding: 12px; background: white; border: 1px solid #e8e8e8; border-top: none; border-radius: 0 0 8px 8px; }
|
||
.chat-input-area input { flex: 1; padding: 10px 14px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; }
|
||
.chat-input-area input:focus { outline: none; border-color: #4a6cf7; }
|
||
|
||
.chat-controls { display: flex; gap: 8px; align-items: center; }
|
||
.chat-controls select { padding: 8px 10px; border: 1px solid #d0d0d0; border-radius: 6px; font-size: 12px; background: white; color: #555; }
|
||
.chat-rounds { font-size: 11px; color: #999; text-align: center; padding: 6px; }
|
||
|
||
/* Preview */
|
||
.preview-area { background: #f8f9fc; border-radius: 8px; padding: 16px; margin: 12px 0; font-size: 13px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
|
||
.preview-area .valuable { border-left: 3px solid #10b981; padding-left: 12px; margin: 8px 0; }
|
||
.preview-area .filtered { border-left: 3px solid #ef4444; padding-left: 12px; margin: 8px 0; opacity: 0.5; }
|
||
|
||
/* Client download */
|
||
.download-section { text-align: center; padding: 10px; }
|
||
.download-section p { color: #666; font-size: 13px; margin: 8px 0; }
|
||
code { background: #f0f2f5; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||
|
||
/* Footer */
|
||
.footer { text-align: center; color: #999; font-size: 11px; padding: 16px 0; }
|
||
|
||
/* Empty state */
|
||
.empty-state { text-align: center; padding: 40px; color: #999; }
|
||
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; }
|
||
::-webkit-scrollbar-thumb:hover { background: #aaa; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container" id="app">
|
||
<!-- Header -->
|
||
<div class="header">
|
||
<h1>🧠 语料采集系统 · Corpus Agent</h1>
|
||
<p>自动采集 · 脱敏 · 格式化 · 对话式语料分析</p>
|
||
<div class="version">v1.1.0 · 国作登字-2026-A-00037559</div>
|
||
</div>
|
||
|
||
<!-- Login -->
|
||
<div class="card" id="login-section">
|
||
<div class="login-box">
|
||
<h2 style="margin-bottom:8px">登录</h2>
|
||
<p style="color:#666; font-size:13px; margin-bottom:8px">
|
||
使用代码仓库账号登录
|
||
</p>
|
||
<input type="text" id="login-user" placeholder="Gitea 账号" style="width:260px" onkeydown="if(event.key==='Enter')login()">
|
||
<br>
|
||
<input type="password" id="login-pass" placeholder="密码" style="width:260px" onkeydown="if(event.key==='Enter')login()">
|
||
<br>
|
||
<button class="btn btn-primary" onclick="login()">登录</button>
|
||
<div id="login-error" style="color:#ef4444;font-size:13px;margin-top:8px;display:none"></div>
|
||
<p style="color:#999; font-size:12px; margin-top:12px">
|
||
与 <a href="https://guanghulab.com/" target="_blank" style="color:#4a6cf7">光湖联邦首页</a> 使用同一套账号
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main -->
|
||
<div id="main-section" class="hidden">
|
||
<!-- User info -->
|
||
<div class="card">
|
||
<div class="user-info">
|
||
<div>
|
||
<strong id="display-name">用户</strong>
|
||
<span class="badge" id="sample-count">0 条语料</span>
|
||
</div>
|
||
<button class="btn btn-sm btn-danger" onclick="logout()">退出</button>
|
||
</div>
|
||
|
||
<div class="stats-grid" id="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-total">0</div>
|
||
<div class="label">语料总数</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-chars">0</div>
|
||
<div class="label">总字符数</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-sources">0</div>
|
||
<div class="label">来源渠道</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="tabs">
|
||
<button class="tab active" onclick="switchTab('collection', this)">📥 实时采集</button>
|
||
<button class="tab" onclick="switchTab('browse', this)">📚 语料浏览</button>
|
||
<button class="tab" onclick="switchTab('client', this)">💻 Mac 客户端</button>
|
||
<button class="tab" onclick="switchTab('chat', this); if(!chatInitialized)initChat()">🤖 语料对话</button>
|
||
</div>
|
||
|
||
<!-- Tab: 实时采集 -->
|
||
<div class="card" id="tab-collection">
|
||
<h2>📥 实时采集</h2>
|
||
<p style="color:#666; font-size:13px; margin-bottom:12px">粘贴对话文本,引擎自动判断价值并脱敏存储</p>
|
||
<textarea id="input-text" style="width:100%;height:120px;padding:12px;border:2px solid #e0e0e0;border-radius:8px;font-size:13px;resize:vertical" placeholder="粘贴聊天内容(技术讨论、踩坑记录、架构决策等会被自动识别)"></textarea>
|
||
<div style="margin-top:12px">
|
||
<button class="btn btn-primary" onclick="collectText()">采集</button>
|
||
<button class="btn btn-outline btn-sm" style="margin-left:8px" onclick="document.getElementById('input-text').value=''">清空</button>
|
||
</div>
|
||
<div id="collect-result" class="hidden preview-area"></div>
|
||
</div>
|
||
|
||
<!-- Tab: 语料浏览 -->
|
||
<div class="card hidden" id="tab-browse">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||
<h2 style="margin:0">📚 语料库</h2>
|
||
<div>
|
||
<button class="btn btn-sm btn-success" onclick="exportCorpus()">📥 导出 JSONL</button>
|
||
</div>
|
||
</div>
|
||
<div id="sample-container">
|
||
<div class="empty-state">
|
||
<div class="icon">📭</div>
|
||
<p>还没有语料,开始采集吧</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: Mac 客户端 -->
|
||
<div class="card hidden" id="tab-client">
|
||
<div class="download-section">
|
||
<h2>💻 Mac 客户端</h2>
|
||
<p>在 Mac 上运行的桌面采集助手,自动截图→OCR→筛选→上传</p>
|
||
|
||
<div style="text-align:left;max-width:600px;margin:20px auto">
|
||
<h3 style="font-size:14px;margin-bottom:12px">📋 安装步骤</h3>
|
||
<ol style="font-size:13px;color:#666;line-height:2">
|
||
<li>下载 <code>mac_client.py</code> 到你的 Mac</li>
|
||
<li>安装依赖:<code>pip3 install pyautogui pillow websocket-client</code></li>
|
||
<li>运行:<code>python3 mac_client.py --token YOUR_TOKEN</code></li>
|
||
<li>选择模式:<code>--mode auto</code>(实时)或 <code>--mode scroll</code>(滚屏)</li>
|
||
</ol>
|
||
|
||
<h3 style="font-size:14px;margin:12px 0 8px">⚙️ 模式说明</h3>
|
||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||
<tr style="border-bottom:1px solid #eee">
|
||
<td style="padding:8px"><code>auto</code> 实时模式</td>
|
||
<td style="padding:8px;color:#666">在后台运行,监控屏幕变化,自动采集</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid #eee">
|
||
<td style="padding:8px"><code>scroll</code> 滚屏模式</td>
|
||
<td style="padding:8px;color:#666">自动滚屏采集历史对话(需手动滚到顶部)</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:8px"><code>clipboard</code> 剪贴板模式</td>
|
||
<td style="padding:8px;color:#666">监听剪贴板,自动采集复制的内容</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
|
||
<p style="color:#999;font-size:12px;margin-top:20px">
|
||
Mac 客户端需要 macOS 12.0+,使用系统原生 OCR
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: 语料对话 -->
|
||
<div class="card hidden" id="tab-chat">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<h2 style="margin:0">🤖 语料助手对话</h2>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<span id="chat-rounds-display" style="font-size:12px;color:#999">0/30 轮</span>
|
||
<button class="btn btn-sm btn-outline" onclick="clearChat()">🗑️ 清空记忆</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-container">
|
||
<div class="chat-messages" id="chat-messages">
|
||
<div class="chat-message system">
|
||
<div class="bubble">
|
||
👋 你好!我是语料助手,基于 DeepSeek/智谱/通义/火山 引擎驱动。<br>
|
||
我可以帮你分析语料、讨论技术方案、解答训练相关的问题。<br>
|
||
<span style="color:#999;font-size:12px">当前使用「铸渊」人格 + DeepSeek 引擎 · 支持30轮连续对话</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-controls" style="padding:8px 12px 0;display:flex;gap:8px;flex-wrap:wrap">
|
||
<select id="chat-persona">
|
||
<option value="zhuyuan">🧠 铸渊 · 执行层</option>
|
||
<option value="shuangyan">🗣️ 霜砚 · 语言层</option>
|
||
</select>
|
||
<select id="chat-engine">
|
||
<option value="deepseek">🔷 DeepSeek V4 Pro</option>
|
||
<option value="zhipu">🟢 智谱 GLM-4-Plus</option>
|
||
<option value="tongyi">🔴 通义 Qwen-Max</option>
|
||
<option value="huoshan">🟠 火山 Doubao-Pro</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="chat-input-area">
|
||
<input type="text" id="chat-input" placeholder="输入你的问题..." onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendChatMessage()}">
|
||
<button class="btn btn-primary" id="chat-send-btn" onclick="sendChatMessage()">发送</button>
|
||
<button class="btn btn-outline btn-sm hidden" id="chat-stop-btn" onclick="stopChatStream()" style="background:#ef4444;color:white;border:none">■ 停止</button>
|
||
</div>
|
||
<div class="chat-rounds" id="chat-rounds-bar">对话将自动保留最近30轮上下文</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div class="footer">
|
||
语料采集系统 v1.1.0 · 铸渊 · ICE-GL-ZY001 · 国作登字-2026-A-00037559
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let token = localStorage.getItem('corpus_token') || '';
|
||
let username = '';
|
||
const API = '';
|
||
let chatInitialized = false;
|
||
let chatAbortController = null;
|
||
let isStreaming = false;
|
||
|
||
// ─── API Helper ─────────────────────────────────────────────
|
||
async function api(method, path, body) {
|
||
const url = `${API}${path}${path.includes('?') ? '&' : '?'}token=${token}`;
|
||
const opts = { method, headers: {'Content-Type': 'application/json'} };
|
||
if (body) opts.body = JSON.stringify(body);
|
||
const resp = await fetch(url, opts);
|
||
return resp.json();
|
||
}
|
||
|
||
// ─── Login ─────────────────────────────────────────────────
|
||
async function login() {
|
||
const user = document.getElementById('login-user').value.trim();
|
||
const pass = document.getElementById('login-pass').value;
|
||
const errEl = document.getElementById('login-error');
|
||
if (!user || !pass) { errEl.textContent = '请输入账号和密码'; errEl.style.display = 'block'; return; }
|
||
errEl.style.display = 'none';
|
||
const r = await fetch(`${API}/api/auth/login/password`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({username: user, password: pass})
|
||
});
|
||
const d = await r.json();
|
||
if (!d.ok) { errEl.textContent = d.detail || '登录失败'; errEl.style.display = 'block'; return; }
|
||
token = d.token;
|
||
username = d.username;
|
||
localStorage.setItem('corpus_token', token);
|
||
document.getElementById('login-pass').value = '';
|
||
document.getElementById('login-section').classList.add('hidden');
|
||
document.getElementById('main-section').classList.remove('hidden');
|
||
document.getElementById('display-name').textContent = username;
|
||
document.getElementById('sample-count').textContent = d.stats.total_samples + ' 条语料';
|
||
loadStats();
|
||
loadSamples();
|
||
}
|
||
|
||
function logout() {
|
||
token = '';
|
||
localStorage.removeItem('corpus_token');
|
||
document.getElementById('login-section').classList.remove('hidden');
|
||
document.getElementById('main-section').classList.add('hidden');
|
||
}
|
||
|
||
// Auto login
|
||
if (token) {
|
||
api('GET', '/api/auth/check').then(d => {
|
||
if (d.ok) {
|
||
username = d.username;
|
||
document.getElementById('login-section').classList.add('hidden');
|
||
document.getElementById('main-section').classList.remove('hidden');
|
||
document.getElementById('display-name').textContent = username;
|
||
loadStats();
|
||
loadSamples();
|
||
} else {
|
||
localStorage.removeItem('corpus_token');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Tabs ──────────────────────────────────────────────────
|
||
function switchTab(name, btn) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('[id^="tab-"]').forEach(t => t.classList.add('hidden'));
|
||
if (btn) btn.classList.add('active');
|
||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||
}
|
||
|
||
// ─── Collect ───────────────────────────────────────────────
|
||
async function collectText() {
|
||
const text = document.getElementById('input-text').value.trim();
|
||
if (!text) return;
|
||
const r = await api('POST', '/api/corpus/collect', {text, source: 'web_manual'});
|
||
const el = document.getElementById('collect-result');
|
||
el.classList.remove('hidden');
|
||
if (r.collected > 0) {
|
||
el.innerHTML = `<div class="valuable">✅ 采集了 ${r.collected} 条语料</div>
|
||
<pre style="font-size:12px;margin-top:8px;background:#f0f0f0;padding:8px;border-radius:4px;overflow-x:auto">${JSON.stringify(r.preview, null, 2)}</pre>`;
|
||
loadStats();
|
||
} else {
|
||
el.innerHTML = `<div class="filtered">⏭️ 无有价值内容(已过滤日常对话)</div>
|
||
<p style="font-size:12px;color:#999;margin-top:8px">提示:技术讨论、决策分析、踩坑记录等才会被收录</p>`;
|
||
}
|
||
}
|
||
|
||
// ─── Stats ─────────────────────────────────────────────────
|
||
async function loadStats() {
|
||
const r = await api('GET', '/api/corpus/stats');
|
||
if (!r.ok) return;
|
||
document.getElementById('stat-total').textContent = r.total_samples || 0;
|
||
document.getElementById('stat-chars').textContent = (r.total_chars || 0).toLocaleString();
|
||
document.getElementById('stat-sources').textContent = Object.keys(r.by_source || {}).length;
|
||
document.getElementById('sample-count').textContent = (r.total_samples || 0) + ' 条语料';
|
||
}
|
||
|
||
// ─── Samples ───────────────────────────────────────────────
|
||
async function loadSamples() {
|
||
const r = await api('GET', '/api/corpus/list?page=1&size=50');
|
||
if (!r.ok) return;
|
||
const container = document.getElementById('sample-container');
|
||
if (r.samples.length === 0) {
|
||
container.innerHTML = `<div class="empty-state"><div class="icon">📭</div><p>还没有语料,开始采集吧</p></div>`;
|
||
return;
|
||
}
|
||
let html = '<div class="sample-list">';
|
||
for (const s of r.samples) {
|
||
const msgs = s.messages || [];
|
||
const first = msgs[0]?.content?.slice(0, 80) || '(空)';
|
||
const tags = (s.tags || []).map(t => `<span class="tag">${t}</span>`).join('');
|
||
const src = s.source || 'unknown';
|
||
html += `<div class="sample-item">
|
||
<div class="msg">${escapeHtml(first)}</div>
|
||
<div class="meta">${tags}<span style="margin-left:8px">来源: ${src}</span></div>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// ─── Export ────────────────────────────────────────────────
|
||
async function exportCorpus() {
|
||
const r = await fetch(`${API}/api/corpus/export?token=${token}&format=jsonl`);
|
||
if (r.ok) {
|
||
const blob = await r.blob();
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `corpus_${username}_${new Date().toISOString().slice(0,10)}.jsonl`;
|
||
a.click();
|
||
}
|
||
}
|
||
|
||
// ─── Chat ──────────────────────────────────────────────────
|
||
let streamBuffer = '';
|
||
|
||
function initChat() {
|
||
if (chatInitialized) return;
|
||
chatInitialized = true;
|
||
loadChatHistory();
|
||
}
|
||
|
||
function addChatMessage(role, content, isHtml) {
|
||
const container = document.getElementById('chat-messages');
|
||
const div = document.createElement('div');
|
||
div.className = 'chat-message ' + role;
|
||
|
||
const roleLabels = { user: '你', assistant: '助手', system: '系统' };
|
||
const dotColors = { user: 'user', assistant: 'assistant', system: 'system' };
|
||
|
||
let contentHtml = isHtml ? content : escapeHtml(content);
|
||
// Convert markdown-style bold and code for assistant messages
|
||
if (role === 'assistant' && !isHtml) {
|
||
contentHtml = contentHtml
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/`(.+?)`/g, '<code style="background:#f0f0f0;padding:1px 4px;border-radius:3px;font-size:13px">$1</code>')
|
||
.replace(/\n/g, '<br>');
|
||
} else if (role === 'user' && !isHtml) {
|
||
contentHtml = contentHtml.replace(/\n/g, '<br>');
|
||
}
|
||
|
||
div.innerHTML = `
|
||
<div class="role"><span class="dot ${dotColors[role]}"></span>${roleLabels[role] || role}</div>
|
||
<div class="bubble">${contentHtml}</div>
|
||
`;
|
||
|
||
container.appendChild(div);
|
||
container.scrollTop = container.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
function showTyping() {
|
||
const container = document.getElementById('chat-messages');
|
||
const div = document.createElement('div');
|
||
div.className = 'chat-message assistant';
|
||
div.id = 'typing-indicator';
|
||
div.innerHTML = `
|
||
<div class="role"><span class="dot assistant"></span>助手</div>
|
||
<div class="bubble">
|
||
<div class="typing-indicator">
|
||
<span></span><span></span><span></span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(div);
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function removeTyping() {
|
||
const el = document.getElementById('typing-indicator');
|
||
if (el) el.remove();
|
||
}
|
||
|
||
async function loadChatHistory() {
|
||
try {
|
||
const r = await api('GET', '/api/corpus/chat/history');
|
||
if (r.ok) {
|
||
document.getElementById('chat-rounds-display').textContent = `${r.rounds}/${r.max_rounds} 轮`;
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function sendChatMessage() {
|
||
if (isStreaming) return;
|
||
|
||
const input = document.getElementById('chat-input');
|
||
const message = input.value.trim();
|
||
if (!message) return;
|
||
|
||
input.value = '';
|
||
isStreaming = true;
|
||
|
||
// Disable send, show stop
|
||
document.getElementById('chat-send-btn').classList.add('hidden');
|
||
document.getElementById('chat-stop-btn').classList.remove('hidden');
|
||
|
||
// Add user message
|
||
addChatMessage('user', message);
|
||
|
||
// Show typing indicator
|
||
showTyping();
|
||
|
||
// Get config
|
||
const persona = document.getElementById('chat-persona').value;
|
||
const engine = document.getElementById('chat-engine').value;
|
||
|
||
// Build SSE URL
|
||
const url = `${API}/api/corpus/chat?token=${token}`;
|
||
|
||
try {
|
||
chatAbortController = new AbortController();
|
||
streamBuffer = '';
|
||
|
||
const resp = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
message: message,
|
||
persona: persona,
|
||
engine: engine
|
||
}),
|
||
signal: chatAbortController.signal
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
removeTyping();
|
||
addChatMessage('system', `❌ 请求失败 (${resp.status})`);
|
||
isStreaming = false;
|
||
document.getElementById('chat-send-btn').classList.remove('hidden');
|
||
document.getElementById('chat-stop-btn').classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Read SSE stream
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let fullText = '';
|
||
let hasContent = false;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
// Process complete SSE events
|
||
while (buffer.includes('\n\n')) {
|
||
const idx = buffer.indexOf('\n\n');
|
||
const event = buffer.slice(0, idx);
|
||
buffer = buffer.slice(idx + 2);
|
||
|
||
if (event.startsWith('data: ')) {
|
||
const dataStr = event.slice(6).trim();
|
||
|
||
if (dataStr === '[DONE]') continue;
|
||
|
||
try {
|
||
const data = JSON.parse(dataStr);
|
||
const type = data.type;
|
||
|
||
if (type === 'persona-loaded') {
|
||
// Remove typing, show persona loaded
|
||
removeTyping();
|
||
hasContent = true;
|
||
// Start a new bubble for the assistant
|
||
addChatMessage('assistant', '');
|
||
streamBuffer = '';
|
||
} else if (type === 'token') {
|
||
const token = data.token || '';
|
||
fullText += token;
|
||
streamBuffer += token;
|
||
|
||
if (hasContent) {
|
||
// Update the last assistant message
|
||
const container = document.getElementById('chat-messages');
|
||
const lastMsg = container.lastElementChild;
|
||
if (lastMsg && lastMsg.classList.contains('assistant')) {
|
||
const bubble = lastMsg.querySelector('.bubble');
|
||
if (bubble) {
|
||
let displayText = fullText
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/`(.+?)`/g, '<code style="background:#f0f0f0;padding:1px 4px;border-radius:3px;font-size:13px">$1</code>')
|
||
.replace(/\n/g, '<br>');
|
||
bubble.innerHTML = displayText;
|
||
}
|
||
}
|
||
} else {
|
||
removeTyping();
|
||
addChatMessage('assistant', token);
|
||
fullText = token;
|
||
streamBuffer = token;
|
||
hasContent = true;
|
||
}
|
||
|
||
// Auto scroll
|
||
const container = document.getElementById('chat-messages');
|
||
container.scrollTop = container.scrollHeight;
|
||
|
||
} else if (type === 'tool-call') {
|
||
addChatMessage('system', `🔧 正在调用工具: ${data.name || data.tool}`);
|
||
} else if (type === 'tool-result') {
|
||
// Tool result, continue
|
||
} else if (type === 'done') {
|
||
// Finished
|
||
if (data.rounds) {
|
||
document.getElementById('chat-rounds-display').textContent = `${data.rounds}/${data.max_rounds || 30} 轮`;
|
||
}
|
||
} else if (type === 'error') {
|
||
removeTyping();
|
||
addChatMessage('system', `⚠️ ${data.message || '未知错误'}`);
|
||
}
|
||
} catch(e) {
|
||
// Skip malformed JSON
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle leftover buffer
|
||
if (buffer.startsWith('data: ')) {
|
||
const dataStr = buffer.slice(6).trim();
|
||
if (dataStr !== '[DONE]') {
|
||
try {
|
||
const data = JSON.parse(dataStr);
|
||
if (data.type === 'token') {
|
||
fullText += data.token || '';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') {
|
||
addChatMessage('system', '⏹️ 对话已停止');
|
||
} else {
|
||
removeTyping();
|
||
addChatMessage('system', `❌ 连接错误: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// Cleanup
|
||
removeTyping();
|
||
isStreaming = false;
|
||
chatAbortController = null;
|
||
document.getElementById('chat-send-btn').classList.remove('hidden');
|
||
document.getElementById('chat-stop-btn').classList.add('hidden');
|
||
}
|
||
|
||
function stopChatStream() {
|
||
if (chatAbortController) {
|
||
chatAbortController.abort();
|
||
}
|
||
}
|
||
|
||
async function clearChat() {
|
||
if (!confirm('确定清空对话记忆吗?')) return;
|
||
const r = await api('POST', '/api/corpus/chat/clear');
|
||
if (r.ok) {
|
||
// Clear messages
|
||
const container = document.getElementById('chat-messages');
|
||
container.innerHTML = `
|
||
<div class="chat-message system">
|
||
<div class="bubble">
|
||
👋 记忆已清空,开始新的对话吧!<br>
|
||
<span style="color:#999;font-size:12px">当前使用「铸渊」人格 + DeepSeek 引擎 · 支持30轮连续对话</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.getElementById('chat-rounds-display').textContent = '0/30 轮';
|
||
}
|
||
}
|
||
|
||
// ─── Utilities ─────────────────────────────────────────────
|
||
function escapeHtml(s) {
|
||
if (typeof s !== 'string') s = String(s || '');
|
||
const d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|