diff --git a/works/ai-chat-app/www/app.js b/works/ai-chat-app/www/app.js index 0a0a913..a28bda8 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', @@ -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 = '▌'; 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) + '▌'; + 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, '
$2');
// 处理行内代码
@@ -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 = `
-
-
- `;
- messagesDiv.appendChild(indicator);
- scrollToBottom();
-}
-
-// 隐藏打字指示器
-function hideTypingIndicator() {
- const indicator = document.getElementById('typingIndicator');
- if (indicator) {
- indicator.remove();
- }
-}
-
// 滚动到底部
function scrollToBottom() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
diff --git a/works/ai-chat-app/www/style.css b/works/ai-chat-app/www/style.css
index 59cc676..5218f80 100644
--- a/works/ai-chat-app/www/style.css
+++ b/works/ai-chat-app/www/style.css
@@ -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 {