705 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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