nianjie/dialog/static/script.js
2026-01-11 18:52:11 +08:00

216 lines
6.9 KiB
JavaScript

const chatLog = document.getElementById('chatLog');
const userInput = document.getElementById('userInput');
const sendBtn = document.getElementById('sendBtn');
const statusIndicator = document.getElementById('statusIndicator');
const conversationList = document.getElementById('conversationList');
const newChatBtn = document.getElementById('newChat');
const toggleSidebarBtn = document.getElementById('toggleSidebar');
let currentConversationId = null;
let isStreaming = false;
function setStatus(text) { statusIndicator.textContent = text; }
function clearChat() { chatLog.innerHTML = ''; }
const loaderHTML = `
<span class="loader-wrapper">
<span class="letter-wrapper">
<span class="loader-letter">搜</span>
<span class="loader-letter">索</span>
<span class="loader-letter">中</span>
<span class="loader-letter">.</span>
<span class="loader-letter">.</span>
<span class="loader-letter">.</span>
</span>
</span>`;
const doneHTML = `<span class="search-done">搜索完成</span>`;
const escapeHTML = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const withBr = (s) => escapeHTML(s).replace(/\n/g, '<br>');
function normalizeText(text) {
if (!text) return '';
// 去掉首尾空白,压缩多余空行
let t = text.replace(/^\s+/, '').replace(/\s+$/, '');
t = t.replace(/\n{3,}/g, '\n\n'); // 连续3行以上压缩为1个空行
return t;
}
function setAssistantText(bubble, text, position = 'before') {
const cls = position === 'after' ? '.assistant-after' : '.assistant-before';
const el = bubble.querySelector(cls);
if (el) el.innerHTML = withBr(normalizeText(text || ''));
}
function setToolState(bubble, state) {
const el = bubble.querySelector('.assistant-tool');
if (!el) return;
if (state === 'searching') {
el.innerHTML = `<div class="search-state loading">${loaderHTML}</div>`;
} else if (state === 'done') {
el.innerHTML = `<div class="search-state done">${doneHTML}</div>`;
} else {
el.innerHTML = '';
}
}
function createBubble(role, content = '') {
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant') {
div.innerHTML = '<div class="assistant-before"></div><div class="assistant-tool"></div><div class="assistant-after"></div>';
setAssistantText(div, content, 'before');
} else {
div.textContent = content;
}
chatLog.appendChild(div);
chatLog.scrollTop = chatLog.scrollHeight;
return div;
}
async function loadConversations() {
const res = await fetch('/api/conversations');
const data = await res.json();
conversationList.innerHTML = '';
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item.title;
li.dataset.id = item.id;
if (item.id === currentConversationId) li.classList.add('active');
li.onclick = () => loadConversation(item.id);
conversationList.appendChild(li);
});
}
async function loadConversation(id) {
const res = await fetch(`/api/conversations/${id}`);
if (!res.ok) return;
const data = await res.json();
currentConversationId = data.id;
clearChat();
const msgs = data.messages || [];
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i];
if (msg.role === 'tool') continue;
// 处理带 tool_calls 的助手消息:组合“前置文字 + 搜索状态 + 完成后文字”
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
let beforeText = msg.content || '';
let afterText = '';
// 查找后续的 tool 结果和下一条 assistant 回复
let j = i + 1;
while (j < msgs.length && msgs[j].role === 'tool') j++;
if (j < msgs.length && msgs[j].role === 'assistant' && !msgs[j].tool_calls) {
afterText = msgs[j].content || '';
// 跳过已消费的助手消息
i = j;
}
const bubble = createBubble('assistant', beforeText);
setToolState(bubble, 'done');
setAssistantText(bubble, afterText, 'after');
continue;
}
createBubble(msg.role, msg.content || '');
}
await loadConversations();
}
async function sendMessage() {
const text = userInput.value.trim();
if (!text || isStreaming) return;
isStreaming = true;
sendBtn.disabled = true;
userInput.value = '';
createBubble('user', text);
const assistantBubble = createBubble('assistant', '');
setStatus('生成中');
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversation_id: currentConversationId, message: text })
});
if (!response.ok || !response.body) throw new Error('请求失败');
const newId = response.headers.get('X-Conversation-Id');
if (newId) currentConversationId = newId;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let beforeText = '';
let afterText = '';
let lineBuffer = '';
let toolStarted = false;
let toolFinished = false;
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
lineBuffer += chunk;
while (true) {
const nl = lineBuffer.indexOf('\n');
if (nl === -1) break;
const line = lineBuffer.slice(0, nl);
lineBuffer = lineBuffer.slice(nl + 1);
if (!line.trim()) continue;
let evt;
try { evt = JSON.parse(line); } catch { continue; }
if (evt.type === 'assistant_delta') {
if (!toolStarted || !toolFinished) {
beforeText += evt.delta || '';
setAssistantText(assistantBubble, beforeText, 'before');
} else {
afterText += evt.delta || '';
setAssistantText(assistantBubble, afterText, 'after');
}
} else if (evt.type === 'tool_call_start') {
toolStarted = true;
setToolState(assistantBubble, 'searching');
} else if (evt.type === 'tool_result') {
toolFinished = true;
setToolState(assistantBubble, 'done');
}
}
chatLog.scrollTop = chatLog.scrollHeight;
}
} catch (err) {
setAssistantText(assistantBubble, '出错了,请重试');
setToolState(assistantBubble, null);
console.error(err);
} finally {
isStreaming = false;
sendBtn.disabled = false;
setStatus('空闲');
await loadConversations();
}
}
sendBtn.addEventListener('click', sendMessage);
userInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
newChatBtn.addEventListener('click', () => {
currentConversationId = null;
clearChat();
setStatus('空闲');
userInput.focus();
Array.from(conversationList.children).forEach(li => li.classList.remove('active'));
});
toggleSidebarBtn.addEventListener('click', () => {
document.querySelector('.sidebar').classList.toggle('open');
});
// 初始加载
loadConversations();