Files
ai-chat-app/www/app.js

565 lines
18 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.
// AI助手 - 前端应用
// 使用智谱 GLM-4.5-Air 模型(流式输出 + 多对话管理)
const CONFIG = {
apiUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
apiKey: '2259e33a1357460abe17919aaf81e73d.K44a8LPQTmFM5PKm',
model: 'glm-4.5-air',
maxTokens: 2048
};
// 数据结构
let conversations = []; // 对话列表
let currentConversation = null; // 当前对话
let isLoading = false;
// DOM 元素(初始为 null在 openConversation 时重新获取)
let appContainer = null;
let messagesContainer = null;
let messagesDiv = null;
let userInput = null;
let sendBtn = null;
let welcome = null;
// 初始化
document.addEventListener('DOMContentLoaded', () => {
// 初始化 appContainer
appContainer = document.getElementById('app');
// 从本地存储加载对话列表
const saved = localStorage.getItem('conversations');
if (saved) {
conversations = JSON.parse(saved);
}
// 兼容旧数据格式chat_history
const oldHistory = localStorage.getItem('chat_history');
if (oldHistory && conversations.length === 0) {
const oldMessages = JSON.parse(oldHistory);
if (oldMessages.length > 0) {
// 转换旧数据为新格式
const convertedConv = {
id: Date.now().toString(),
title: oldMessages[0].content.slice(0, 30) + (oldMessages[0].content.length > 30 ? '...' : ''),
messages: oldMessages,
createdAt: Date.now(),
updatedAt: Date.now()
};
conversations.push(convertedConv);
saveConversations();
localStorage.removeItem('chat_history'); // 清理旧数据
}
}
// 显示对话列表页面
showConversationList();
});
// ==================== 对话列表页面 ====================
function showConversationList() {
currentConversation = null;
// 渲染对话列表
const listHtml = `
<div class="conversation-list-page">
<header class="list-header">
<div class="header-title">
<span class="logo">🤖</span>
<h1>AI助手</h1>
</div>
<button class="new-chat-btn-header" id="newChatBtn" title="新建对话">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
</header>
<div class="list-content">
<div class="conversation-list" id="conversationList">
${conversations.length === 0
? '<div class="empty-list">暂无对话记录</div>'
: conversations.map(conv => `
<div class="conversation-item" data-id="${conv.id}">
<div class="conv-title">${escapeHtml(conv.title)}</div>
<div class="conv-meta">${conv.messages.length} 条消息 · ${formatTime(conv.updatedAt)}</div>
<button class="conv-delete-btn" data-id="${conv.id}" title="删除对话">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
`).join('')
}
</div>
</div>
</div>
`;
appContainer.innerHTML = listHtml;
// 绑定事件
const newChatBtn = document.getElementById('newChatBtn');
if (newChatBtn) {
newChatBtn.addEventListener('click', createNewConversation);
}
const conversationList = document.getElementById('conversationList');
if (conversationList) {
conversationList.addEventListener('click', (e) => {
const item = e.target.closest('.conversation-item');
const deleteBtn = e.target.closest('.conv-delete-btn');
if (deleteBtn) {
e.stopPropagation();
const id = deleteBtn.getAttribute('data-id');
deleteConversation(id);
} else if (item) {
const id = item.getAttribute('data-id');
openConversation(id);
}
});
}
}
// 创建新对话
function createNewConversation() {
const newConv = {
id: Date.now().toString(),
title: '新对话',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now()
};
conversations.unshift(newConv);
saveConversations();
openConversation(newConv.id);
}
// 打开对话
function openConversation(id) {
currentConversation = conversations.find(c => c.id === id);
if (!currentConversation) {
showConversationList();
return;
}
// 渲染对话页面
const chatHtml = `
<div id="chatPage">
<header class="header">
<button class="back-btn" id="backBtn">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<div class="header-title">
<span class="logo">🤖</span>
<h1>${escapeHtml(currentConversation.title)}</h1>
</div>
<button class="clear-btn" id="clearBtn" title="清空对话">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
</button>
</header>
<div class="messages-container" id="messagesContainer">
<div class="welcome" id="welcome" style="${currentConversation.messages.length > 0 ? 'display:none' : ''}">
<div class="welcome-icon">👋</div>
<h2>你好我是AI助手</h2>
<p>有什么可以帮助你的吗?</p>
<div class="quick-actions">
<button class="quick-btn" data-text="介绍一下你自己">介绍一下你自己</button>
<button class="quick-btn" data-text="帮我写一段代码">帮我写代码</button>
<button class="quick-btn" data-text="解释一个概念">解释概念</button>
</div>
</div>
<div class="messages" id="messages"></div>
</div>
<div class="input-area">
<textarea
id="userInput"
placeholder="输入消息..."
rows="1"
></textarea>
<button class="send-btn" id="sendBtn">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
`;
appContainer.innerHTML = chatHtml;
// 重新获取 DOM 元素
messagesContainer = document.getElementById('messagesContainer');
messagesDiv = document.getElementById('messages');
userInput = document.getElementById('userInput');
sendBtn = document.getElementById('sendBtn');
welcome = document.getElementById('welcome');
// 绑定按钮事件
const backBtn = document.getElementById('backBtn');
if (backBtn) backBtn.addEventListener('click', showConversationList);
const clearBtn = document.getElementById('clearBtn');
if (clearBtn) clearBtn.addEventListener('click', clearCurrentChat);
// 绑定输入事件
userInput.addEventListener('keydown', handleKeyDown);
userInput.addEventListener('input', (e) => autoResize(e.target));
sendBtn.addEventListener('click', sendMessage);
// 绑定快捷按钮事件
document.querySelectorAll('.quick-btn').forEach(btn => {
btn.addEventListener('click', () => {
const text = btn.getAttribute('data-text');
userInput.value = text;
sendMessage();
});
});
// 渲染消息
renderMessages();
userInput.focus();
}
// 删除对话
function deleteConversation(id) {
if (!confirm('确定要删除这个对话吗?')) return;
conversations = conversations.filter(c => c.id !== id);
saveConversations();
showConversationList();
}
// ==================== 对话页面 ====================
// 自动调整输入框高度
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
// 处理键盘事件
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
// 发送快捷消息
function sendQuickMessage(text) {
userInput.value = text;
sendMessage();
}
// 发送消息(流式输出)
async function sendMessage() {
if (!currentConversation) return;
const text = userInput.value.trim();
if (!text || isLoading) return;
// 隐藏欢迎界面
welcome.style.display = 'none';
// 添加用户消息
currentConversation.messages.push({ role: 'user', content: text });
// 更新对话标题(第一条用户消息)
if (currentConversation.title === '新对话') {
currentConversation.title = text.slice(0, 30) + (text.length > 30 ? '...' : '');
// 更新标题显示
const titleEl = document.querySelector('.header h1');
if (titleEl) {
titleEl.textContent = currentConversation.title;
}
}
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
userInput.value = '';
autoResize(userInput);
// 调用流式生成
await streamGenerate(currentConversation.messages.length - 1);
}
// 流式生成 AI 回复
async function streamGenerate(userMsgIndex) {
isLoading = true;
sendBtn.disabled = true;
const aiMessageIndex = currentConversation.messages.length;
currentConversation.messages.push({ role: 'assistant', content: '' });
renderMessages();
const lastMessageEl = messagesDiv.lastElementChild;
const contentEl = lastMessageEl.querySelector('.message-content');
contentEl.innerHTML = '<span class="streaming-cursor">▌</span>';
try {
const response = await fetch(CONFIG.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.apiKey}`
},
body: JSON.stringify({
model: CONFIG.model,
messages: currentConversation.messages.slice(0, aiMessageIndex).map(m => ({
role: m.role,
content: m.content
})),
max_tokens: CONFIG.maxTokens,
stream: true
})
});
if (!response.ok) {
throw new Error(`API 错误: ${response.status}`);
}
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: ')) {
const jsonStr = line.slice(6).trim();
if (jsonStr === '[DONE]') continue;
try {
const data = JSON.parse(jsonStr);
if (data.choices && data.choices[0]?.delta?.content) {
currentConversation.messages[aiMessageIndex].content += data.choices[0].delta.content;
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content) + '<span class="streaming-cursor">▌</span>';
scrollToBottom();
}
} catch (e) {}
}
}
}
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
} catch (error) {
console.error('Error:', error);
currentConversation.messages[aiMessageIndex].content = `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`;
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
} finally {
isLoading = false;
sendBtn.disabled = false;
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
}
}
// 重新生成 AI 回复
async function regenerate(index) {
if (!currentConversation || isLoading || index < 1) return;
const userMsgIndex = index - 1;
if (currentConversation.messages[userMsgIndex].role !== 'user') return;
currentConversation.messages.splice(index, 1);
currentConversation.updatedAt = Date.now();
saveConversations();
await streamGenerate(userMsgIndex);
}
// 删除消息
function deleteMessage(index) {
if (!currentConversation || isLoading) return;
const msg = currentConversation.messages[index];
if (msg.role === 'assistant') {
if (index > 0 && currentConversation.messages[index - 1].role === 'user') {
currentConversation.messages.splice(index - 1, 2);
} else {
currentConversation.messages.splice(index, 1);
}
} else {
if (index < currentConversation.messages.length - 1 && currentConversation.messages[index + 1].role === 'assistant') {
currentConversation.messages.splice(index, 2);
} else {
currentConversation.messages.splice(index, 1);
}
}
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
if (currentConversation.messages.length === 0) {
welcome.style.display = 'block';
}
}
// 复制消息(复制原文)
function copyMessage(index) {
if (!currentConversation) return;
const content = currentConversation.messages[index].content;
navigator.clipboard.writeText(content).then(() => {
showToast('已复制到剪贴板');
}).catch(err => {
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('已复制到剪贴板');
});
}
// 清空当前对话
function clearCurrentChat() {
if (!currentConversation) return;
if (confirm('确定要清空当前对话吗?')) {
currentConversation.messages = [];
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
welcome.style.display = 'block';
}
}
// 渲染消息
function renderMessages() {
if (!currentConversation) return;
// 根据消息数量显示/隐藏欢迎界面
if (welcome) {
welcome.style.display = currentConversation.messages.length > 0 ? 'none' : 'block';
}
messagesDiv.innerHTML = currentConversation.messages.map((msg, index) => {
const isUser = msg.role === 'user';
const avatar = isUser ? '👤' : '🤖';
const content = renderMarkdown(msg.content);
const copyIcon = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
const actions = isUser
? `<div class="message-actions">
<button class="action-btn copy-btn" data-index="${index}" title="复制">${copyIcon}</button>
<button class="action-btn delete-btn" data-index="${index}" title="删除">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`
: `<div class="message-actions">
<button class="action-btn copy-btn" data-index="${index}" title="复制">${copyIcon}</button>
<button class="action-btn regenerate-btn" data-index="${index}" title="重新生成">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</button>
<button class="action-btn delete-btn" data-index="${index}" title="删除">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`;
return `
<div class="message ${msg.role}" data-index="${index}">
<div class="message-avatar">${avatar}</div>
<div class="message-body">
<div class="message-content">${content}</div>
${actions}
</div>
</div>
`;
}).join('');
// 绑定消息操作按钮事件(事件委托)
messagesDiv.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => copyMessage(parseInt(btn.dataset.index)));
});
messagesDiv.querySelectorAll('.regenerate-btn').forEach(btn => {
btn.addEventListener('click', () => regenerate(parseInt(btn.dataset.index)));
});
messagesDiv.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => deleteMessage(parseInt(btn.dataset.index)));
});
scrollToBottom();
}
// ==================== 工具函数 ====================
// 渲染 Markdown
function renderMarkdown(text) {
if (!text) return '';
marked.setOptions({
breaks: true,
gfm: true
});
return marked.parse(text);
}
// 滚动到底部
function scrollToBottom() {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
// 保存对话列表
function saveConversations() {
localStorage.setItem('conversations', JSON.stringify(conversations));
}
// 显示提示
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => document.body.removeChild(toast), 300);
}, 2000);
}
// HTML转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 格式化时间
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前';
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前';
return date.toLocaleDateString('zh-CN');
}
// PWA 注册
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(() => {});
}