Files
AI-Chat/assets/js/chat.js
2026-05-06 16:45:43 +08:00

309 lines
10 KiB
JavaScript
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.
const ChatManager = {
isStreaming: false,
messages: [],
init() {
const input = document.getElementById('messageInput');
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
this.sendMessage();
}
});
// 自适应高度
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
});
}
const sendBtn = document.getElementById('sendBtn');
if (sendBtn) {
sendBtn.addEventListener('click', () => this.sendMessage());
}
UploadManager.init();
},
async loadMessages(sessionId) {
try {
const res = await api.get('/sessions/' + sessionId + '/messages');
this.messages = res.data || [];
this.renderMessages();
Storage.setCachedMessages(sessionId, this.messages);
} catch (err) {
// 尝试从缓存加载
const cached = Storage.getCachedMessages(sessionId);
if (cached) {
this.messages = cached;
this.renderMessages();
}
}
},
renderMessages() {
const container = document.getElementById('messagesContainer');
if (!container) return;
if (this.messages.length === 0) {
container.innerHTML = '<div class="empty-state"><h3>开始新的对话</h3><p>输入消息开始聊天</p></div>';
return;
}
container.innerHTML = this.messages.map(msg => {
if (msg.role === 'user') {
return `<div class="message user">${this.escapeHtml(msg.content)}</div>`;
} else {
let html = `<div class="message assistant">`;
if (msg.thinking_content) {
html += `<div class="thinking-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">思考过程 ▾</div>`;
html += `<div class="thinking-content">${MarkdownRenderer.render(msg.thinking_content)}</div>`;
}
html += `<div class="markdown-body">${MarkdownRenderer.render(msg.content)}</div>`;
html += `</div>`;
return html;
}
}).join('');
container.scrollTop = container.scrollHeight;
},
async sendMessage() {
if (this.isStreaming) return;
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content) return;
if (!SessionManager.currentSessionId) {
await SessionManager.createSession();
}
// 清空输入
input.value = '';
input.style.height = 'auto';
// 构建消息
const userMessage = { role: 'user', content };
if (UploadManager.getFiles().length > 0) {
userMessage.file_info = UploadManager.getFiles();
}
this.messages.push(userMessage);
this.renderMessages();
// 保存用户消息到数据库
api.post('/sessions/' + SessionManager.currentSessionId + '/messages', userMessage).catch(console.error);
// 清除文件
UploadManager.clearFiles();
// 获取当前配置
const provider = document.getElementById('providerSelect')?.value || 'newapi';
const model = document.getElementById('modelSelect')?.value || 'gpt-3.5-turbo';
const thinkingMode = document.getElementById('thinkingMode')?.checked || false;
// SSE 流式请求
this.isStreaming = true;
this.updateSendButton();
try {
await this.streamChat(provider, model, thinkingMode);
} catch (err) {
this.addErrorMessage(err.message);
} finally {
this.isStreaming = false;
this.updateSendButton();
}
},
async streamChat(provider, model, thinkingMode) {
const token = Storage.getToken();
// 构建消息历史(只取 role 和 content
const messages = this.messages.map(m => ({
role: m.role,
content: m.content
}));
// 添加 AI 消息占位
const assistantEl = this.addAssistantPlaceholder();
let fullContent = '';
let thinkingContent = '';
const response = await fetch('/api/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({
provider, model, messages, stream: true, thinkingMode
})
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'AI 请求失败');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') {
continue;
}
try {
const parsed = JSON.parse(data);
if (parsed.type === 'error') {
throw new Error(parsed.message);
}
if (parsed.thinking) {
thinkingContent += parsed.thinking;
this.updateThinkingContent(assistantEl, thinkingContent);
}
if (parsed.content) {
fullContent += parsed.content;
this.updateAssistantContent(assistantEl, fullContent);
}
} catch (e) {
if (e.message && !e.message.includes('JSON')) throw e;
}
}
}
// 流结束,保存 AI 消息
const assistantMessage = {
role: 'assistant',
content: fullContent,
thinking_content: thinkingContent || null
};
this.messages.push(assistantMessage);
// 保存到数据库
api.post('/sessions/' + SessionManager.currentSessionId + '/messages', assistantMessage).catch(console.error);
// 缓存到 localStorage
Storage.setCachedMessages(SessionManager.currentSessionId, this.messages);
// 移除打字机光标
this.removeTypingCursor(assistantEl);
},
addAssistantPlaceholder() {
const container = document.getElementById('messagesContainer');
const empty = container.querySelector('.empty-state');
if (empty) empty.remove();
const el = document.createElement('div');
el.className = 'message assistant';
el.innerHTML = '<span class="typing-cursor"></span>';
container.appendChild(el);
container.scrollTop = container.scrollHeight;
return el;
},
updateAssistantContent(el, content) {
const cursor = el.querySelector('.typing-cursor');
const body = el.querySelector('.markdown-body');
if (body) {
body.innerHTML = MarkdownRenderer.render(content);
if (cursor) body.appendChild(cursor);
} else {
const thinking = el.querySelector('.thinking-content, .thinking-toggle');
const md = document.createElement('div');
md.className = 'markdown-body';
md.innerHTML = MarkdownRenderer.render(content);
if (cursor) md.appendChild(cursor);
if (thinking) {
el.appendChild(md);
} else {
el.innerHTML = '';
el.appendChild(md);
}
}
el.parentElement.scrollTop = el.parentElement.scrollHeight;
},
updateThinkingContent(el, content) {
let toggle = el.querySelector('.thinking-toggle');
let container = el.querySelector('.thinking-content');
if (!toggle) {
toggle = document.createElement('div');
toggle.className = 'thinking-toggle expanded';
toggle.textContent = '思考过程 ▾';
toggle.onclick = function() {
container.classList.toggle('expanded');
this.textContent = container.classList.contains('expanded') ? '思考过程 ▴' : '思考过程 ▾';
};
el.insertBefore(toggle, el.firstChild);
}
if (!container) {
container = document.createElement('div');
container.className = 'thinking-content expanded';
el.insertBefore(container, toggle.nextSibling);
}
container.innerHTML = MarkdownRenderer.render(content);
el.parentElement.scrollTop = el.parentElement.scrollHeight;
},
removeTypingCursor(el) {
const cursor = el.querySelector('.typing-cursor');
if (cursor) cursor.remove();
},
addErrorMessage(message) {
const container = document.getElementById('messagesContainer');
const el = document.createElement('div');
el.className = 'message assistant';
el.innerHTML = `<div class="alert alert-error">错误: ${this.escapeHtml(message)} <button class="btn btn-sm btn-secondary" onclick="ChatManager.retryLast()">重试</button></div>`;
container.appendChild(el);
container.scrollTop = container.scrollHeight;
},
clearMessages() {
const container = document.getElementById('messagesContainer');
if (container) {
container.innerHTML = '<div class="empty-state"><h3>开始新的对话</h3><p>输入消息开始聊天</p></div>';
}
this.messages = [];
},
updateSendButton() {
const btn = document.getElementById('sendBtn');
if (btn) {
btn.disabled = this.isStreaming;
btn.textContent = this.isStreaming ? '回复中...' : '发送';
}
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};