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

2470 lines
86 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,
// Tavily Search API
tavilyApiUrl: 'https://api.tavily.com/search',
tavilyApiKey: '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'
};
// 数据结构
let conversations = []; // 对话列表
let currentConversation = null; // 当前对话
let isLoading = false;
// 当前页面状态
let currentPage = 'chats'; // chats | agents | profile
// 智能体数据
let agents = [
// 热门智能体
{ id: 'assistant', name: '通用助手', avatar: '🤖', category: 'hot', desc: '能回答各类问题,帮助写作、分析、解答疑惑', systemPrompt: '你是一个智能助手,能够回答各类问题,帮助用户解决问题。' },
{ id: 'writer', name: '写作助手', avatar: '✍️', category: 'hot', desc: '专注于文章写作、文案创作、内容润色', systemPrompt: '你是一个专业的写作助手,擅长各类文章写作、文案创作和内容润色。' },
{ id: 'coder', name: '编程助手', avatar: '👨‍💻', category: 'hot', desc: '精通编程语言,解答技术问题,生成代码', systemPrompt: '你是一个专业的编程助手,精通各类编程语言,能够解答技术问题并生成高质量代码。' },
{ id: 'translator', name: '翻译助手', avatar: '🌐', category: 'hot', desc: '多语言翻译,精准表达,文化适配', systemPrompt: '你是一个专业的翻译助手,精通多语言翻译,能够精准表达并适配文化差异。' },
// 工作助手
{ id: 'assistant-work', name: '工作助手', avatar: '💼', category: 'work', desc: '职场问题解答,工作效率提升', systemPrompt: '你是一个工作助手,帮助解决职场问题,提升工作效率。' },
{ id: 'ppt', name: 'PPT助手', avatar: '📊', category: 'work', desc: 'PPT内容生成结构优化设计建议', systemPrompt: '你是一个PPT助手擅长PPT内容生成、结构优化和设计建议。' },
{ id: 'excel', name: 'Excel助手', avatar: '📈', category: 'work', desc: 'Excel公式、数据分析、表格优化', systemPrompt: '你是一个Excel助手精通Excel公式、数据分析和表格优化。' },
// 学习助手
{ id: 'teacher', name: '学习助手', avatar: '📚', category: 'study', desc: '知识讲解,学习方法,考试辅导', systemPrompt: '你是一个学习助手,擅长知识讲解、学习方法指导和考试辅导。' },
{ id: 'english', name: '英语助手', avatar: '🔤', category: 'study', desc: '英语学习,语法纠正,口语练习', systemPrompt: '你是一个英语助手,帮助英语学习、语法纠正和口语练习。' },
{ id: 'math', name: '数学助手', avatar: '🔢', category: 'study', desc: '数学解题,公式推导,概念讲解', systemPrompt: '你是一个数学助手,擅长数学解题、公式推导和概念讲解。' },
// 生活助手
{ id: 'health', name: '健康助手', avatar: '🏥', category: 'life', desc: '健康咨询,养生建议,运动指导', systemPrompt: '你是一个健康助手,提供健康咨询、养生建议和运动指导。' },
{ id: 'travel', name: '旅行助手', avatar: '✈️', category: 'life', desc: '旅行规划,景点推荐,美食指南', systemPrompt: '你是一个旅行助手,擅长旅行规划、景点推荐和美食指南。' },
{ id: 'food', name: '美食助手', avatar: '🍳', category: 'life', desc: '菜谱推荐,烹饪技巧,营养搭配', systemPrompt: '你是一个美食助手,提供菜谱推荐、烹饪技巧和营养搭配建议。' },
];
let currentAgent = null; // 当前选中的智能体
// 智能体使用历史记录
let agentUsageHistory = [];
// 加载智能体使用历史
function loadAgentUsageHistory() {
const saved = localStorage.getItem('agentUsageHistory');
if (saved) {
agentUsageHistory = JSON.parse(saved);
}
}
// 保存智能体使用历史
function saveAgentUsageHistory() {
localStorage.setItem('agentUsageHistory', JSON.stringify(agentUsageHistory));
}
// 记录智能体使用
function recordAgentUsage(agentId) {
const record = agentUsageHistory.find(r => r.agentId === agentId);
if (record) {
record.usageCount++;
record.lastUsedAt = Date.now();
} else {
agentUsageHistory.push({
agentId: agentId,
usageCount: 1,
lastUsedAt: Date.now()
});
}
saveAgentUsageHistory();
}
// 格式化使用时间
function formatUsageTime(timestamp) {
const now = Date.now();
const diff = now - timestamp;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
const month = 30 * day;
const year = 365 * day;
if (diff < minute) return '刚刚';
if (diff < hour) return Math.floor(diff / minute) + '分钟前';
if (diff < day) return Math.floor(diff / hour) + '小时前';
if (diff < week) return Math.floor(diff / day) + '天前';
if (diff < month) return Math.floor(diff / week) + '周前';
if (diff < year) return Math.floor(diff / month) + '月前';
return Math.floor(diff / year) + '年前';
}
// 获取最近使用的智能体最多5个
function getRecentUsedAgents(limit = 5) {
return agentUsageHistory
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)
.slice(0, limit)
.map(record => {
const agent = agents.find(a => a.id === record.agentId);
return { ...agent, ...record };
})
.filter(a => a.id); // 过滤掉可能被删除的智能体
}
// 功能开关
let enableThinking = false; // 深度思考
let enableSearch = false; // 联网搜索
let autoScrollEnabled = true; // 自动滚动(用户滚动后可关闭)
// DOM 元素(初始为 null在 openConversation 时重新获取)
let appContainer = null;
let messagesContainer = null;
let messagesDiv = null;
let userInput = null;
let sendBtn = null;
let welcome = null;
let thinkingBtn = null;
let searchBtn = null;
// 初始化
document.addEventListener('DOMContentLoaded', () => {
// 初始化 appContainer
appContainer = document.getElementById('app');
// 从本地存储加载对话列表
const saved = localStorage.getItem('conversations');
if (saved) {
conversations = JSON.parse(saved);
}
// 加载智能体使用历史
loadAgentUsageHistory();
// 兼容旧数据格式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'); // 清理旧数据
}
}
// 加载当前页面状态
const savedPage = localStorage.getItem('currentPage');
if (savedPage) {
currentPage = savedPage;
}
// 显示主页
showMainPage();
});
// ==================== 主页(底部导航栏) ====================
function showMainPage() {
currentConversation = null;
currentAgent = null;
const mainHtml = `
<div class="main-page">
<div class="main-content" id="mainContent">
${renderCurrentPage()}
</div>
<!-- 底部导航栏 -->
<nav class="bottom-nav">
<div class="nav-item ${currentPage === 'chats' ? 'active' : ''}" data-page="chats">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>
<span>对话</span>
</div>
<div class="nav-item ${currentPage === 'agents' ? 'active' : ''}" data-page="agents">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>
<span>智能体</span>
</div>
<div class="nav-item ${currentPage === 'profile' ? 'active' : ''}" data-page="profile">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
<span>我的</span>
</div>
</nav>
</div>
`;
appContainer.innerHTML = mainHtml;
// 绑定底部导航事件
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
const page = item.getAttribute('data-page');
switchPage(page);
});
});
// 根据当前页面绑定其他事件
bindPageEvents();
}
// 渲染当前页面内容
function renderCurrentPage() {
switch (currentPage) {
case 'chats':
return renderChatsPage();
case 'agents':
return renderAgentsPage();
case 'profile':
return renderProfilePage();
default:
return renderChatsPage();
}
}
// 切换页面
function switchPage(page) {
currentPage = page;
localStorage.setItem('currentPage', page);
// 更新导航栏状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.getAttribute('data-page') === page);
});
// 更新内容区
const mainContent = document.getElementById('mainContent');
if (mainContent) {
mainContent.innerHTML = renderCurrentPage();
}
// 绑定页面事件
bindPageEvents();
}
// 绑定页面事件
function bindPageEvents() {
switch (currentPage) {
case 'chats':
bindChatsPageEvents();
break;
case 'agents':
bindAgentsPageEvents();
break;
case 'profile':
bindProfilePageEvents();
break;
}
}
// ==================== 对话页面 ====================
function renderChatsPage() {
return `
<div class="chats-page">
<header class="chats-header">
<h1>对话</h1>
<div class="chats-header-actions">
<button class="chats-header-btn" id="searchToggleBtn" title="搜索">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
</button>
<button class="chats-header-btn" 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>
</div>
</header>
<div class="chats-content">
<div class="conversation-list" id="conversationList">
${conversations.length === 0
? '<div class="empty-list">暂无对话记录<br><br>点击右上角 + 开始新对话</div>'
: sortConversations().map(conv => `
<div class="conversation-item ${conv.is_pinned ? 'pinned' : ''}" data-id="${conv.id}">
${conv.is_pinned ? '<span class="pin-icon">📌</span>' : ''}
<div class="conv-title">${escapeHtml(conv.title)}</div>
<div class="conv-meta">${conv.messages.length} 条消息 · ${formatTime(conv.updatedAt)}</div>
</div>
`).join('')
}
</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar" id="searchBar">
<div class="search-input-wrapper">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="searchInput" placeholder="搜索对话标题或内容...">
<button class="search-close-btn" id="searchCloseBtn">
<svg viewBox="0 0 24 24" width="18" height="18"><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>
</div>
<div class="search-results" id="searchResults"></div>
</div>
</div>
`;
}
function bindChatsPageEvents() {
const newChatBtn = document.getElementById('newChatBtn');
if (newChatBtn) {
newChatBtn.addEventListener('click', createNewConversation);
}
// 搜索功能
const searchToggleBtn = document.getElementById('searchToggleBtn');
const searchBar = document.getElementById('searchBar');
const searchInput = document.getElementById('searchInput');
const searchCloseBtn = document.getElementById('searchCloseBtn');
const searchResults = document.getElementById('searchResults');
if (searchToggleBtn) {
searchToggleBtn.addEventListener('click', () => {
if (searchBar) {
searchBar.classList.add('show');
if (searchInput) {
searchInput.focus();
}
}
});
}
if (searchCloseBtn) {
searchCloseBtn.addEventListener('click', () => {
hideSearchBarInChats();
});
}
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const keyword = e.target.value.trim();
if (keyword) {
searchConversations(keyword);
} else {
if (searchResults) searchResults.innerHTML = '';
}
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hideSearchBarInChats();
}
});
}
if (searchResults) {
searchResults.addEventListener('click', (e) => {
const item = e.target.closest('.search-result-item');
if (item) {
const id = item.getAttribute('data-id');
hideSearchBarInChats();
openConversation(id);
}
});
}
function hideSearchBarInChats() {
if (searchBar) {
searchBar.classList.remove('show');
}
if (searchInput) {
searchInput.value = '';
}
if (searchResults) {
searchResults.innerHTML = '';
}
}
const conversationList = document.getElementById('conversationList');
if (conversationList) {
conversationList.addEventListener('click', (e) => {
const item = e.target.closest('.conversation-item');
if (item) {
const id = item.getAttribute('data-id');
openConversation(id);
}
});
// 长按事件
setupLongPressEvents(conversationList);
}
}
// ==================== 智能体页面 ====================
function renderAgentsPage() {
const hotAgents = agents.filter(a => a.category === 'hot');
const workAgents = agents.filter(a => a.category === 'work');
const studyAgents = agents.filter(a => a.category === 'study');
const lifeAgents = agents.filter(a => a.category === 'life');
// 获取最近使用的智能体最多5个
const recentAgents = getRecentUsedAgents(5);
const totalRecentCount = agentUsageHistory.length;
return `
<div class="agents-page">
<header class="page-header">
<h1>智能体</h1>
</header>
<div class="agents-content">
<!-- 最近使用 -->
${recentAgents.length > 0 ? `
<div class="agents-section">
<div class="section-title-row">
<div class="section-title">🕐 最近使用</div>
${totalRecentCount > 5 ? `
<div class="section-more" id="showAllRecentBtn">更多&gt;&gt;</div>
` : ''}
</div>
<div class="recent-agents-list">
${recentAgents.map(agent => `
<div class="recent-agent-item" data-id="${agent.id}">
<div class="recent-agent-left">
<span class="recent-agent-avatar">${agent.avatar}</span>
<span class="recent-agent-name">${agent.name}</span>
</div>
<div class="recent-agent-right">
<span class="recent-agent-usage">${agent.usageCount}次</span>
<span class="recent-agent-time">${formatUsageTime(agent.lastUsedAt)}</span>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
<!-- 热门智能体 -->
<div class="agents-section">
<div class="section-title">🔥 热门智能体</div>
<div class="agents-grid">
${hotAgents.map(agent => `
<div class="agent-card" data-id="${agent.id}">
<div class="agent-avatar">${agent.avatar}</div>
<div class="agent-name">${agent.name}</div>
<div class="agent-desc">${agent.desc}</div>
</div>
`).join('')}
</div>
</div>
<!-- 工作助手 -->
<div class="agents-section">
<div class="section-title">💼 工作助手</div>
<div class="agents-grid">
${workAgents.map(agent => `
<div class="agent-card" data-id="${agent.id}">
<div class="agent-avatar">${agent.avatar}</div>
<div class="agent-name">${agent.name}</div>
<div class="agent-desc">${agent.desc}</div>
</div>
`).join('')}
</div>
</div>
<!-- 学习助手 -->
<div class="agents-section">
<div class="section-title">📚 学习助手</div>
<div class="agents-grid">
${studyAgents.map(agent => `
<div class="agent-card" data-id="${agent.id}">
<div class="agent-avatar">${agent.avatar}</div>
<div class="agent-name">${agent.name}</div>
<div class="agent-desc">${agent.desc}</div>
</div>
`).join('')}
</div>
</div>
<!-- 生活助手 -->
<div class="agents-section">
<div class="section-title">🏠 生活助手</div>
<div class="agents-grid">
${lifeAgents.map(agent => `
<div class="agent-card" data-id="${agent.id}">
<div class="agent-avatar">${agent.avatar}</div>
<div class="agent-name">${agent.name}</div>
<div class="agent-desc">${agent.desc}</div>
</div>
`).join('')}
</div>
</div>
</div>
</div>
`;
}
function bindAgentsPageEvents() {
// 智能体卡片点击
document.querySelectorAll('.agent-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.getAttribute('data-id');
openAgent(id);
});
});
// 最近使用智能体点击
document.querySelectorAll('.recent-agent-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.getAttribute('data-id');
openAgent(id);
});
});
// 查看全部历史使用
const showAllRecentBtn = document.getElementById('showAllRecentBtn');
if (showAllRecentBtn) {
showAllRecentBtn.addEventListener('click', () => {
showAgentHistoryPage();
});
}
}
// ==================== 我的页面 ====================
function renderProfilePage() {
return `
<div class="profile-page">
<header class="page-header">
<h1>我的</h1>
</header>
<div class="profile-content">
<div class="profile-card">
<div class="profile-avatar">👤</div>
<div class="profile-name">用户</div>
</div>
<div class="profile-section">
<div class="profile-item" id="settingsBtn">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.44.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span>设置</span>
</div>
<div class="profile-item" id="aboutBtn">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
<span>关于</span>
</div>
<div class="profile-item" id="clearDataBtn">
<svg viewBox="0 0 24 24" width="20" height="20"><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>
<span>清除数据</span>
</div>
</div>
<div class="profile-footer">
<p>AI助手 v2.8.0</p>
<p>基于智谱 GLM-4.5-Air</p>
</div>
</div>
</div>
`;
}
function bindProfilePageEvents() {
const clearDataBtn = document.getElementById('clearDataBtn');
if (clearDataBtn) {
clearDataBtn.addEventListener('click', () => {
if (confirm('确定要清除所有数据吗?这将删除所有对话记录。')) {
localStorage.clear();
conversations = [];
showToast('数据已清除');
showMainPage();
}
});
}
}
// 查看全部历史使用
function showAgentHistoryPage() {
const allRecentAgents = getRecentUsedAgents(100); // 获取所有
const historyHtml = `
<div class="agent-history-page">
<header class="page-header">
<button class="back-btn-white" id="historyBackBtn">
<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>
<h1>历史使用</h1>
</header>
<div class="agent-history-content">
${allRecentAgents.length === 0
? '<div class="empty-list">暂无历史使用记录</div>'
: allRecentAgents.map(agent => `
<div class="agent-history-item" data-id="${agent.id}">
<div class="agent-history-left">
<span class="agent-history-avatar">${agent.avatar}</span>
<span class="agent-history-name">${agent.name}</span>
</div>
<div class="agent-history-right">
<span class="agent-history-usage">${agent.usageCount}次</span>
<span class="agent-history-time">${formatUsageTime(agent.lastUsedAt)}</span>
</div>
</div>
`).join('')
}
</div>
</div>
`;
appContainer.innerHTML = historyHtml;
// 绑定返回按钮
const backBtn = document.getElementById('historyBackBtn');
if (backBtn) {
backBtn.addEventListener('click', () => {
showMainPage();
});
}
// 绑定智能体点击
document.querySelectorAll('.agent-history-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.getAttribute('data-id');
openAgent(id);
});
});
}
// 打开智能体对话
function openAgent(agentId) {
currentAgent = agents.find(a => a.id === agentId);
if (!currentAgent) return;
// 记录智能体使用历史
recordAgentUsage(agentId);
// 创建新对话并设置智能体
createNewConversation();
currentConversation.agentId = agentId;
currentConversation.title = currentAgent.name;
saveConversations();
// 显示对话界面
showAgentChatPage();
}
// 显示智能体对话界面
function showAgentChatPage() {
if (!currentAgent || !currentConversation) return;
const chatHtml = `
<div class="chat-page">
<header class="chat-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-info">
<div class="agent-avatar-header">${currentAgent.avatar}</div>
<div class="header-text">
<h1>${currentAgent.name}</h1>
<p class="agent-desc-header">${currentAgent.desc}</p>
</div>
</div>
</header>
<div class="messages-container" id="messagesContainer">
<div class="welcome" id="welcome">
<div class="welcome-icon">${currentAgent.avatar}</div>
<h2>${currentAgent.name}</h2>
<p>${currentAgent.desc}</p>
<p class="welcome-tip">有什么可以帮你的吗?</p>
</div>
<div class="messages" id="messages"></div>
</div>
<!-- 功能开关栏 -->
<div class="feature-bar" id="featureBar">
<div class="feature-left">
<button class="feature-btn thinking-btn" id="thinkingBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>深度思考</span>
</button>
<button class="feature-btn search-btn" id="searchBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
<span>联网搜索</span>
</button>
</div>
<div class="feature-right">
<button class="feature-btn nav-btn" id="scrollTopBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>
</button>
<button class="feature-btn nav-btn" id="scrollBottomBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
</button>
</div>
</div>
<div class="input-area">
<button class="attach-btn" id="attachBtn" title="上传文件">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
<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 class="attach-panel" id="attachPanel">
<div class="attach-panel-content">
<div class="attach-item" data-type="image">
<div class="attach-icon">📷</div>
<div class="attach-label">上传图片</div>
</div>
<div class="attach-item" data-type="file">
<div class="attach-icon">📄</div>
<div class="attach-label">上传文件</div>
</div>
</div>
</div>
<input type="file" id="imageInput" accept="image/*" style="display:none">
<input type="file" id="fileInput" accept=".txt,.md,.pdf,.doc,.docx,.json,.csv" style="display:none">
<!-- 搜索栏 -->
<div class="search-bar" id="searchBar">
<div class="search-input-wrapper">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="searchInput" placeholder="搜索对话标题或内容...">
<button class="search-close-btn" id="searchCloseBtn">
<svg viewBox="0 0 24 24" width="18" height="18"><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>
</div>
<div class="search-results" id="searchResults"></div>
</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');
thinkingBtn = document.getElementById('thinkingBtn');
searchBtn = document.getElementById('searchBtn');
// 重置自动滚动状态
autoScrollEnabled = true;
// 设置滚动监听
setupScrollListener();
// 绑定事件
const backBtn = document.getElementById('backBtn');
if (backBtn) {
backBtn.addEventListener('click', () => {
showMainPage();
});
}
// 绑定功能开关按钮事件
if (thinkingBtn) {
thinkingBtn.addEventListener('click', () => {
enableThinking = !enableThinking;
thinkingBtn.classList.toggle('active', enableThinking);
});
}
if (searchBtn) {
searchBtn.addEventListener('click', () => {
enableSearch = !enableSearch;
searchBtn.classList.toggle('active', enableSearch);
});
}
// 绑定输入事件
userInput.addEventListener('keydown', handleKeyDown);
userInput.addEventListener('input', (e) => autoResize(e.target));
sendBtn.addEventListener('click', sendMessage);
// 绑定置顶置底按钮事件
const scrollTopBtn = document.getElementById('scrollTopBtn');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
if (scrollTopBtn) {
scrollTopBtn.addEventListener('click', () => {
if (messagesContainer) {
messagesContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
}
});
}
if (scrollBottomBtn) {
scrollBottomBtn.addEventListener('click', () => {
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: 'smooth'
});
}
});
}
// 绑定上传按钮事件
const attachBtn = document.getElementById('attachBtn');
const attachPanel = document.getElementById('attachPanel');
const imageInput = document.getElementById('imageInput');
const fileInput = document.getElementById('fileInput');
if (attachBtn) {
attachBtn.addEventListener('click', (e) => {
e.stopPropagation();
attachPanel.classList.toggle('show');
});
}
document.addEventListener('click', (e) => {
if (attachPanel && attachPanel.classList.contains('show') &&
!attachPanel.contains(e.target) && !attachBtn.contains(e.target)) {
attachPanel.classList.remove('show');
}
});
attachPanel.querySelectorAll('.attach-item').forEach(item => {
item.addEventListener('click', () => {
const type = item.getAttribute('data-type');
attachPanel.classList.remove('show');
if (type === 'image') {
imageInput.click();
} else if (type === 'file') {
fileInput.click();
}
});
});
imageInput.addEventListener('change', handleImageUpload);
fileInput.addEventListener('change', handleFileUpload);
// 渲染消息
renderMessages();
userInput.focus();
}
// 设置长按事件
function setupLongPressEvents(container) {
let longPressTimer = null;
let currentActionConvId = null;
container.addEventListener('touchstart', (e) => {
const item = e.target.closest('.conversation-item');
if (item) {
longPressTimer = setTimeout(() => {
currentActionConvId = item.getAttribute('data-id');
showActionMenu(currentActionConvId);
}, 500);
}
});
container.addEventListener('touchend', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
container.addEventListener('touchmove', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
container.addEventListener('mousedown', (e) => {
const item = e.target.closest('.conversation-item');
if (item) {
longPressTimer = setTimeout(() => {
currentActionConvId = item.getAttribute('data-id');
showActionMenu(currentActionConvId);
}, 500);
}
});
container.addEventListener('mouseup', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
container.addEventListener('mouseleave', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
}
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>
<div class="header-actions">
<button class="header-btn search-toggle-btn" id="searchToggleBtn" title="搜索">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
</button>
<button class="header-btn 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>
</div>
</header>
<div class="list-content">
<div class="conversation-list" id="conversationList">
${conversations.length === 0
? '<div class="empty-list">暂无对话记录</div>'
: sortConversations().map(conv => `
<div class="conversation-item ${conv.is_pinned ? 'pinned' : ''}" data-id="${conv.id}">
${conv.is_pinned ? '<span class="pin-icon">📌</span>' : ''}
<div class="conv-title">${escapeHtml(conv.title)}</div>
<div class="conv-meta">${conv.messages.length} 条消息 · ${formatTime(conv.updatedAt)}</div>
</div>
`).join('')
}
</div>
</div>
<!-- 操作菜单 -->
<div class="action-menu" id="actionMenu">
<div class="action-menu-content">
<div class="action-menu-item" data-action="rename">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
<span>重命名</span>
</div>
<div class="action-menu-item" data-action="share">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.35 2.92 3 2.92s3-1.31 3-2.92c0-1.61-1.35-2.92-3-2.92z"/></svg>
<span>分享</span>
</div>
<div class="action-menu-item" data-action="pin">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>
<span id="pinText">置顶</span>
</div>
<div class="action-menu-item delete-action" data-action="delete">
<svg viewBox="0 0 24 24" width="18" height="18"><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>
<span>删除</span>
</div>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar" id="searchBar">
<div class="search-input-wrapper">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="searchInput" placeholder="搜索对话标题或内容...">
<button class="search-close-btn" id="searchCloseBtn">
<svg viewBox="0 0 24 24" width="18" height="18"><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>
</div>
<div class="search-results" id="searchResults"></div>
</div>
</div>
</div>
`;
appContainer.innerHTML = listHtml;
// 绑定事件
const newChatBtn = document.getElementById('newChatBtn');
if (newChatBtn) {
newChatBtn.addEventListener('click', createNewConversation);
}
// 搜索功能
const searchToggleBtn = document.getElementById('searchToggleBtn');
const searchBar = document.getElementById('searchBar');
const searchInput = document.getElementById('searchInput');
const searchCloseBtn = document.getElementById('searchCloseBtn');
const searchResults = document.getElementById('searchResults');
if (searchToggleBtn) {
searchToggleBtn.addEventListener('click', () => {
if (searchBar) {
searchBar.classList.add('show');
if (searchInput) {
searchInput.focus();
}
}
});
}
if (searchCloseBtn) {
searchCloseBtn.addEventListener('click', () => {
hideSearchBar();
});
}
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const keyword = e.target.value.trim();
if (keyword) {
searchConversations(keyword);
} else {
if (searchResults) searchResults.innerHTML = '';
}
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hideSearchBar();
}
});
}
// 点击搜索结果
if (searchResults) {
searchResults.addEventListener('click', (e) => {
const item = e.target.closest('.search-result-item');
if (item) {
const id = item.getAttribute('data-id');
hideSearchBar();
openConversation(id);
}
});
}
function hideSearchBar() {
if (searchBar) {
searchBar.classList.remove('show');
}
if (searchInput) {
searchInput.value = '';
}
if (searchResults) {
searchResults.innerHTML = '';
}
}
const conversationList = document.getElementById('conversationList');
const actionMenu = document.getElementById('actionMenu');
let longPressTimer = null;
let currentActionConvId = null;
if (conversationList) {
// 点击事件
conversationList.addEventListener('click', (e) => {
const item = e.target.closest('.conversation-item');
if (item) {
const id = item.getAttribute('data-id');
openConversation(id);
}
});
// 长按事件
conversationList.addEventListener('touchstart', (e) => {
const item = e.target.closest('.conversation-item');
if (item) {
longPressTimer = setTimeout(() => {
currentActionConvId = item.getAttribute('data-id');
showActionMenu(currentActionConvId);
}, 500); // 500ms长按
}
});
conversationList.addEventListener('touchend', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
conversationList.addEventListener('touchmove', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
// 鼠标长按PC端
conversationList.addEventListener('mousedown', (e) => {
const item = e.target.closest('.conversation-item');
if (item) {
longPressTimer = setTimeout(() => {
currentActionConvId = item.getAttribute('data-id');
showActionMenu(currentActionConvId);
}, 500);
}
});
conversationList.addEventListener('mouseup', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
conversationList.addEventListener('mouseleave', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
}
// 操作菜单事件
if (actionMenu) {
actionMenu.addEventListener('click', (e) => {
const item = e.target.closest('.action-menu-item');
if (item && currentActionConvId) {
const action = item.getAttribute('data-action');
handleActionMenuAction(action, currentActionConvId);
hideActionMenu();
}
});
// 点击其他地方关闭菜单
document.addEventListener('click', (e) => {
if (actionMenu.classList.contains('show') && !actionMenu.contains(e.target)) {
hideActionMenu();
}
});
}
}
// 排序对话(置顶在前)
function sortConversations() {
return [...conversations].sort((a, b) => {
// 置顶优先
if (a.is_pinned && !b.is_pinned) return -1;
if (!a.is_pinned && b.is_pinned) return 1;
// 然后按更新时间
return b.updatedAt - a.updatedAt;
});
}
// 搜索对话
function searchConversations(keyword) {
const searchResults = document.getElementById('searchResults');
if (!searchResults) return;
keyword = keyword.toLowerCase();
// 搜索标题和消息内容
const results = conversations.filter(conv => {
// 搜索标题
if (conv.title.toLowerCase().includes(keyword)) return true;
// 搜索消息内容
if (conv.messages.some(m => m.content.toLowerCase().includes(keyword))) return true;
return false;
});
if (results.length === 0) {
searchResults.innerHTML = '<div class="search-empty">未找到相关对话</div>';
return;
}
searchResults.innerHTML = results.map(conv => {
// 找到匹配的消息片段
let matchSnippet = '';
const matchedMsg = conv.messages.find(m => m.content.toLowerCase().includes(keyword));
if (matchedMsg) {
const content = matchedMsg.content;
const idx = content.toLowerCase().indexOf(keyword);
const start = Math.max(0, idx - 30);
const end = Math.min(content.length, idx + keyword.length + 30);
matchSnippet = (start > 0 ? '...' : '') + content.slice(start, end) + (end < content.length ? '...' : '');
}
return `
<div class="search-result-item ${conv.is_pinned ? 'pinned' : ''}" data-id="${conv.id}">
${conv.is_pinned ? '<span class="pin-icon">📌</span>' : ''}
<div class="search-result-title">${escapeHtml(conv.title)}</div>
${matchSnippet ? `<div class="search-result-snippet">${escapeHtml(matchSnippet)}</div>` : ''}
<div class="search-result-meta">${conv.messages.length} 条消息 · ${formatTime(conv.updatedAt)}</div>
</div>
`;
}).join('');
}
// 显示操作菜单
function showActionMenu(convId) {
const actionMenu = document.getElementById('actionMenu');
const conv = conversations.find(c => c.id === convId);
if (!actionMenu || !conv) return;
// 更新置顶按钮文字
const pinText = document.getElementById('pinText');
if (pinText) {
pinText.textContent = conv.is_pinned ? '取消置顶' : '置顶';
}
// 显示菜单
actionMenu.classList.add('show');
}
// 隐藏操作菜单
function hideActionMenu() {
const actionMenu = document.getElementById('actionMenu');
if (actionMenu) {
actionMenu.classList.remove('show');
}
}
// 处理操作菜单动作
function handleActionMenuAction(action, convId) {
const conv = conversations.find(c => c.id === convId);
if (!conv) return;
switch (action) {
case 'rename':
renameConversation(convId);
break;
case 'share':
shareConversation(convId);
break;
case 'pin':
togglePinConversation(convId);
break;
case 'delete':
deleteConversation(convId);
break;
}
}
// 重命名对话
function renameConversation(convId) {
const conv = conversations.find(c => c.id === convId);
if (!conv) return;
const newTitle = prompt('请输入新的对话标题:', conv.title);
if (newTitle && newTitle.trim() && newTitle !== conv.title) {
conv.title = newTitle.trim();
conv.updatedAt = Date.now();
saveConversations();
showConversationList();
showToast('已重命名');
}
}
// 分享对话
function shareConversation(convId) {
const conv = conversations.find(c => c.id === convId);
if (!conv) return;
// 构建分享内容
const shareContent = `${conv.title}\n\n${conv.messages.map(m =>
`${m.role === 'user' ? '👤 用户' : '🤖 AI'}: ${m.content}`
).join('\n\n')}`;
// 复制到剪贴板
try {
const textarea = document.createElement('textarea');
textarea.value = shareContent;
textarea.style.position = 'fixed';
textarea.style.top = '0';
textarea.style.left = '0';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('对话已复制到剪贴板');
} catch (err) {
showToast('分享失败');
}
}
// 置顶/取消置顶对话
function togglePinConversation(convId) {
const conv = conversations.find(c => c.id === convId);
if (!conv) return;
conv.is_pinned = !conv.is_pinned;
conv.updatedAt = Date.now();
saveConversations();
showConversationList();
showToast(conv.is_pinned ? '已置顶' : '已取消置顶');
}
// 创建新对话
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="feature-bar">
<div class="feature-left">
<button class="feature-btn thinking-btn ${enableThinking ? 'active' : ''}" id="thinkingBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>深度思考</span>
</button>
<button class="feature-btn search-btn ${enableSearch ? 'active' : ''}" id="searchBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
<span>联网搜索</span>
</button>
</div>
<div class="feature-right">
<button class="feature-btn nav-btn" id="scrollTopBtn" title="回到顶部">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>
</button>
<button class="feature-btn nav-btn" id="scrollBottomBtn" title="回到底部">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
</button>
</div>
</div>
<div class="input-area">
<button class="attach-btn" id="attachBtn" title="上传文件">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
<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 class="attach-panel" id="attachPanel">
<div class="attach-panel-content">
<div class="attach-item" data-type="image">
<div class="attach-icon">📷</div>
<div class="attach-label">上传图片</div>
</div>
<div class="attach-item" data-type="file">
<div class="attach-icon">📄</div>
<div class="attach-label">上传文件</div>
</div>
</div>
</div>
<input type="file" id="imageInput" accept="image/*" style="display:none">
<input type="file" id="fileInput" accept=".txt,.md,.pdf,.doc,.docx,.json,.csv" style="display:none">
</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');
thinkingBtn = document.getElementById('thinkingBtn');
searchBtn = document.getElementById('searchBtn');
// 重置自动滚动状态
autoScrollEnabled = true;
// 设置滚动监听
setupScrollListener();
// 绑定按钮事件
const backBtn = document.getElementById('backBtn');
if (backBtn) backBtn.addEventListener('click', showConversationList);
const clearBtn = document.getElementById('clearBtn');
if (clearBtn) clearBtn.addEventListener('click', clearCurrentChat);
// 绑定功能开关按钮事件
if (thinkingBtn) {
thinkingBtn.addEventListener('click', () => {
enableThinking = !enableThinking;
thinkingBtn.classList.toggle('active', enableThinking);
});
}
if (searchBtn) {
searchBtn.addEventListener('click', () => {
enableSearch = !enableSearch;
searchBtn.classList.toggle('active', enableSearch);
});
}
// 绑定置顶置底按钮事件
const scrollTopBtn = document.getElementById('scrollTopBtn');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
if (scrollTopBtn) {
scrollTopBtn.addEventListener('click', () => {
if (messagesContainer) {
messagesContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
}
});
}
if (scrollBottomBtn) {
scrollBottomBtn.addEventListener('click', () => {
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: 'smooth'
});
}
});
}
// 绑定输入事件
userInput.addEventListener('keydown', handleKeyDown);
userInput.addEventListener('input', (e) => autoResize(e.target));
sendBtn.addEventListener('click', sendMessage);
// 绑定上传按钮事件
const attachBtn = document.getElementById('attachBtn');
const attachPanel = document.getElementById('attachPanel');
const imageInput = document.getElementById('imageInput');
const fileInput = document.getElementById('fileInput');
if (attachBtn) {
attachBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止冒泡到 document
attachPanel.classList.toggle('show');
});
}
// 点击其他地方关闭面板
document.addEventListener('click', (e) => {
if (attachPanel && attachPanel.classList.contains('show') &&
!attachPanel.contains(e.target) && !attachBtn.contains(e.target)) {
attachPanel.classList.remove('show');
}
});
// 上传选项点击
attachPanel.querySelectorAll('.attach-item').forEach(item => {
item.addEventListener('click', () => {
const type = item.getAttribute('data-type');
attachPanel.classList.remove('show');
if (type === 'image') {
imageInput.click();
} else if (type === 'file') {
fileInput.click();
}
});
});
// 图片上传处理
imageInput.addEventListener('change', handleImageUpload);
// 文件上传处理
fileInput.addEventListener('change', handleFileUpload);
// 绑定快捷按钮事件
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();
// 发送新消息时启用自动滚动
autoScrollEnabled = true;
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;
const userMessage = currentConversation.messages[userMsgIndex];
// 如果开启联网搜索,先执行搜索
let searchResults = null;
if (enableSearch && userMessage.role === 'user') {
searchResults = await performSearch(userMessage.content);
}
// 只有开启深度思考时才添加 thinking 字段,开启搜索时添加 search_results 字段
currentConversation.messages.push({
role: 'assistant',
content: '',
...(enableThinking ? { thinking: '' } : {}),
...(searchResults ? { search_results: searchResults } : {})
});
renderMessages();
const lastMessageEl = messagesDiv.lastElementChild;
const contentEl = lastMessageEl.querySelector('.message-content');
const thinkingEl = lastMessageEl.querySelector('.thinking-content');
// 深度思考模式:思考块默认展开
if (enableThinking && thinkingEl) {
const thinkingBlock = lastMessageEl.querySelector('.thinking-block');
if (thinkingBlock) thinkingBlock.classList.add('expanded');
thinkingEl.innerHTML = '<span class="streaming-cursor">思考中...</span>';
}
contentEl.innerHTML = '<span class="streaming-cursor">▌</span>';
// 显示停止生成按钮
showStopGenerateBtn();
try {
// 构建消息数组
let messagesToSend = currentConversation.messages.slice(0, aiMessageIndex).map(m => ({
role: m.role,
content: m.content
}));
// 如果有搜索结果,将搜索内容添加到消息中
if (searchResults) {
const searchContext = formatSearchResultsForLLM(searchResults);
messagesToSend.push({
role: 'system',
content: `以下是搜索结果,请根据这些信息回答用户问题:\n\n${searchContext}`
});
}
// 构建请求体 - 统一使用 glm-4.5-air通过 thinking 参数控制
const requestBody = {
model: CONFIG.model,
messages: messagesToSend,
max_tokens: CONFIG.maxTokens,
stream: true,
thinking: {
type: enableThinking ? 'enabled' : 'disabled'
}
};
const response = await fetch(CONFIG.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.apiKey}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`API 错误: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let thinkingOutputStarted = false; // 正式内容是否开始输出
let abortController = new AbortController(); // 用于中断流
// 绑定停止按钮事件
const stopBtn = document.getElementById('stopGenerateBtn');
if (stopBtn) {
stopBtn.onclick = () => {
abortController.abort();
isLoading = false;
sendBtn.disabled = false;
hideStopGenerateBtn();
// 更新最终内容
if (thinkingEl && enableThinking && currentConversation.messages[aiMessageIndex].thinking) {
thinkingEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].thinking);
}
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
};
}
while (true) {
if (abortController.signal.aborted) break; // 检查是否已停止
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);
const delta = data.choices?.[0]?.delta;
if (delta) {
// 只有开启深度思考时才处理思考内容
if (enableThinking && (delta.reasoning_content || delta.thinking)) {
const thinkingChunk = delta.reasoning_content || delta.thinking;
currentConversation.messages[aiMessageIndex].thinking += thinkingChunk;
if (thinkingEl) {
thinkingEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].thinking) + '<span class="streaming-cursor">▌</span>';
}
// 确保思考块展开
const thinkingBlock = lastMessageEl.querySelector('.thinking-block');
if (thinkingBlock && !thinkingBlock.classList.contains('expanded')) {
thinkingBlock.classList.add('expanded');
}
scrollToBottom();
}
// 处理正式回复内容
if (delta.content) {
// 如果开启深度思考且开始输出正式内容,说明思考完成,立即折叠思考块
if (enableThinking && !thinkingOutputStarted && currentConversation.messages[aiMessageIndex].thinking) {
thinkingOutputStarted = true;
// 折叠思考内容
const thinkingBlock = lastMessageEl.querySelector('.thinking-block');
if (thinkingBlock) thinkingBlock.classList.remove('expanded');
if (thinkingEl) thinkingEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].thinking);
}
currentConversation.messages[aiMessageIndex].content += delta.content;
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content) + '<span class="streaming-cursor">▌</span>';
scrollToBottom();
}
}
} catch (e) {}
}
}
}
// 最终渲染
if (thinkingEl && enableThinking && currentConversation.messages[aiMessageIndex].thinking) {
thinkingEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].thinking);
}
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;
hideStopGenerateBtn();
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
// 自动总结标题第一次对话和每隔5次对话
const totalMessages = currentConversation.messages.length;
// 第一次对话(用户+AI=2条或每5次对话10条
if (totalMessages === 2 || totalMessages % 10 === 0) {
await generateConversationTitle();
}
}
}
// 执行 Tavily 搜索
async function performSearch(query) {
try {
const response = await fetch(CONFIG.tavilyApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.tavilyApiKey}`
},
body: JSON.stringify({
query: query,
max_results: 10,
include_raw_content: false
})
});
if (!response.ok) {
console.error('搜索失败:', response.status);
return null;
}
const data = await response.json();
return data.results || [];
} catch (error) {
console.error('搜索错误:', error);
return null;
}
}
// 格式化搜索结果给 LLM
function formatSearchResultsForLLM(results) {
if (!results || results.length === 0) return '无搜索结果';
return results.map((r, i) =>
`${i + 1}. 【${r.title}\n来源: ${r.url}\n摘要: ${r.content || '无摘要'}\n`
).join('\n');
}
// 显示停止生成按钮
function showStopGenerateBtn() {
// 检查是否已存在
if (document.getElementById('stopGenerateBtn')) return;
const stopBtn = document.createElement('button');
stopBtn.id = 'stopGenerateBtn';
stopBtn.className = 'stop-generate-btn';
stopBtn.innerHTML = `
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 6h12v12H6z"/></svg>
<span>停止生成</span>
`;
// 插入到消息容器底部
if (messagesContainer) {
messagesContainer.appendChild(stopBtn);
}
}
// 隐藏停止生成按钮
function hideStopGenerateBtn() {
const stopBtn = document.getElementById('stopGenerateBtn');
if (stopBtn) {
stopBtn.remove();
}
}
// 生成对话标题
async function generateConversationTitle() {
if (!currentConversation) return;
console.log('开始生成标题,当前消息数:', currentConversation.messages.length);
// 构建对话摘要
const conversationText = currentConversation.messages.map(m =>
`${m.role === 'user' ? '用户' : 'AI'}: ${m.content.slice(0, 200)}`
).join('\n');
const titlePrompt = `请用不超过20个字总结以下对话的主题只输出标题不要其他内容
${conversationText}`;
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: [{ role: 'user', content: titlePrompt }],
max_tokens: 100,
thinking: { type: 'disabled' } // 禁用思考模式,直接输出标题
})
});
console.log('标题API响应状态:', response.status);
if (response.ok) {
const data = await response.json();
console.log('标题API响应数据:', data);
const newTitle = data.choices?.[0]?.message?.content?.trim();
console.log('生成的标题:', newTitle);
if (newTitle && newTitle.length > 0) {
// 去掉可能的引号和多余符号
const cleanTitle = newTitle.replace(/^["'"']+|["'"']+$/g, '').trim();
if (cleanTitle.length > 0 && cleanTitle.length <= 30) {
currentConversation.title = cleanTitle;
currentConversation.updatedAt = Date.now();
saveConversations();
console.log('标题已保存:', cleanTitle);
// 更新页面标题显示
const titleEl = document.querySelector('.header h1');
if (titleEl) {
titleEl.textContent = cleanTitle;
}
// 更新侧边栏标题(如果在对话列表页面)
const convItem = document.querySelector(`.conversation-item[data-id="${currentConversation.id}"] .conv-title`);
if (convItem) {
convItem.textContent = cleanTitle;
}
showToast('标题已更新');
}
}
} else {
const errorText = await response.text();
console.error('标题API错误:', errorText);
}
} catch (error) {
console.error('生成标题失败:', error);
}
}
// 重新生成 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 msg = currentConversation.messages[index];
// 如果是图片消息,复制图片描述或提示
let content = msg.content;
if (msg.image && content === '[图片]') {
content = '[图片: ' + (msg.imageName || '未命名') + ']';
}
// HTTP 环境下 navigator.clipboard 不工作,优先使用 fallback
try {
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.style.position = 'fixed';
textarea.style.top = '0';
textarea.style.left = '0';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
if (success) {
showToast('已复制到剪贴板');
} else {
showToast('复制失败,请手动复制');
}
} catch (err) {
console.error('复制失败:', err);
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 ? '👤' : '🤖';
// 处理消息内容(支持图片)
let contentHtml = '';
if (msg.image) {
// 图片消息
contentHtml = `<div class="message-image"><img src="${msg.image}" alt="${msg.imageName || '图片'}"></div>`;
if (msg.content && msg.content !== '[图片]') {
contentHtml += `<div class="message-text">${renderMarkdown(msg.content)}</div>`;
}
} else {
contentHtml = renderMarkdown(msg.content);
}
// 思考内容块仅AI消息
let thinkingHtml = '';
if (!isUser && 'thinking' in msg) {
// 判断是否是当前正在生成的消息有thinking字段且正在加载
const isGenerating = index === currentConversation.messages.length - 1 && isLoading && enableThinking;
// 思考进行中且没有正式内容时展开
const expandedClass = isGenerating && !msg.content ? 'expanded' : '';
thinkingHtml = `
<div class="thinking-block ${expandedClass}" onclick="toggleThinking(this)">
<div class="thinking-header">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>思考过程</span>
<svg class="thinking-arrow" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="thinking-content">${renderMarkdown(msg.thinking || '思考中...')}</div>
</div>`;
}
// 搜索结果块仅AI消息放在思考块前面
let searchHtml = '';
if (!isUser && msg.search_results && msg.search_results.length > 0) {
searchHtml = `
<div class="search-results-block" onclick="toggleSearchResults(this)">
<div class="search-results-header">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
<span>搜索结果 (${msg.search_results.length})</span>
<svg class="search-results-arrow" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="search-results-content">
${msg.search_results.map((r, i) => `
<div class="search-result-link">
<span class="search-result-num">${i + 1}</span>
<a href="${r.url}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>
</div>
`).join('')}
</div>
</div>`;
}
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">
${searchHtml}
${thinkingHtml}
<div class="message-content">${contentHtml}</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();
}
// 折叠/展开思考内容
function toggleThinking(block) {
block.classList.toggle('expanded');
}
// 折叠/展开搜索结果
function toggleSearchResults(block) {
block.classList.toggle('expanded');
}
// ==================== 工具函数 ====================
// 渲染 Markdown
function renderMarkdown(text) {
if (!text) return '';
marked.setOptions({
breaks: true,
gfm: true
});
return marked.parse(text);
}
// 滚动到底部(智能滚动:只在用户已在底部时自动滚动)
function scrollToBottom() {
if (messagesContainer) {
// 判断用户是否在底部附近距离底部100px以内
const isNearBottom = messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < 100;
if (isNearBottom || autoScrollEnabled) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
}
// 监听用户滚动行为
function setupScrollListener() {
if (messagesContainer) {
messagesContainer.addEventListener('scroll', () => {
// 判断用户是否在底部附近
const isNearBottom = messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < 100;
if (isNearBottom) {
autoScrollEnabled = true;
} else {
// 用户往上滚动,停止自动滚动
autoScrollEnabled = false;
}
});
}
}
// 保存对话列表
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(() => {});
}
// ==================== 文件上传处理 ====================
// 处理图片上传
async function handleImageUpload(e) {
const file = e.target.files[0];
if (!file) return;
// 读取图片为base64
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = event.target.result;
// 添加用户消息(显示图片)
currentConversation.messages.push({
role: 'user',
content: '[图片]',
image: base64,
imageName: file.name
});
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
// 隐藏欢迎界面
if (welcome) welcome.style.display = 'none';
// 调用AI生成
await streamGenerateWithImage(base64, file.name);
};
reader.readAsDataURL(file);
// 清空input以便再次选择同一文件
e.target.value = '';
}
// 处理文件上传
async function handleFileUpload(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const content = event.target.result;
const fileName = file.name;
// 添加用户消息
currentConversation.messages.push({
role: 'user',
content: `[文件: ${fileName}]\\n\\n${content.slice(0, 500)}${content.length > 500 ? '...' : ''}`
});
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
if (welcome) welcome.style.display = 'none';
// 调用AI生成
await streamGenerateWithFile(content, fileName);
};
// 根据文件类型读取
if (file.name.endsWith('.pdf') || file.name.endsWith('.doc') || file.name.endsWith('.docx')) {
// PDF/Word文件暂时只显示文件名
showToast('PDF/Word文件暂不支持解析请上传文本文件');
e.target.value = '';
return;
}
reader.readAsText(file);
e.target.value = '';
}
// 带图片的流式生成
async function streamGenerateWithImage(base64, imageName) {
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 messages = currentConversation.messages.slice(0, aiMessageIndex).map(m => {
if (m.image) {
return {
role: m.role,
content: [
{ type: 'image_url', image_url: { url: m.image } },
{ type: 'text', text: '请分析这张图片' }
]
};
}
return { role: m.role, content: m.content };
});
const response = await fetch(CONFIG.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.apiKey}`
},
body: JSON.stringify({
model: 'glm-4v-flash', // 视觉模型
messages: messages,
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 (err) {}
}
}
}
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
} catch (error) {
console.error('Error:', error);
currentConversation.messages[aiMessageIndex].content = `抱歉,图片分析失败:${error.message}`;
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
} finally {
isLoading = false;
sendBtn.disabled = false;
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
}
}
// 带文件的流式生成
async function streamGenerateWithFile(content, fileName) {
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 messages = currentConversation.messages.slice(0, aiMessageIndex).map(m => ({
role: m.role,
content: m.content
}));
// 添加文件内容作为系统提示
messages.unshift({
role: 'system',
content: `以下是用户上传的文件内容,请根据内容回答问题:\\n文件名${fileName}\\n内容\\n${content}`
});
const response = await fetch(CONFIG.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.apiKey}`
},
body: JSON.stringify({
model: CONFIG.model,
messages: messages,
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 (err) {}
}
}
}
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
} catch (error) {
console.error('Error:', error);
currentConversation.messages[aiMessageIndex].content = `抱歉,文件处理失败:${error.message}`;
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
} finally {
isLoading = false;
sendBtn.disabled = false;
currentConversation.updatedAt = Date.now();
saveConversations();
renderMessages();
}
}