功能特点: - 支持8种方言(普通话、四川话、粤语、上海话、客家话、闽南话、东北话、河南话) - 用户注册登录系统(用户名、手机、邮箱可选、密码确认) - 对话功能(文字输入、语音识别、文件图片上传) - 多对话管理(新建、切换、删除) - 移动端适配(响应式设计、触摸友好) 技术栈: - Flask后端API - 原生HTML/CSS/JS前端 - Web Speech API语音识别 - JWT用户认证 - 大模型: qwen3.5-4b 端口: 19002
531 lines
22 KiB
HTML
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> |