mcp-interactive-feedback-server
Version:
一个简洁高效的MCP服务器,支持AI与用户的实时交互问答
1,263 lines (1,067 loc) • 44.2 kB
HTML
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>AI 交互问答界面</title>
<!-- Bootstrap 5 CSS -->
<link href="libs/bootstrap.min.css" rel="stylesheet">
<script src="libs/marked.min.js"></script>
<link rel="stylesheet" href="libs/github.min.css">
<script src="libs/highlight.min.js"></script>
<style>
html, body {
height: 100%;
overflow: hidden;
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
}
.app-container {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
z-index: 1030;
}
.header h1 {
font-size: 18px;
margin: 0;
font-weight: 600;
}
.header .btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: none;
font-size: 12px;
padding: 6px 12px;
border-radius: 20px;
transition: all 0.3s ease;
}
.header .btn:hover {
background: rgba(255, 255, 255, 0.3);
color: white;
transform: translateY(-1px);
}
.connection-status {
background: rgba(255, 255, 255, 0.1);
padding: 6px 12px;
border-radius: 15px;
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4CAF50;
animation: pulse 2s infinite;
}
.status-indicator.disconnected {
background: #f44336;
animation: none;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f5f5f5;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background: linear-gradient(to bottom, #e3f2fd 0%, #f5f5f5 100%);
}
.message-group {
margin-bottom: 20px;
}
.message-bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
margin-bottom: 8px;
position: relative;
word-wrap: break-word;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: fadeInUp 0.3s ease;
}
.message-copy-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 20;
color: #666;
min-width: 40px;
text-align: center;
}
.message-copy-btn:hover {
background: rgba(255, 255, 255, 1);
border-color: #bbb;
color: #333;
}
.message-bubble:hover .message-copy-btn {
opacity: 1;
}
.message-copy-btn.copied {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
.message-user .message-copy-btn {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
}
.message-user .message-copy-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 1);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-ai {
background: white;
border: 1px solid #e1e5e9;
margin-right: auto;
border-bottom-left-radius: 6px;
}
.message-ai::before {
content: '';
position: absolute;
left: -8px;
bottom: 8px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 8px 8px 0;
border-color: transparent white transparent transparent;
}
.message-user {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin-left: auto;
border-bottom-right-radius: 6px;
}
.message-user::after {
content: '';
position: absolute;
right: -8px;
bottom: 8px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 0 8px 8px;
border-color: transparent transparent #764ba2 transparent;
}
.message-pending {
background: #fff3cd;
border: 1px solid #ffeaa7;
margin-right: auto;
border-bottom-left-radius: 6px;
position: relative;
}
.message-pending::before {
content: '';
position: absolute;
left: -8px;
bottom: 8px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 8px 8px 0;
border-color: transparent #fff3cd transparent transparent;
}
.message-pending .pending-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
color: #856404;
font-style: italic;
}
.typing-indicator {
display: inline-flex;
gap: 4px;
}
.typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #856404;
animation: typing 1.4s infinite ease-in-out;
}
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes typing {
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
}
.avatar-ai {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.avatar-user {
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
color: white;
}
.message-time {
font-size: 11px;
color: #999;
text-align: center;
margin: 8px 0;
}
.message-content {
line-height: 1.5;
}
.message-ai .message-content {
color: #333;
}
.message-user .message-content {
color: white;
}
.input-container {
background: white;
border-top: 1px solid #e1e5e9;
padding: 16px 20px;
flex-shrink: 0;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 12px;
max-width: 100%;
}
.input-field {
flex: 1;
min-height: 40px;
max-height: 120px;
border: 1px solid #e1e5e9;
border-radius: 20px;
padding: 10px 16px;
resize: none;
font-size: 14px;
line-height: 1.4;
transition: border-color 0.3s ease;
overflow-y: auto;
}
.input-field:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.send-button {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
}
.send-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.send-button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
text-align: center;
padding: 40px;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.empty-state-subtitle {
font-size: 14px;
opacity: 0.7;
}
/* Markdown content styles */
.markdown-content {
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin: 12px 0 8px 0;
font-weight: 600;
}
.message-ai .markdown-content h1, .message-ai .markdown-content h2,
.message-ai .markdown-content h3, .message-ai .markdown-content h4,
.message-ai .markdown-content h5, .message-ai .markdown-content h6 {
color: #2c3e50;
}
.message-user .markdown-content h1, .message-user .markdown-content h2,
.message-user .markdown-content h3, .message-user .markdown-content h4,
.message-user .markdown-content h5, .message-user .markdown-content h6 {
color: rgba(255, 255, 255, 0.9);
}
.markdown-content h1 { font-size: 18px; }
.markdown-content h2 { font-size: 16px; }
.markdown-content h3 { font-size: 15px; }
.markdown-content h4 { font-size: 14px; }
.markdown-content p { margin: 6px 0; }
.markdown-content ul, .markdown-content ol { margin: 6px 0; padding-left: 16px; }
.markdown-content li { margin: 2px 0; }
.markdown-content blockquote {
border-left: 3px solid #667eea;
margin: 8px 0;
padding: 6px 12px;
background: rgba(102, 126, 234, 0.1);
border-radius: 4px;
font-style: italic;
}
.message-user .markdown-content blockquote {
border-left-color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.1);
}
.markdown-content code {
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
}
.message-user .markdown-content code {
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
.markdown-content pre {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 8px;
margin: 8px 0;
overflow-x: auto;
position: relative;
}
.message-user .markdown-content pre {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.markdown-content pre code {
background: none;
padding: 0;
font-size: 12px;
}
.copy-button {
position: absolute;
top: 6px;
right: 6px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ddd;
border-radius: 4px;
padding: 2px 6px;
font-size: 11px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
color: #666;
}
.copy-button:hover {
background: rgba(255, 255, 255, 1);
border-color: #bbb;
color: #333;
}
.markdown-content pre:hover .copy-button {
opacity: 1;
}
.copy-button.copied {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
.message-user .copy-button {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
}
.message-user .copy-button:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 1);
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 13px;
}
.markdown-content th, .markdown-content td {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 4px 8px;
text-align: left;
}
.markdown-content th {
background: rgba(0, 0, 0, 0.05);
font-weight: 600;
}
.message-user .markdown-content th {
background: rgba(255, 255, 255, 0.1);
}
.message-user .markdown-content th, .message-user .markdown-content td {
border-color: rgba(255, 255, 255, 0.2);
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.message-user .markdown-content a {
color: rgba(255, 255, 255, 0.9);
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content hr {
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
margin: 12px 0;
}
.message-user .markdown-content hr {
border-top-color: rgba(255, 255, 255, 0.2);
}
/* Mobile responsive */
@media (max-width: 768px) {
.header h1 {
font-size: 16px;
}
.header .btn {
font-size: 11px;
padding: 4px 8px;
}
.connection-status {
font-size: 10px;
padding: 4px 8px;
}
.chat-messages {
padding: 12px;
}
.message-bubble {
max-width: 85%;
padding: 10px 14px;
font-size: 14px;
}
.input-container {
padding: 12px;
}
.input-field {
font-size: 16px; /* Prevent zoom on iOS */
}
.markdown-content {
font-size: 13px;
}
.markdown-content h1 { font-size: 16px; }
.markdown-content h2 { font-size: 15px; }
.markdown-content h3 { font-size: 14px; }
.markdown-content h4 { font-size: 13px; }
/* 手机端消息复制按钮调整 */
.message-copy-btn {
opacity: 1; /* 手机端始终显示 */
font-size: 10px;
padding: 3px 6px;
top: 6px;
right: 6px;
min-width: 35px;
}
/* 手机端代码复制按钮调整 */
.copy-button {
opacity: 1; /* 手机端始终显示 */
font-size: 10px;
padding: 2px 5px;
top: 4px;
right: 4px;
}
}
/* Scrollbar styling */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
</style>
</head>
<body>
<div class="app-container">
<div class="header">
<div class="container-fluid">
<div class="row align-items-center">
<div class="col">
<h1>🤖 AI 智能助手</h1>
</div>
<div class="col-auto">
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm" id="audioPermissionBtn" onclick="app.requestAudioPermission()" title="声音通知权限">
🔇
</button>
<button class="btn btn-sm" onclick="app.clearHistory()">
🗑️ 清空
</button>
<button class="btn btn-sm btn-danger" onclick="app.stopServer()" title="停止服务器">
🛑 停止
</button>
<div class="connection-status" id="connectionStatus">
<div class="status-indicator" id="statusIndicator"></div>
<span id="statusText">连接中...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="chat-container">
<div class="chat-messages" id="chatMessages">
<div class="empty-state" id="emptyState">
<div class="empty-state-icon">💬</div>
<div class="empty-state-title">欢迎使用 AI 智能助手</div>
<div class="empty-state-subtitle">开始对话,我将为您提供帮助</div>
</div>
</div>
<div class="input-container">
<form id="messageForm">
<div class="input-wrapper">
<textarea
id="messageInput"
class="input-field"
placeholder="输入消息..."
rows="1"
required
></textarea>
<button type="submit" class="send-button" id="sendButton">
➤
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Bootstrap 5 JS -->
<script src="libs/bootstrap.bundle.min.js"></script>
<script>
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (err) {}
}
return hljs.highlightAuto(code).value;
},
breaks: true,
gfm: true
});
class ChatApp {
constructor() {
this.ws = null;
this.history = [];
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.baseReconnectDelay = 1000;
this.maxReconnectDelay = 30000;
// 音频相关
this.audioContext = null;
this.audioPermissionGranted = false;
this.connectWebSocket();
this.setupEventListeners();
this.setupAutoResize();
this.initAudio();
}
connectWebSocket() {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket连接已建立');
this.reconnectAttempts = 0;
this.updateConnectionStatus(true);
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
this.handleMessage(data);
} catch (error) {
console.error('解析消息失败:', error);
}
};
this.ws.onclose = () => {
console.log('WebSocket连接已关闭');
this.updateConnectionStatus(false);
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
);
this.reconnectAttempts++;
setTimeout(() => {
this.connectWebSocket();
}, delay);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('创建WebSocket连接失败:', error);
this.updateConnectionStatus(false);
}
}
setupEventListeners() {
document.getElementById('messageForm').addEventListener('submit', (e) => {
e.preventDefault();
this.sendMessage();
});
document.getElementById('messageInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
}
setupAutoResize() {
const messageInput = document.getElementById('messageInput');
messageInput.addEventListener('input', () => {
messageInput.style.height = 'auto';
messageInput.style.height = Math.min(messageInput.scrollHeight, 120) + 'px';
});
}
handleMessage(data) {
switch (data.type) {
case 'history':
console.log('收到历史记录:', data.history.length, '条');
this.loadHistory(data.history);
break;
case 'ai_message':
console.log('收到AI消息:', data.message);
this.loadHistory(data.history);
this.scrollToBottom();
// 播放提示音
this.playNotificationSound();
break;
case 'history_updated':
console.log('历史记录已更新');
this.loadHistory(data.history);
this.scrollToBottom();
break;
case 'history_cleared':
console.log('历史记录已清理');
this.history = [];
this.renderMessages();
break;
case 'error':
alert('错误: ' + data.message);
break;
case 'server_stopping':
console.log('服务器正在关闭:', data.message);
this.updateConnectionStatus(false);
setTimeout(() => {
alert('服务器已关闭。请刷新页面或重新启动服务器。');
}, 2000);
break;
default:
console.log('未知消息类型:', data.type);
}
}
loadHistory(historyData) {
this.history = historyData;
this.renderMessages();
}
renderMessages() {
const chatMessages = document.getElementById('chatMessages');
const emptyState = document.getElementById('emptyState');
// 先清除现有消息,无论历史记录是否为空
const existingMessages = chatMessages.querySelectorAll('.message-group, .message-bubble');
existingMessages.forEach(msg => msg.remove());
if (this.history.length === 0) {
emptyState.style.display = 'flex';
return;
}
emptyState.style.display = 'none';
this.history.forEach((item, index) => {
if (item.type === 'ai') {
this.addAIMessage(item.message, item.timestamp);
} else if (item.type === 'user') {
this.addUserMessage(item.message, item.timestamp);
}
});
this.scrollToBottom();
}
addAIMessage(message, timestamp) {
const chatMessages = document.getElementById('chatMessages');
const messageGroup = document.createElement('div');
messageGroup.className = 'message-group';
if (timestamp) {
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
timeDiv.textContent = new Date(timestamp).toLocaleString();
messageGroup.appendChild(timeDiv);
}
const messageDiv = document.createElement('div');
messageDiv.className = 'message-bubble message-ai';
const avatarDiv = document.createElement('div');
avatarDiv.className = 'message-avatar avatar-ai';
avatarDiv.textContent = '🤖';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content markdown-content';
contentDiv.innerHTML = marked.parse(message);
// 添加消息复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'message-copy-btn';
copyBtn.textContent = '复制';
copyBtn.title = '复制消息';
copyBtn.addEventListener('click', () => this.copyMessage(message, copyBtn));
messageDiv.appendChild(contentDiv);
messageDiv.appendChild(copyBtn);
messageGroup.appendChild(messageDiv);
chatMessages.appendChild(messageGroup);
// Highlight code blocks
messageDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Add copy buttons to code blocks
this.addCopyButtons(messageDiv);
}
addUserMessage(message, timestamp) {
const chatMessages = document.getElementById('chatMessages');
const messageGroup = document.createElement('div');
messageGroup.className = 'message-group';
const messageDiv = document.createElement('div');
messageDiv.className = 'message-bubble message-user';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content markdown-content';
contentDiv.innerHTML = marked.parse(message);
// 添加消息复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'message-copy-btn';
copyBtn.textContent = '复制';
copyBtn.title = '复制消息';
copyBtn.addEventListener('click', () => this.copyMessage(message, copyBtn));
messageDiv.appendChild(contentDiv);
messageDiv.appendChild(copyBtn);
messageGroup.appendChild(messageDiv);
if (timestamp) {
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
timeDiv.textContent = new Date(timestamp).toLocaleString();
messageGroup.appendChild(timeDiv);
}
chatMessages.appendChild(messageGroup);
// Highlight code blocks
messageDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Add copy buttons to code blocks
this.addCopyButtons(messageDiv);
}
sendMessage() {
const messageInput = document.getElementById('messageInput');
const message = messageInput.value.trim();
if (!message) return;
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// 只发送消息到服务器,不在前端立即显示
// 等待服务器的history_updated消息来统一更新界面
this.ws.send(JSON.stringify({
type: 'user_message',
message: message
}));
console.log('发送用户消息:', message);
}
messageInput.value = '';
messageInput.style.height = 'auto';
// 移除立即滚动,等历史更新后再滚动
}
scrollToBottom() {
const chatMessages = document.getElementById('chatMessages');
setTimeout(() => {
chatMessages.scrollTop = chatMessages.scrollHeight;
}, 100);
}
updateConnectionStatus(connected) {
const indicator = document.getElementById('statusIndicator');
const statusText = document.getElementById('statusText');
if (indicator && statusText) {
if (connected) {
statusText.textContent = '已连接';
indicator.className = 'status-indicator';
} else {
statusText.textContent = '已断开';
indicator.className = 'status-indicator disconnected';
}
}
}
clearHistory() {
if (confirm('确定要清空所有聊天记录吗?')) {
// 只发送清空请求给服务器,不在本地立即清空
// 等待服务器的 history_cleared 消息来更新界面
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'clear_history'
}));
}
}
}
stopServer() {
if (confirm('确定要停止服务器吗?这将关闭整个应用程序。')) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'stop_server'
}));
}
this.updateConnectionStatus(false);
}
}
initAudio() {
// 检查音频权限状态
this.checkAudioPermission();
}
async checkAudioPermission() {
try {
// 检查浏览器是否支持Web Audio API
if (!window.AudioContext && !window.webkitAudioContext) {
console.log('浏览器不支持Web Audio API');
this.updateAudioButton(false, '不支持');
return;
}
// 尝试创建AudioContext来检查权限
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
// 检查是否已经有权限
if (this.audioContext && this.audioContext.state === 'running') {
this.audioPermissionGranted = true;
this.updateAudioButton(true, '已授权');
return;
}
// 更新按钮状态为未授权
this.updateAudioButton(false, '需要授权');
} catch (error) {
console.error('检查音频权限失败:', error);
this.updateAudioButton(false, '检查失败');
}
}
async requestAudioPermission() {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!this.audioContext) {
this.audioContext = new AudioContextClass();
}
// 如果AudioContext处于suspended状态,需要用户交互来激活
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
// 播放一个测试音来确认权限
await this.playNotificationSound();
this.audioPermissionGranted = true;
this.updateAudioButton(true, '已授权');
console.log('音频权限已获取');
} catch (error) {
console.error('请求音频权限失败:', error);
this.updateAudioButton(false, '授权失败');
alert('音频权限获取失败,请检查浏览器设置');
}
}
updateAudioButton(hasPermission, tooltip) {
const btn = document.getElementById('audioPermissionBtn');
if (btn) {
if (hasPermission) {
btn.innerHTML = '🔊';
btn.className = 'btn btn-sm btn-success';
btn.title = `声音通知: ${tooltip}`;
} else {
btn.innerHTML = '🔇';
btn.className = 'btn btn-sm btn-warning';
btn.title = `声音通知: ${tooltip}`;
}
}
}
addCopyButtons(messageElement) {
const codeBlocks = messageElement.querySelectorAll('pre');
codeBlocks.forEach(pre => {
// 检查是否已经有复制按钮
if (pre.querySelector('.copy-button')) {
return;
}
const copyButton = document.createElement('button');
copyButton.className = 'copy-button';
copyButton.textContent = '复制';
copyButton.title = '复制代码';
copyButton.addEventListener('click', async () => {
const codeElement = pre.querySelector('code');
const code = codeElement ? codeElement.textContent : pre.textContent;
try {
await navigator.clipboard.writeText(code);
copyButton.textContent = '已复制';
copyButton.classList.add('copied');
setTimeout(() => {
copyButton.textContent = '复制';
copyButton.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('复制失败:', err);
// 降级方案:使用传统方法复制
this.fallbackCopyText(code);
copyButton.textContent = '已复制';
copyButton.classList.add('copied');
setTimeout(() => {
copyButton.textContent = '复制';
copyButton.classList.remove('copied');
}, 2000);
}
});
pre.appendChild(copyButton);
});
}
fallbackCopyText(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
} catch (err) {
console.error('降级复制也失败了:', err);
}
document.body.removeChild(textArea);
}
async copyMessage(message, button) {
try {
await navigator.clipboard.writeText(message);
button.textContent = '已复制';
button.classList.add('copied');
setTimeout(() => {
button.textContent = '复制';
button.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('复制消息失败:', err);
// 降级方案:使用传统方法复制
this.fallbackCopyText(message);
button.textContent = '已复制';
button.classList.add('copied');
setTimeout(() => {
button.textContent = '复制';
button.classList.remove('copied');
}, 2000);
}
}
async playNotificationSound() {
if (!this.audioContext || !this.audioPermissionGranted) {
console.log('音频未初始化或无权限');
return;
}
try {
// 创建一个1秒左右的提示音
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
// 连接音频节点
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
// 设置音频参数 - 创建一个愉快的提示音序列
oscillator.type = 'sine';
const currentTime = this.audioContext.currentTime;
// 音符序列:C5 -> E5 -> G5 -> C6 (大约1秒)
oscillator.frequency.setValueAtTime(523, currentTime); // C5
oscillator.frequency.setValueAtTime(659, currentTime + 0.25); // E5
oscillator.frequency.setValueAtTime(784, currentTime + 0.5); // G5
oscillator.frequency.setValueAtTime(1047, currentTime + 0.75); // C6
// 设置音量包络 - 1秒总时长
gainNode.gain.setValueAtTime(0, currentTime);
gainNode.gain.linearRampToValueAtTime(0.2, currentTime + 0.1);
gainNode.gain.setValueAtTime(0.2, currentTime + 0.8);
gainNode.gain.linearRampToValueAtTime(0, currentTime + 1.0);
// 播放声音 - 1秒时长
oscillator.start(currentTime);
oscillator.stop(currentTime + 1.0);
} catch (error) {
console.error('播放提示音失败:', error);
}
}
}
// 初始化应用
const app = new ChatApp();
</script>
</body>
</html>