From 3417709a79bdd3d4053428f5efd485bd219d17ac Mon Sep 17 00:00:00 2001 From: hubian <908234780@qq.com> Date: Sat, 25 Apr 2026 23:05:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E5=AF=B9=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20+=20=E5=8E=86=E5=8F=B2=E5=AF=B9=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增对话列表页面(首页) - 每个对话显示标题、消息数、更新时间 - 对话列表支持删除对话 - 新建对话按钮 - 对话标题自动生成(第一条消息摘要) - 点击对话进入具体对话页面 - 返回按钮回到对话列表 - 数据结构改为 conversations 多对话存储 - 兼容旧数据格式自动转换 --- works/ai-chat-app/www/app.js | 369 +++++++++++++++++++++--------- works/ai-chat-app/www/index.html | 47 +--- works/ai-chat-app/www/style.css | 375 ++++++++++++++++++++----------- 3 files changed, 512 insertions(+), 279 deletions(-) diff --git a/works/ai-chat-app/www/app.js b/works/ai-chat-app/www/app.js index e6921ab..41af9a6 100644 --- a/works/ai-chat-app/www/app.js +++ b/works/ai-chat-app/www/app.js @@ -1,5 +1,5 @@ // AI助手 - 前端应用 -// 使用智谱 GLM-4.5-Air 模型(流式输出) +// 使用智谱 GLM-4.5-Air 模型(流式输出 + 多对话管理) const CONFIG = { apiUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', @@ -8,11 +8,13 @@ const CONFIG = { maxTokens: 2048 }; -// 对话历史 -let messages = []; +// 数据结构 +let conversations = []; // 对话列表 +let currentConversation = null; // 当前对话 let isLoading = false; // DOM 元素 +const appContainer = document.getElementById('app'); const messagesContainer = document.getElementById('messagesContainer'); const messagesDiv = document.getElementById('messages'); const userInput = document.getElementById('userInput'); @@ -21,20 +23,151 @@ const welcome = document.getElementById('welcome'); // 初始化 document.addEventListener('DOMContentLoaded', () => { - // 从本地存储恢复对话 - const saved = localStorage.getItem('chat_history'); + // 从本地存储加载对话列表 + const saved = localStorage.getItem('conversations'); if (saved) { - messages = JSON.parse(saved); - renderMessages(); - if (messages.length > 0) { - welcome.style.display = 'none'; - } + conversations = JSON.parse(saved); } - // 聚焦输入框 - userInput.focus(); + // 显示对话列表页面 + showConversationList(); }); +// ==================== 对话列表页面 ==================== + +function showConversationList() { + currentConversation = null; + + // 渲染对话列表 + const listHtml = ` +
+
+
+ +

AI助手

+
+
+ +
+ + +
+ ${conversations.length === 0 + ? '
暂无对话记录
' + : conversations.map(conv => ` +
+
${escapeHtml(conv.title)}
+
${conv.messages.length} 条消息 · ${formatTime(conv.updatedAt)}
+ +
+ `).join('') + } +
+
+
+ `; + + appContainer.innerHTML = listHtml; +} + +// 创建新对话 +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 = ` +
+
+ +
+ +

${escapeHtml(currentConversation.title)}

+
+ +
+ +
+
+
👋
+

你好!我是AI助手

+

有什么可以帮助你的吗?

+
+ + + +
+
+
+
+ +
+ + +
+
+ `; + + appContainer.innerHTML = chatHtml; + + // 重新获取 DOM 元素 + messagesContainer = document.getElementById('messagesContainer'); + messagesDiv = document.getElementById('messages'); + userInput = document.getElementById('userInput'); + sendBtn = document.getElementById('sendBtn'); + welcome = document.getElementById('welcome'); + + // 渲染消息 + 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'; @@ -57,6 +190,8 @@ function sendQuickMessage(text) { // 发送消息(流式输出) async function sendMessage() { + if (!currentConversation) return; + const text = userInput.value.trim(); if (!text || isLoading) return; @@ -64,33 +199,43 @@ async function sendMessage() { welcome.style.display = 'none'; // 添加用户消息 - messages.push({ role: 'user', content: text }); + 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(messages.length - 1); + await streamGenerate(currentConversation.messages.length - 1); } // 流式生成 AI 回复 async function streamGenerate(userMsgIndex) { - // 显示加载状态 isLoading = true; sendBtn.disabled = true; - // 创建 AI 消息容器(流式填充) - const aiMessageIndex = messages.length; - messages.push({ role: 'assistant', content: '' }); + const aiMessageIndex = currentConversation.messages.length; + currentConversation.messages.push({ role: 'assistant', content: '' }); renderMessages(); - // 获取最后一条消息的 DOM 元素 const lastMessageEl = messagesDiv.lastElementChild; const contentEl = lastMessageEl.querySelector('.message-content'); contentEl.innerHTML = ''; try { - // 调用 API(流式) const response = await fetch(CONFIG.apiUrl, { method: 'POST', headers: { @@ -99,7 +244,7 @@ async function streamGenerate(userMsgIndex) { }, body: JSON.stringify({ model: CONFIG.model, - messages: messages.slice(0, aiMessageIndex).map(m => ({ + messages: currentConversation.messages.slice(0, aiMessageIndex).map(m => ({ role: m.role, content: m.content })), @@ -112,7 +257,6 @@ async function streamGenerate(userMsgIndex) { throw new Error(`API 错误: ${response.status}`); } - // 处理流式响应 const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; @@ -122,8 +266,6 @@ async function streamGenerate(userMsgIndex) { if (done) break; buffer += decoder.decode(value, { stream: true }); - - // 解析 SSE 数据 const lines = buffer.split('\n'); buffer = lines.pop() || ''; @@ -135,8 +277,8 @@ async function streamGenerate(userMsgIndex) { try { const data = JSON.parse(jsonStr); if (data.choices && data.choices[0]?.delta?.content) { - messages[aiMessageIndex].content += data.choices[0].delta.content; - contentEl.innerHTML = renderMarkdown(messages[aiMessageIndex].content) + ''; + currentConversation.messages[aiMessageIndex].content += data.choices[0].delta.content; + contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content) + ''; scrollToBottom(); } } catch (e) {} @@ -144,45 +286,73 @@ async function streamGenerate(userMsgIndex) { } } - // 完成,移除光标 - contentEl.innerHTML = renderMarkdown(messages[aiMessageIndex].content); + contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content); } catch (error) { console.error('Error:', error); - messages[aiMessageIndex].content = `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`; - contentEl.innerHTML = renderMarkdown(messages[aiMessageIndex].content); + currentConversation.messages[aiMessageIndex].content = `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`; + contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content); } finally { isLoading = false; sendBtn.disabled = false; - saveHistory(); - renderMessages(); // 重新渲染显示操作按钮 + currentConversation.updatedAt = Date.now(); + saveConversations(); + renderMessages(); } } // 重新生成 AI 回复 async function regenerate(index) { - if (isLoading || index < 1) return; + if (!currentConversation || isLoading || index < 1) return; - // 找到对应的用户消息(AI消息前一条) const userMsgIndex = index - 1; - if (messages[userMsgIndex].role !== 'user') return; + if (currentConversation.messages[userMsgIndex].role !== 'user') return; - // 删除当前 AI 回复 - messages.splice(index, 1); + 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) { - const content = messages[index].content; + if (!currentConversation) return; + + const content = currentConversation.messages[index].content; navigator.clipboard.writeText(content).then(() => { showToast('已复制到剪贴板'); }).catch(err => { - console.error('复制失败:', err); - // 备用方案 const textarea = document.createElement('textarea'); textarea.value = content; textarea.style.position = 'fixed'; @@ -195,59 +365,28 @@ function copyMessage(index) { }); } -// 显示提示 -function showToast(message) { - const toast = document.createElement('div'); - toast.className = 'toast'; - toast.textContent = message; - document.body.appendChild(toast); +// 清空当前对话 +function clearCurrentChat() { + if (!currentConversation) return; - setTimeout(() => toast.classList.add('show'), 10); - setTimeout(() => { - toast.classList.remove('show'); - setTimeout(() => document.body.removeChild(toast), 300); - }, 2000); -} - -// 删除消息 -function deleteMessage(index) { - if (isLoading) return; - - const msg = messages[index]; - - if (msg.role === 'assistant') { - // 删除 AI 消息时,同时删除前一条用户消息 - if (index > 0 && messages[index - 1].role === 'user') { - messages.splice(index - 1, 2); // 删除两条 - } else { - messages.splice(index, 1); - } - } else { - // 删除用户消息时,同时删除后一条 AI 消息 - if (index < messages.length - 1 && messages[index + 1].role === 'assistant') { - messages.splice(index, 2); - } else { - messages.splice(index, 1); - } - } - - renderMessages(); - saveHistory(); - - // 如果没有消息了,显示欢迎界面 - if (messages.length === 0) { + if (confirm('确定要清空当前对话吗?')) { + currentConversation.messages = []; + currentConversation.updatedAt = Date.now(); + saveConversations(); + renderMessages(); welcome.style.display = 'block'; } } // 渲染消息 function renderMessages() { - messagesDiv.innerHTML = messages.map((msg, index) => { + if (!currentConversation) return; + + messagesDiv.innerHTML = currentConversation.messages.map((msg, index) => { const isUser = msg.role === 'user'; const avatar = isUser ? '👤' : '🤖'; const content = renderMarkdown(msg.content); - // 操作按钮(复制按钮放在最前面) const copyIcon = ``; const actions = isUser @@ -281,14 +420,15 @@ function renderMessages() { scrollToBottom(); } +// ==================== 工具函数 ==================== + // 渲染 Markdown function renderMarkdown(text) { if (!text) return ''; - // 配置 marked marked.setOptions({ - breaks: true, // 支持 GFM 换行 - gfm: true // GitHub Flavored Markdown + breaks: true, + gfm: true }); return marked.parse(text); @@ -296,24 +436,51 @@ function renderMarkdown(text) { // 滚动到底部 function scrollToBottom() { - messagesContainer.scrollTop = messagesContainer.scrollHeight; -} - -// 保存历史 -function saveHistory() { - localStorage.setItem('chat_history', JSON.stringify(messages)); -} - -// 清空对话 -function clearChat() { - if (confirm('确定要清空所有对话记录吗?')) { - messages = []; - messagesDiv.innerHTML = ''; - welcome.style.display = 'block'; - localStorage.removeItem('chat_history'); + 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(() => {}); diff --git a/works/ai-chat-app/www/index.html b/works/ai-chat-app/www/index.html index 48d914a..cc32465 100644 --- a/works/ai-chat-app/www/index.html +++ b/works/ai-chat-app/www/index.html @@ -9,52 +9,7 @@ -
- -
-
- -

AI助手

-
- -
- - -
-
-
👋
-

你好!我是AI助手

-

有什么可以帮助你的吗?

-
- - - -
-
-
-
- - -
- - -
-
- +
diff --git a/works/ai-chat-app/www/style.css b/works/ai-chat-app/www/style.css index d261fd3..18b3636 100644 --- a/works/ai-chat-app/www/style.css +++ b/works/ai-chat-app/www/style.css @@ -25,17 +25,155 @@ body { } #app { + min-height: 100vh; + min-height: 100dvh; +} + +/* ==================== 对话列表页面 ==================== */ + +.conversation-list-page { + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; + background: var(--card-bg); +} + +.list-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: linear-gradient(135deg, var(--primary) 0%, #764ba2 100%); + color: white; + position: sticky; + top: 0; + z-index: 100; +} + +.list-header .header-title { + display: flex; + align-items: center; + gap: 10px; +} + +.list-header .logo { + font-size: 24px; +} + +.list-header h1 { + font-size: 18px; + font-weight: 600; +} + +.list-content { + flex: 1; + padding: 16px; + overflow-y: auto; +} + +.new-chat-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: linear-gradient(135deg, var(--primary) 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 12px; + font-size: 16px; + cursor: pointer; + margin-bottom: 16px; + transition: transform 0.2s; +} + +.new-chat-btn:active { + transform: scale(0.98); +} + +.conversation-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.empty-list { + text-align: center; + padding: 40px; + color: var(--text-light); +} + +.conversation-item { + display: flex; + flex-direction: column; + padding: 12px 16px; + background: white; + border: 1px solid var(--border-color); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.conversation-item:hover { + border-color: var(--primary); + background: rgba(102, 126, 234, 0.05); +} + +.conversation-item:active { + background: rgba(102, 126, 234, 0.1); +} + +.conv-title { + font-size: 16px; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conv-meta { + font-size: 12px; + color: var(--text-light); +} + +.conv-delete-btn { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-light); + padding: 6px; + cursor: pointer; + opacity: 0; + transition: all 0.2s; +} + +.conversation-item:hover .conv-delete-btn { + opacity: 1; +} + +.conv-delete-btn:hover { + color: #e53e3e; +} + +/* ==================== 对话页面 ==================== */ + +#chatPage { display: flex; flex-direction: column; height: 100vh; - height: 100dvh; /* 移动端动态视口高度 */ + height: 100dvh; max-width: 800px; margin: 0 auto; background: var(--card-bg); box-shadow: var(--shadow); } -/* 头部 */ .header { display: flex; justify-content: space-between; @@ -48,19 +186,38 @@ body { z-index: 100; } +.back-btn { + background: transparent; + border: none; + color: white; + padding: 8px; + cursor: pointer; + display: flex; + align-items: center; +} + +.back-btn:active { + opacity: 0.8; +} + .header-title { display: flex; align-items: center; gap: 10px; + flex: 1; + margin-left: 8px; +} + +.header-title h1 { + font-size: 16px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .logo { - font-size: 24px; -} - -.header h1 { - font-size: 18px; - font-weight: 600; + font-size: 20px; } .clear-btn { @@ -70,14 +227,12 @@ body { padding: 8px; color: white; cursor: pointer; - transition: background 0.2s; } .clear-btn:active { background: rgba(255,255,255,0.3); } -/* 消息区域 */ .messages-container { flex: 1; overflow-y: auto; @@ -98,7 +253,6 @@ body { .welcome h2 { font-size: 20px; margin-bottom: 8px; - color: var(--text-color); } .welcome p { @@ -121,7 +275,6 @@ body { border-radius: 20px; font-size: 14px; cursor: pointer; - transition: all 0.2s; } .quick-actions button:active { @@ -216,109 +369,16 @@ body { background: rgba(0,0,0,0.2); } -/* 加载动画 */ -.typing-indicator { - display: flex; - gap: 4px; - padding: 8px 0; +/* 流式输出光标 */ +.streaming-cursor { + animation: blink 1s infinite; + color: var(--primary); + font-weight: bold; } -.typing-indicator span { - width: 8px; - height: 8px; - background: var(--text-light); - border-radius: 50%; - animation: typing 1.4s infinite ease-in-out; -} - -.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } -.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } - -@keyframes typing { - 0%, 60%, 100% { transform: translateY(0); } - 30% { transform: translateY(-8px); } -} - -/* 输入区域 */ -.input-area { - display: flex; - gap: 10px; - padding: 12px 16px; - background: white; - border-top: 1px solid var(--border-color); - position: sticky; - bottom: 0; -} - -#userInput { - flex: 1; - padding: 12px 16px; - border: 2px solid var(--border-color); - border-radius: 24px; - font-size: 16px; - resize: none; - outline: none; - font-family: inherit; - max-height: 120px; - transition: border-color 0.2s; -} - -#userInput:focus { - border-color: var(--primary); -} - -.send-btn { - width: 48px; - height: 48px; - border-radius: 50%; - background: linear-gradient(135deg, var(--primary) 0%, #764ba2 100%); - border: none; - color: white; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s, opacity 0.2s; - flex-shrink: 0; -} - -.send-btn:active { - transform: scale(0.95); -} - -.send-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* 错误提示 */ -.error-msg { - color: #e53e3e; - font-size: 14px; - text-align: center; - padding: 8px; -} - -/* Toast 提示 */ -.toast { - position: fixed; - top: 80px; - left: 50%; - transform: translateX(-50%) translateY(-20px); - background: var(--text-color); - color: white; - padding: 12px 24px; - border-radius: 8px; - font-size: 14px; - opacity: 0; - transition: all 0.3s ease; - z-index: 1000; - box-shadow: 0 4px 12px rgba(0,0,0,0.2); -} - -.toast.show { - opacity: 1; - transform: translateX(-50%) translateY(0); +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } } /* 消息结构 */ @@ -328,24 +388,14 @@ body { gap: 8px; } -/* 用户消息更宽 */ .message.user .message-body { max-width: 85%; } -/* AI 消息稍窄 */ .message.assistant .message-body { max-width: 80%; } -.message.user .message-body { - align-items: flex-end; -} - -.message.assistant .message-body { - align-items: flex-start; -} - /* 消息操作按钮 */ .message-actions { display: flex; @@ -365,7 +415,6 @@ body { padding: 4px 6px; color: var(--text-light); cursor: pointer; - transition: all 0.2s; display: flex; align-items: center; justify-content: center; @@ -451,16 +500,78 @@ body { font-weight: 600; } -/* 流式输出光标 */ -.streaming-cursor { - animation: blink 1s infinite; - color: var(--primary); - font-weight: bold; +/* 输入区域 */ +.input-area { + display: flex; + gap: 10px; + padding: 12px 16px; + background: white; + border-top: 1px solid var(--border-color); + position: sticky; + bottom: 0; } -@keyframes blink { - 0%, 50% { opacity: 1; } - 51%, 100% { opacity: 0; } +#userInput { + flex: 1; + padding: 12px 16px; + border: 2px solid var(--border-color); + border-radius: 24px; + font-size: 16px; + resize: none; + outline: none; + font-family: inherit; + max-height: 120px; + transition: border-color 0.2s; +} + +#userInput:focus { + border-color: var(--primary); +} + +.send-btn { + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, var(--primary) 0%, #764ba2 100%); + border: none; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s, opacity 0.2s; + flex-shrink: 0; +} + +.send-btn:active { + transform: scale(0.95); +} + +.send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Toast 提示 */ +.toast { + position: fixed; + top: 80px; + left: 50%; + transform: translateX(-50%) translateY(-20px); + background: var(--text-color); + color: white; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + opacity: 0; + transition: all 0.3s ease; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); +} + +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); } /* 安全区域适配(刘海屏) */