Files
dialect-chat/frontend/chat.html
hubian 376cdacf5e 初始化方言版AI对话助手
功能特点:
- 支持8种方言(普通话、四川话、粤语、上海话、客家话、闽南话、东北话、河南话)
- 用户注册登录系统(用户名、手机、邮箱可选、密码确认)
- 对话功能(文字输入、语音识别、文件图片上传)
- 多对话管理(新建、切换、删除)
- 移动端适配(响应式设计、触摸友好)

技术栈:
- Flask后端API
- 原生HTML/CSS/JS前端
- Web Speech API语音识别
- JWT用户认证
- 大模型: qwen3.5-4b

端口: 19002
2026-04-08 12:09:46 +08:00

531 lines
22 KiB
HTML

<!DOCTYPE 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>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
* { -webkit-tap-highlight-color: transparent; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.gradient-bg { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.chat-bubble { max-width: 85%; }
.chat-bubble-user { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.chat-bubble-ai { background: #f3f4f6; }
.sidebar { transform: translateX(-100%); transition: transform 0.3s; }
.sidebar.open { transform: translateX(0); }
.dialect-select { max-height: 50vh; overflow-y: auto; }
.message-content img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
.recording { animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.typing-indicator span { animation: blink 1.4s infinite both; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } }
</style>
</head>
<body class="bg-gray-100 h-screen overflow-hidden">
<!-- 侧边栏遮罩 -->
<div id="sidebarOverlay" class="fixed inset-0 bg-black/50 z-40 hidden" onclick="closeSidebar()"></div>
<!-- 侧边栏 -->
<aside id="sidebar" class="sidebar fixed left-0 top-0 h-full w-72 bg-white z-50 shadow-xl flex flex-col">
<div class="gradient-bg text-white p-4">
<div class="flex items-center justify-between">
<h2 class="font-bold text-lg">🗣️ 方言AI助手</h2>
<button onclick="closeSidebar()" class="p-1"><i class="ri-close-line text-xl"></i></button>
</div>
<div class="mt-3 text-sm text-white/80" id="userInfo">加载中...</div>
</div>
<!-- 新建对话按钮 -->
<div class="p-3 border-b">
<button onclick="createNewChat()" class="w-full py-2.5 border-2 border-dashed border-purple-300 text-purple-600 rounded-lg hover:bg-purple-50 flex items-center justify-center gap-2">
<i class="ri-add-line"></i> 新建对话
</button>
</div>
<!-- 对话列表 -->
<div class="flex-1 overflow-y-auto" id="chatList">
<div class="p-4 text-center text-gray-400 text-sm">加载中...</div>
</div>
<!-- 底部操作 -->
<div class="p-3 border-t space-y-2">
<button onclick="showDialectModal()" class="w-full py-2 bg-gray-100 rounded-lg text-gray-700 flex items-center justify-center gap-2">
<i class="ri-translate-2"></i> 切换方言
</button>
<button onclick="logout()" class="w-full py-2 bg-red-50 text-red-500 rounded-lg flex items-center justify-center gap-2">
<i class="ri-logout-box-line"></i> 退出登录
</button>
</div>
</aside>
<!-- 主内容区 -->
<div class="h-full flex flex-col">
<!-- 顶部栏 -->
<header class="gradient-bg text-white p-4 flex items-center gap-3 shadow-lg">
<button onclick="openSidebar()" class="p-1"><i class="ri-menu-line text-xl"></i></button>
<div class="flex-1">
<h1 class="font-medium" id="chatTitle">新对话</h1>
<p class="text-xs text-white/70" id="currentDialect">普通话</p>
</div>
<button onclick="deleteCurrentChat()" class="p-1 text-white/70 hover:text-white">
<i class="ri-delete-bin-line text-xl"></i>
</button>
</header>
<!-- 消息区域 -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" id="messagesContainer">
<!-- 欢迎消息 -->
<div class="text-center py-8">
<div class="text-6xl mb-4">🗣️</div>
<h2 class="text-xl font-bold text-gray-800">方言AI助手</h2>
<p class="text-gray-500 mt-2">用家乡话聊天,更亲切</p>
<div class="mt-4 text-sm text-gray-400">
当前方言: <span id="welcomeDialect">普通话</span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="bg-white border-t p-3">
<!-- 图片预览 -->
<div id="imagePreview" class="hidden mb-2 relative inline-block">
<img src="" class="h-20 rounded-lg">
<button onclick="clearImagePreview()" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs">
<i class="ri-close-line"></i>
</button>
</div>
<div class="flex items-end gap-2">
<!-- 工具按钮 -->
<div class="flex gap-1">
<label class="p-2 text-gray-400 hover:text-purple-500 cursor-pointer">
<i class="ri-image-line text-xl"></i>
<input type="file" accept="image/*" class="hidden" onchange="handleImageUpload(event)">
</label>
<label class="p-2 text-gray-400 hover:text-purple-500 cursor-pointer">
<i class="ri-attachment-line text-xl"></i>
<input type="file" class="hidden" onchange="handleFileUpload(event)">
</label>
</div>
<!-- 输入框 -->
<div class="flex-1 relative">
<textarea id="messageInput" placeholder="输入消息..."
class="w-full px-4 py-2.5 border border-gray-200 rounded-2xl resize-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
rows="1" style="max-height: 120px;"
onkeydown="handleKeyDown(event)"></textarea>
</div>
<!-- 发送/语音按钮 -->
<button id="voiceBtn" onclick="toggleVoice()" class="p-2.5 text-gray-400 hover:text-purple-500">
<i class="ri-mic-line text-xl"></i>
</button>
<button onclick="sendMessage()" class="p-2.5 gradient-bg text-white rounded-full">
<i class="ri-send-plane-fill text-xl"></i>
</button>
</div>
</div>
</div>
<!-- 方言选择弹窗 -->
<div id="dialectModal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center p-4">
<div class="bg-white rounded-2xl w-full max-w-sm">
<div class="p-4 border-b flex justify-between items-center">
<h3 class="font-bold text-lg">选择方言</h3>
<button onclick="closeDialectModal()" class="text-gray-400"><i class="ri-close-line text-xl"></i></button>
</div>
<div class="dialect-select p-2" id="dialectList">
<!-- 方言列表 -->
</div>
</div>
</div>
<script>
const API_URL = '';
let token = localStorage.getItem('token');
let user = JSON.parse(localStorage.getItem('user') || '{}');
let currentChatId = null;
let dialects = {};
let currentDialect = 'mandarin';
let isRecording = false;
let recognition = null;
let uploadedImage = null;
// 初始化
document.addEventListener('DOMContentLoaded', () => {
if (!token) {
window.location.href = '/';
return;
}
loadDialects();
loadChatList();
initVoiceRecognition();
});
// 加载方言列表
async function loadDialects() {
try {
const res = await fetch('/api/dialects');
dialects = await res.json();
renderDialectList();
} catch (e) {
console.error('加载方言失败', e);
}
}
function renderDialectList() {
const container = document.getElementById('dialectList');
container.innerHTML = Object.entries(dialects).map(([key, val]) => `
<button onclick="selectDialect('${key}')"
class="w-full p-3 text-left rounded-lg hover:bg-purple-50 flex items-center justify-between ${currentDialect === key ? 'bg-purple-50 text-purple-600' : ''}">
<span>${val.name}</span>
${currentDialect === key ? '<i class="ri-check-line"></i>' : ''}
</button>
`).join('');
}
// 加载对话列表
async function loadChatList() {
try {
const res = await fetch('/api/chats', {
headers: { 'Authorization': `Bearer ${token}` }
});
const chats = await res.json();
renderChatList(chats);
document.getElementById('userInfo').textContent = `欢迎, ${user.username || '用户'}`;
// 如果有对话,加载第一个
if (chats.length > 0) {
loadChat(chats[0].id);
}
} catch (e) {
console.error('加载对话列表失败', e);
}
}
function renderChatList(chats) {
const container = document.getElementById('chatList');
if (chats.length === 0) {
container.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">暂无对话</div>';
return;
}
container.innerHTML = chats.map(chat => `
<div onclick="loadChat('${chat.id}')"
class="p-3 border-b hover:bg-gray-50 cursor-pointer ${currentChatId === chat.id ? 'bg-purple-50' : ''}">
<div class="font-medium text-gray-800 truncate">${chat.title}</div>
<div class="text-xs text-gray-400 mt-1">${dialects[chat.dialect]?.name || '普通话'}</div>
</div>
`).join('');
}
// 创建新对话
async function createNewChat() {
try {
const res = await fetch('/api/chats', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ dialect: currentDialect })
});
const chat = await res.json();
currentChatId = chat.id;
document.getElementById('chatTitle').textContent = chat.title;
document.getElementById('messagesContainer').innerHTML = `
<div class="text-center py-8">
<div class="text-6xl mb-4">🗣️</div>
<h2 class="text-xl font-bold text-gray-800">方言AI助手</h2>
<p class="text-gray-500 mt-2">${dialects[currentDialect]?.greeting || '你好!有什么我可以帮助你的吗?'}</p>
</div>
`;
loadChatList();
closeSidebar();
} catch (e) {
alert('创建对话失败');
}
}
// 加载对话
async function loadChat(chatId) {
try {
const res = await fetch(`/api/chats/${chatId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const chat = await res.json();
currentChatId = chatId;
currentDialect = chat.dialect;
document.getElementById('chatTitle').textContent = chat.title;
document.getElementById('currentDialect').textContent = dialects[currentDialect]?.name || '普通话';
document.getElementById('welcomeDialect').textContent = dialects[currentDialect]?.name || '普通话';
renderMessages(chat.messages);
closeSidebar();
loadChatList();
} catch (e) {
console.error('加载对话失败', e);
}
}
function renderMessages(messages) {
const container = document.getElementById('messagesContainer');
if (messages.length === 0) {
container.innerHTML = `
<div class="text-center py-8">
<div class="text-6xl mb-4">🗣️</div>
<h2 class="text-xl font-bold text-gray-800">方言AI助手</h2>
<p class="text-gray-500 mt-2">${dialects[currentDialect]?.greeting || '你好!'}</p>
</div>
`;
return;
}
container.innerHTML = messages.map(msg => `
<div class="flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}">
<div class="chat-bubble ${msg.role === 'user' ? 'chat-bubble-user text-white' : 'chat-bubble-ai text-gray-800'} p-3 rounded-2xl">
<div class="message-content text-sm whitespace-pre-wrap">${msg.content}</div>
</div>
</div>
`).join('');
container.scrollTop = container.scrollHeight;
}
// 发送消息
async function sendMessage() {
const input = document.getElementById('messageInput');
let content = input.value.trim();
if (!currentChatId) {
await createNewChat();
}
if (uploadedImage) {
content = uploadedImage + '\n' + content;
uploadedImage = null;
document.getElementById('imagePreview').classList.add('hidden');
}
if (!content) return;
input.value = '';
input.style.height = 'auto';
// 显示用户消息
addMessage('user', content);
// 显示加载动画
const loadingId = showLoading();
try {
const res = await fetch(`/api/chats/${currentChatId}/send`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content })
});
const data = await res.json();
removeLoading(loadingId);
if (data.ai_message) {
addMessage('assistant', data.ai_message.content);
document.getElementById('chatTitle').textContent = data.ai_message.content.substring(0, 20) + '...';
}
} catch (e) {
removeLoading(loadingId);
addMessage('assistant', '抱歉,网络出现问题了,请稍后再试。');
}
}
function addMessage(role, content) {
const container = document.getElementById('messagesContainer');
const div = document.createElement('div');
div.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'}`;
div.innerHTML = `
<div class="chat-bubble ${role === 'user' ? 'chat-bubble-user text-white' : 'chat-bubble-ai text-gray-800'} p-3 rounded-2xl">
<div class="message-content text-sm whitespace-pre-wrap">${content}</div>
</div>
`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function showLoading() {
const container = document.getElementById('messagesContainer');
const id = 'loading-' + Date.now();
const div = document.createElement('div');
div.id = id;
div.className = 'flex justify-start';
div.innerHTML = `
<div class="chat-bubble chat-bubble-ai p-3 rounded-2xl">
<div class="typing-indicator flex gap-1">
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
</div>
</div>
`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return id;
}
function removeLoading(id) {
document.getElementById(id)?.remove();
}
// 语音识别
function initVoiceRecognition() {
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = true;
recognition.lang = 'zh-CN';
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
document.getElementById('messageInput').value = transcript;
};
recognition.onend = () => {
isRecording = false;
document.getElementById('voiceBtn').innerHTML = '<i class="ri-mic-line text-xl"></i>';
document.getElementById('voiceBtn').classList.remove('text-red-500', 'recording');
};
}
}
function toggleVoice() {
if (!recognition) {
alert('您的浏览器不支持语音识别');
return;
}
if (isRecording) {
recognition.stop();
} else {
recognition.start();
isRecording = true;
document.getElementById('voiceBtn').innerHTML = '<i class="ri-mic-fill text-xl"></i>';
document.getElementById('voiceBtn').classList.add('text-red-500', 'recording');
}
}
// 图片上传
function handleImageUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
uploadedImage = `[图片: ${file.name}]`;
const preview = document.getElementById('imagePreview');
preview.querySelector('img').src = e.target.result;
preview.classList.remove('hidden');
};
reader.readAsDataURL(file);
}
function clearImagePreview() {
uploadedImage = null;
document.getElementById('imagePreview').classList.add('hidden');
}
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
document.getElementById('messageInput').value += `\n[文件: ${file.name}]`;
}
// 方言选择
function showDialectModal() {
renderDialectList();
document.getElementById('dialectModal').classList.remove('hidden');
document.getElementById('dialectModal').classList.add('flex');
}
function closeDialectModal() {
document.getElementById('dialectModal').classList.add('hidden');
document.getElementById('dialectModal').classList.remove('flex');
}
function selectDialect(key) {
currentDialect = key;
document.getElementById('currentDialect').textContent = dialects[key]?.name || '普通话';
document.getElementById('welcomeDialect').textContent = dialects[key]?.name || '普通话';
closeDialectModal();
}
// 侧边栏
function openSidebar() {
document.getElementById('sidebar').classList.add('open');
document.getElementById('sidebarOverlay').classList.remove('hidden');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebarOverlay').classList.add('hidden');
}
// 删除对话
async function deleteCurrentChat() {
if (!currentChatId || !confirm('确定删除这个对话吗?')) return;
try {
await fetch(`/api/chats/${currentChatId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
currentChatId = null;
loadChatList();
document.getElementById('messagesContainer').innerHTML = `
<div class="text-center py-8">
<div class="text-6xl mb-4">🗣️</div>
<h2 class="text-xl font-bold text-gray-800">方言AI助手</h2>
<p class="text-gray-500 mt-2">点击"新建对话"开始聊天</p>
</div>
`;
} catch (e) {
alert('删除失败');
}
}
// 退出登录
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/';
}
// 输入框事件
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
// 自动调整输入框高度
document.getElementById('messageInput').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
</script>
</body>
</html>