216 lines
6.9 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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();
|