Compare commits

..

3 Commits

Author SHA1 Message Date
e1ab11c007 feat: 添加复制按钮(复制原文)
- 用户和AI消息都添加复制按钮
- 复制原始文本内容(不包含Markdown格式)
- 添加Toast提示显示复制成功
- 复制按钮hover时显示蓝色
2026-04-25 17:20:32 +08:00
386fa20c84 feat: Markdown格式显示 + 重新生成 + 删除功能
- 使用 marked.js 渲染 Markdown 格式
- AI消息添加重新生成按钮(刷新图标)
- 用户和AI消息都可删除
- 删除AI消息时同时删除对应的用户消息
- 删除用户消息时同时删除对应的AI回复
- 消息操作按钮hover时显示
- 优化Markdown样式(标题、列表、代码块、表格等)
2026-04-25 17:17:53 +08:00
108a496dab feat: AI回复流式输出
- 使用 SSE 流式调用智谱 API
- 实时显示 AI 回复内容
- 添加闪烁光标动画效果
- 移除打字指示器,改为实时内容显示
2026-04-25 17:06:04 +08:00
4 changed files with 406 additions and 59 deletions

View File

@@ -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',
@@ -55,7 +55,7 @@ function sendQuickMessage(text) {
sendMessage();
}
// 发送消息
// 发送消息(流式输出)
async function sendMessage() {
const text = userInput.value.trim();
if (!text || isLoading) return;
@@ -69,13 +69,28 @@ async function sendMessage() {
userInput.value = '';
autoResize(userInput);
// 调用流式生成
await streamGenerate(messages.length - 1);
}
// 流式生成 AI 回复
async function streamGenerate(userMsgIndex) {
// 显示加载状态
isLoading = true;
sendBtn.disabled = true;
showTypingIndicator();
// 创建 AI 消息容器(流式填充)
const aiMessageIndex = messages.length;
messages.push({ role: 'assistant', content: '' });
renderMessages();
// 获取最后一条消息的 DOM 元素
const lastMessageEl = messagesDiv.lastElementChild;
const contentEl = lastMessageEl.querySelector('.message-content');
contentEl.innerHTML = '<span class="streaming-cursor">▌</span>';
try {
// 调用 API
// 调用 API(流式)
const response = await fetch(CONFIG.apiUrl, {
method: 'POST',
headers: {
@@ -84,12 +99,12 @@ async function sendMessage() {
},
body: JSON.stringify({
model: CONFIG.model,
messages: messages.map(m => ({
messages: messages.slice(0, aiMessageIndex).map(m => ({
role: m.role,
content: m.content
})),
max_tokens: CONFIG.maxTokens,
stream: false
stream: true
})
});
@@ -97,81 +112,186 @@ async function sendMessage() {
throw new Error(`API 错误: ${response.status}`);
}
const data = await response.json();
// 添加 AI 回复
if (data.choices && data.choices[0]) {
const assistantMessage = data.choices[0].message.content;
messages.push({ role: 'assistant', content: assistantMessage });
// 处理流式响应
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 });
// 解析 SSE 数据
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) {
messages[aiMessageIndex].content += data.choices[0].delta.content;
contentEl.innerHTML = renderMarkdown(messages[aiMessageIndex].content) + '<span class="streaming-cursor">▌</span>';
scrollToBottom();
}
} catch (e) {}
}
}
}
// 完成,移除光标
contentEl.innerHTML = renderMarkdown(messages[aiMessageIndex].content);
} catch (error) {
console.error('Error:', error);
messages.push({
role: 'assistant',
content: `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`
});
messages[aiMessageIndex].content = `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`;
contentEl.innerHTML = renderMarkdown(messages[aiMessageIndex].content);
} finally {
isLoading = false;
sendBtn.disabled = false;
hideTypingIndicator();
renderMessages();
saveHistory();
renderMessages(); // 重新渲染显示操作按钮
}
}
// 重新生成 AI 回复
async function regenerate(index) {
if (isLoading || index < 1) return;
// 找到对应的用户消息AI消息前一条
const userMsgIndex = index - 1;
if (messages[userMsgIndex].role !== 'user') return;
// 删除当前 AI 回复
messages.splice(index, 1);
// 重新生成
await streamGenerate(userMsgIndex);
}
// 复制消息(复制原文)
function copyMessage(index) {
const content = 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';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('已复制到剪贴板');
});
}
// 显示提示
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);
}
// 删除消息
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) {
welcome.style.display = 'block';
}
}
// 渲染消息
function renderMessages() {
messagesDiv.innerHTML = messages.map(msg => {
messagesDiv.innerHTML = messages.map((msg, index) => {
const isUser = msg.role === 'user';
const avatar = isUser ? '👤' : '🤖';
const content = formatContent(msg.content);
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" onclick="copyMessage(${index})" title="复制">${copyIcon}</button>
<button class="action-btn delete-btn" onclick="deleteMessage(${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" onclick="copyMessage(${index})" title="复制">${copyIcon}</button>
<button class="action-btn regenerate-btn" onclick="regenerate(${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" onclick="deleteMessage(${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}">
<div class="message ${msg.role}" data-index="${index}">
<div class="message-avatar">${avatar}</div>
<div class="message-content">${content}</div>
<div class="message-body">
<div class="message-content">${content}</div>
${actions}
</div>
</div>
`;
}).join('');
// 滚动到底部
scrollToBottom();
}
// 格式化内容(简单处理代码块)
function formatContent(text) {
// 处理代码块
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
// 处理行内代码
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// 处理换行
text = text.replace(/\n/g, '<br>');
return text;
}
// 显示打字指示器
function showTypingIndicator() {
const indicator = document.createElement('div');
indicator.id = 'typingIndicator';
indicator.className = 'message assistant';
indicator.innerHTML = `
<div class="message-avatar">🤖</div>
<div class="message-content">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
`;
messagesDiv.appendChild(indicator);
scrollToBottom();
}
// 隐藏打字指示器
function hideTypingIndicator() {
const indicator = document.getElementById('typingIndicator');
if (indicator) {
indicator.remove();
}
// 渲染 Markdown
function renderMarkdown(text) {
if (!text) return '';
// 配置 marked
marked.setOptions({
breaks: true, // 支持 GFM 换行
gfm: true // GitHub Flavored Markdown
});
return marked.parse(text);
}
// 滚动到底部

View File

@@ -55,6 +55,7 @@
</div>
</div>
<script src="marked.min.js"></script>
<script src="app.js"></script>
</body>
</html>

69
works/ai-chat-app/www/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -172,11 +172,9 @@ body {
}
.message-content {
max-width: 75%;
padding: 12px 16px;
border-radius: 18px;
word-wrap: break-word;
white-space: pre-wrap;
}
.message.user .message-content {
@@ -214,6 +212,10 @@ body {
padding: 0;
}
.message.user .message-content pre {
background: rgba(0,0,0,0.2);
}
/* 加载动画 */
.typing-indicator {
display: flex;
@@ -297,6 +299,161 @@ body {
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);
}
/* 消息结构 */
.message-body {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 75%;
}
.message.user .message-body {
align-items: flex-end;
}
.message.assistant .message-body {
align-items: flex-start;
}
/* 消息操作按钮 */
.message-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.message:hover .message-actions {
opacity: 1;
}
.action-btn {
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 4px 6px;
color: var(--text-light);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn:hover {
background: var(--border-color);
color: var(--text-color);
}
.action-btn.copy-btn:hover {
background: #dbeafe;
border-color: var(--primary);
color: var(--primary);
}
.action-btn.delete-btn:hover {
background: #fee2e2;
border-color: #e53e3e;
color: #e53e3e;
}
.action-btn.regenerate-btn:hover {
background: #dbeafe;
border-color: var(--primary);
color: var(--primary);
}
/* Markdown 内容样式 */
.message-content h1, .message-content h2, .message-content h3 {
margin: 12px 0 8px;
font-weight: 600;
}
.message-content h1 { font-size: 1.3em; }
.message-content h2 { font-size: 1.2em; }
.message-content h3 { font-size: 1.1em; }
.message-content ul, .message-content ol {
margin: 8px 0;
padding-left: 20px;
}
.message-content li {
margin: 4px 0;
}
.message-content strong {
font-weight: 600;
}
.message-content em {
font-style: italic;
}
.message-content a {
color: var(--primary);
text-decoration: underline;
}
.message-content blockquote {
margin: 8px 0;
padding: 8px 12px;
border-left: 3px solid var(--primary);
background: rgba(102, 126, 234, 0.1);
border-radius: 4px;
}
.message-content table {
margin: 8px 0;
border-collapse: collapse;
width: 100%;
}
.message-content th, .message-content td {
border: 1px solid var(--border-color);
padding: 6px 10px;
text-align: left;
}
.message-content th {
background: var(--bg-color);
font-weight: 600;
}
/* 流式输出光标 */
.streaming-cursor {
animation: blink 1s infinite;
color: var(--primary);
font-weight: bold;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* 响应式 */
@media (min-width: 768px) {
.message-content {