Files
AI-Chat/assets/js/chat.js
2026-05-06 17:25:06 +08:00

415 lines
14 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;
}
};
// ========================================
// 聊天页面初始化
// ========================================
// 切换侧边栏
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
}
// 供应商/模型数据缓存
let providersData = [];
let personalitiesData = [];
// 加载供应商配置
async function loadProviders() {
try {
const res = await api.get('/config');
providersData = res.data.providers || [];
const select = document.getElementById('providerSelect');
select.innerHTML = '<option value="">选择供应商</option>';
providersData.forEach((p, i) => {
if (p.enabled) {
const option = document.createElement('option');
option.value = i;
option.textContent = p.name;
select.appendChild(option);
}
});
// 如果只有一个供应商,自动选择
if (providersData.filter(p => p.enabled).length === 1) {
select.value = providersData.findIndex(p => p.enabled);
onProviderChange();
}
} catch (err) {
console.error('加载供应商配置失败:', err);
}
}
// 供应商切换时更新模型列表
function onProviderChange() {
const index = document.getElementById('providerSelect').value;
const modelSelect = document.getElementById('modelSelect');
modelSelect.innerHTML = '<option value="">选择模型</option>';
if (index !== '' && providersData[index]) {
const provider = providersData[index];
(provider.models || []).forEach(m => {
const option = document.createElement('option');
option.value = m;
option.textContent = m;
modelSelect.appendChild(option);
});
// 如果只有一个模型,自动选择
if (provider.models && provider.models.length === 1) {
modelSelect.value = provider.models[0];
}
// 如果有默认模型,自动选择
if (provider.defaultModel) {
modelSelect.value = provider.defaultModel;
}
}
}
// 加载人格列表
async function loadPersonalities() {
try {
const res = await api.get('/personalities');
personalitiesData = res.data || [];
const select = document.getElementById('personalitySelect');
select.innerHTML = '<option value="">默认人格</option>';
personalitiesData.forEach(p => {
const option = document.createElement('option');
option.value = p.id;
option.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
select.appendChild(option);
});
} catch (err) {
console.error('加载人格列表失败:', err);
}
}
// 页面初始化
(async function() {
// 检查认证
const token = Storage.getToken();
if (!token) {
window.location.href = '/login.php';
return;
}
// 初始化聊天管理器
ChatManager.init();
// 加载数据
await Promise.all([
loadProviders(),
loadPersonalities(),
SessionManager.loadSessions()
]);
// 如果有会话,自动选择第一个
if (SessionManager.sessions.length > 0) {
await SessionManager.switchSession(SessionManager.sessions[0].id);
}
})();