Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 386fa20c84 | |||
| 108a496dab |
@@ -1,5 +1,5 @@
|
|||||||
// AI助手 - 前端应用
|
// AI助手 - 前端应用
|
||||||
// 使用智谱 GLM-4.5-Air 模型
|
// 使用智谱 GLM-4.5-Air 模型(流式输出)
|
||||||
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
apiUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
|
apiUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
|
||||||
@@ -55,7 +55,7 @@ function sendQuickMessage(text) {
|
|||||||
sendMessage();
|
sendMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息(流式输出)
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = userInput.value.trim();
|
const text = userInput.value.trim();
|
||||||
if (!text || isLoading) return;
|
if (!text || isLoading) return;
|
||||||
@@ -69,13 +69,28 @@ async function sendMessage() {
|
|||||||
userInput.value = '';
|
userInput.value = '';
|
||||||
autoResize(userInput);
|
autoResize(userInput);
|
||||||
|
|
||||||
|
// 调用流式生成
|
||||||
|
await streamGenerate(messages.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式生成 AI 回复
|
||||||
|
async function streamGenerate(userMsgIndex) {
|
||||||
// 显示加载状态
|
// 显示加载状态
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
sendBtn.disabled = 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 {
|
try {
|
||||||
// 调用 API
|
// 调用 API(流式)
|
||||||
const response = await fetch(CONFIG.apiUrl, {
|
const response = await fetch(CONFIG.apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -84,12 +99,12 @@ async function sendMessage() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: CONFIG.model,
|
model: CONFIG.model,
|
||||||
messages: messages.map(m => ({
|
messages: messages.slice(0, aiMessageIndex).map(m => ({
|
||||||
role: m.role,
|
role: m.role,
|
||||||
content: m.content
|
content: m.content
|
||||||
})),
|
})),
|
||||||
max_tokens: CONFIG.maxTokens,
|
max_tokens: CONFIG.maxTokens,
|
||||||
stream: false
|
stream: true
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,81 +112,147 @@ async function sendMessage() {
|
|||||||
throw new Error(`API 错误: ${response.status}`);
|
throw new Error(`API 错误: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
// 处理流式响应
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
// 添加 AI 回复
|
while (true) {
|
||||||
if (data.choices && data.choices[0]) {
|
const { done, value } = await reader.read();
|
||||||
const assistantMessage = data.choices[0].message.content;
|
if (done) break;
|
||||||
messages.push({ role: 'assistant', content: assistantMessage });
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
messages.push({
|
messages[aiMessageIndex].content = `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`;
|
||||||
role: 'assistant',
|
contentEl.innerHTML = renderMarkdown(messages[aiMessageIndex].content);
|
||||||
content: `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
hideTypingIndicator();
|
|
||||||
renderMessages();
|
|
||||||
saveHistory();
|
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 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() {
|
function renderMessages() {
|
||||||
messagesDiv.innerHTML = messages.map(msg => {
|
messagesDiv.innerHTML = messages.map((msg, index) => {
|
||||||
const isUser = msg.role === 'user';
|
const isUser = msg.role === 'user';
|
||||||
const avatar = isUser ? '👤' : '🤖';
|
const avatar = isUser ? '👤' : '🤖';
|
||||||
const content = formatContent(msg.content);
|
const content = renderMarkdown(msg.content);
|
||||||
|
|
||||||
|
// 操作按钮
|
||||||
|
const actions = isUser
|
||||||
|
? `<div class="message-actions">
|
||||||
|
<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 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 `
|
return `
|
||||||
<div class="message ${msg.role}">
|
<div class="message ${msg.role}" data-index="${index}">
|
||||||
<div class="message-avatar">${avatar}</div>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// 滚动到底部
|
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化内容(简单处理代码块)
|
// 渲染 Markdown
|
||||||
function formatContent(text) {
|
function renderMarkdown(text) {
|
||||||
// 处理代码块
|
if (!text) return '';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示打字指示器
|
// 配置 marked
|
||||||
function showTypingIndicator() {
|
marked.setOptions({
|
||||||
const indicator = document.createElement('div');
|
breaks: true, // 支持 GFM 换行
|
||||||
indicator.id = 'typingIndicator';
|
gfm: true // GitHub Flavored Markdown
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏打字指示器
|
return marked.parse(text);
|
||||||
function hideTypingIndicator() {
|
|
||||||
const indicator = document.getElementById('typingIndicator');
|
|
||||||
if (indicator) {
|
|
||||||
indicator.remove();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="marked.min.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
69
works/ai-chat-app/www/marked.min.js
vendored
Normal file
69
works/ai-chat-app/www/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -172,11 +172,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
max-width: 75%;
|
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.user .message-content {
|
.message.user .message-content {
|
||||||
@@ -214,6 +212,10 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.user .message-content pre {
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
/* 加载动画 */
|
/* 加载动画 */
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -297,6 +299,133 @@ body {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 消息结构 */
|
||||||
|
.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.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) {
|
@media (min-width: 768px) {
|
||||||
.message-content {
|
.message-content {
|
||||||
|
|||||||
Reference in New Issue
Block a user