feat: AI回复流式输出
- 使用 SSE 流式调用智谱 API - 实时显示 AI 回复内容 - 添加闪烁光标动画效果 - 移除打字指示器,改为实时内容显示
This commit is contained in:
@@ -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;
|
||||
@@ -72,10 +72,19 @@ async function sendMessage() {
|
||||
// 显示加载状态
|
||||
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 +93,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,24 +106,53 @@ 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 = formatContent(messages[aiMessageIndex].content) + '<span class="streaming-cursor">▌</span>';
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 完成,移除光标
|
||||
contentEl.innerHTML = formatContent(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 = formatContent(messages[aiMessageIndex].content);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
sendBtn.disabled = false;
|
||||
hideTypingIndicator();
|
||||
renderMessages();
|
||||
saveHistory();
|
||||
}
|
||||
}
|
||||
@@ -140,6 +178,8 @@ function renderMessages() {
|
||||
|
||||
// 格式化内容(简单处理代码块)
|
||||
function formatContent(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// 处理代码块
|
||||
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
|
||||
// 处理行内代码
|
||||
@@ -149,31 +189,6 @@ function formatContent(text) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
function scrollToBottom() {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
|
||||
@@ -297,6 +297,18 @@ body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* 流式输出光标 */
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user