705 lines
30 KiB
HTML
Raw Normal View History

<!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>