Compare commits

4 Commits

Author SHA1 Message Date
ba2b91b45f fix: 更新版本号到v3.15.2强制刷新浏览器缓存 2026-04-29 17:09:52 +08:00
a7383396db feat: 添加TTS语音播放功能
Backend:
- 新增edge-tts依赖
- 新增/api/tts API生成语音
- 新增/api/tts/voices API获取语音列表
- 系统配置新增tts_provider和tts_voice字段

前端app.js:
- 新增TTS状态变量(enableTTS, ttsVoice, ttsQueue等)
- 智能体对话和普通对话header添加TTS开关按钮
- 消息操作栏添加语音播放按钮
- 实现playTTS和cleanTTS函数
- 加载后台TTS配置

前端admin.js:
- 系统设置页面添加TTS方案和语音配置
- 支持选择晓晓/云希/云扬/晓伊等中文语音

前端style.css:
- 新增.tts-btn样式
2026-04-29 16:58:53 +08:00
da71f99db4 fix: API返回对话配置选择的LLM能力而非默认LLM
- /api/config 根据chat_config.llm_config_id获取对应LLM配置
- 解决用户选择有视觉能力的LLM但前端不显示上传图片按钮的问题
- 如果找不到对应LLM,回退到默认LLM配置
2026-04-29 16:33:30 +08:00
d75f537df5 fix: 历史对话列表不显示空对话
- 新建对话但没有发送消息时,不显示在历史列表中
- normalConversations过滤条件增加 messages.length > 0
- 同时过滤搜索结果的空对话
2026-04-29 16:29:41 +08:00
6 changed files with 272 additions and 21 deletions

View File

@@ -4,13 +4,15 @@ AI Chat App - 后台管理服务
端口: 19020 (与前端同一端口) 端口: 19020 (与前端同一端口)
""" """
from flask import Flask, jsonify, request, send_from_directory from flask import Flask, jsonify, request, send_from_directory, Response
from flask_cors import CORS from flask_cors import CORS
import os import os
import json import json
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
import hashlib import hashlib
import asyncio
import edge_tts
app = Flask(__name__, static_folder='../www') app = Flask(__name__, static_folder='../www')
CORS(app) CORS(app)
@@ -244,6 +246,8 @@ def init_db():
('guest_chat_messages', '20', '游客每日对话消息限制'), ('guest_chat_messages', '20', '游客每日对话消息限制'),
('guest_agent_messages', '20', '游客每日智能体消息限制'), ('guest_agent_messages', '20', '游客每日智能体消息限制'),
('admin_password', 'admin123', '管理员密码'), ('admin_password', 'admin123', '管理员密码'),
('tts_provider', 'edge', 'TTS方案'),
('tts_voice', 'zh-CN-XiaoxiaoNeural', 'TTS语音'),
] ]
for key, value, desc in default_configs: for key, value, desc in default_configs:
cursor.execute('INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)', (key, value, desc)) cursor.execute('INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)', (key, value, desc))
@@ -955,7 +959,17 @@ def get_frontend_config():
conn = get_db() conn = get_db()
cursor = conn.cursor() cursor = conn.cursor()
# 获取默认LLM配置 # 获取默认对话配置
cursor.execute('SELECT * FROM chat_configs WHERE is_default=1 LIMIT 1')
chat_config = cursor.fetchone()
# 根据对话配置的llm_config_id获取LLM配置而不是默认LLM
llm_config_id = chat_config['llm_config_id'] if chat_config else 1
cursor.execute('SELECT * FROM llm_configs WHERE id=? AND is_active=1', (llm_config_id,))
llm = cursor.fetchone()
# 如果没找到使用默认LLM
if not llm:
cursor.execute('SELECT * FROM llm_configs WHERE is_default=1 AND is_active=1 LIMIT 1') cursor.execute('SELECT * FROM llm_configs WHERE is_default=1 AND is_active=1 LIMIT 1')
llm = cursor.fetchone() llm = cursor.fetchone()
@@ -967,10 +981,6 @@ def get_frontend_config():
cursor.execute('SELECT agent_id, name, avatar, category, description, system_prompt, heat, tags, enable_tools FROM agents WHERE is_online=1 AND is_active=1') cursor.execute('SELECT agent_id, name, avatar, category, description, system_prompt, heat, tags, enable_tools FROM agents WHERE is_online=1 AND is_active=1')
agents = [dict(row) for row in cursor.fetchall()] agents = [dict(row) for row in cursor.fetchall()]
# 获取默认对话配置
cursor.execute('SELECT * FROM chat_configs WHERE is_default=1 LIMIT 1')
chat_config = cursor.fetchone()
# 获取系统配置 # 获取系统配置
cursor.execute('SELECT key, value FROM system_configs') cursor.execute('SELECT key, value FROM system_configs')
system = {row['key']: row['value'] for row in cursor.fetchall()} system = {row['key']: row['value'] for row in cursor.fetchall()}
@@ -990,13 +1000,63 @@ def get_frontend_config():
'chatSessions': int(system.get('guest_chat_sessions', '1')), 'chatSessions': int(system.get('guest_chat_sessions', '1')),
'chatMessages': int(system.get('guest_chat_messages', '20')), 'chatMessages': int(system.get('guest_chat_messages', '20')),
'agentMessages': int(system.get('guest_agent_messages', '20')), 'agentMessages': int(system.get('guest_agent_messages', '20')),
} },
'ttsProvider': system.get('tts_provider', 'edge'),
'ttsVoice': system.get('tts_voice', 'zh-CN-XiaoxiaoNeural'),
} }
} }
return jsonify(config) return jsonify(config)
# ==================== TTS 语音合成 ====================
@app.route('/api/tts', methods=['POST'])
def generate_tts():
"""使用 Edge TTS 生成语音"""
data = request.json
text = data.get('text', '')
voice = data.get('voice', 'zh-CN-XiaoxiaoNeural') # 默认中文女声
if not text:
return jsonify({'error': '缺少文本内容'}), 400
try:
# 使用 asyncio 运行 edge_tts
async def generate_audio():
communicate = edge_tts.Communicate(text, voice)
audio_data = b''
for chunk in communicate.stream_sync():
if chunk['type'] == 'audio':
audio_data += chunk['data']
return audio_data
audio_data = asyncio.run(generate_audio())
# 返回音频数据MP3格式
return Response(audio_data, mimetype='audio/mpeg')
except Exception as e:
return jsonify({'error': f'TTS生成失败: {str(e)}'}), 500
@app.route('/api/tts/voices', methods=['GET'])
def get_tts_voices():
"""获取可用的 TTS 语音列表"""
try:
voices = asyncio.run(edge_tts.list_voices())
# 过滤中文语音
chinese_voices = [v for v in voices if v['Locale'].startswith('zh-')]
voice_list = [{
'name': v['ShortName'],
'gender': v['Gender'],
'locale': v['Locale']
} for v in chinese_voices]
return jsonify({'voices': voice_list})
except Exception as e:
return jsonify({'error': f'获取语音列表失败: {str(e)}'}), 500
# ==================== 启动 ==================== # ==================== 启动 ====================
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -1,2 +1,3 @@
flask>=2.0.0 flask>=2.0.0
flask-cors>=3.0.0 flask-cors>=3.0.0
edge-tts>=6.0.0

View File

@@ -1264,6 +1264,25 @@ async function loadSystemPage(content) {
<input type="text" class="form-input" id="adminPassword" value="${systemConfigs.admin_password?.value || ''}" placeholder="修改管理员密码"> <input type="text" class="form-input" id="adminPassword" value="${systemConfigs.admin_password?.value || ''}" placeholder="修改管理员密码">
</div> </div>
<h3 style="margin: 24px 0 16px; padding-top: 16px; border-top: 1px solid #e2e8f0;">TTS语音配置</h3>
<div class="form-group">
<label class="form-label">TTS方案</label>
<select class="form-select" id="ttsProvider">
<option value="edge" ${systemConfigs.tts_provider?.value === 'edge' ? 'selected' : ''}>Edge TTS (微软)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">TTS语音</label>
<select class="form-select" id="ttsVoice">
<option value="zh-CN-XiaoxiaoNeural" ${systemConfigs.tts_voice?.value === 'zh-CN-XiaoxiaoNeural' ? 'selected' : ''}>晓晓 (女声)</option>
<option value="zh-CN-YunxiNeural" ${systemConfigs.tts_voice?.value === 'zh-CN-YunxiNeural' ? 'selected' : ''}>云希 (男声)</option>
<option value="zh-CN-YunyangNeural" ${systemConfigs.tts_voice?.value === 'zh-CN-YunyangNeural' ? 'selected' : ''}>云扬 (男声)</option>
<option value="zh-CN-XiaoyiNeural" ${systemConfigs.tts_voice?.value === 'zh-CN-XiaoyiNeural' ? 'selected' : ''}>晓伊 (女声)</option>
</select>
</div>
<button class="form-submit" onclick="saveSystemConfig()">保存设置</button> <button class="form-submit" onclick="saveSystemConfig()">保存设置</button>
</div> </div>
@@ -1282,7 +1301,9 @@ async function saveSystemConfig() {
guest_chat_sessions: document.getElementById('guestChatSessions').value, guest_chat_sessions: document.getElementById('guestChatSessions').value,
guest_chat_messages: document.getElementById('guestChatMessages').value, guest_chat_messages: document.getElementById('guestChatMessages').value,
guest_agent_messages: document.getElementById('guestAgentMessages').value, guest_agent_messages: document.getElementById('guestAgentMessages').value,
admin_password: document.getElementById('adminPassword').value admin_password: document.getElementById('adminPassword').value,
tts_provider: document.getElementById('ttsProvider').value,
tts_voice: document.getElementById('ttsVoice').value
}; };
await fetchAPI('/api/admin/system', 'POST', data); await fetchAPI('/api/admin/system', 'POST', data);

View File

@@ -123,6 +123,11 @@ async function loadBackendConfig() {
console.log('LLM能力: 思考模式=', llmCapabilities.thinking, '视觉=', llmCapabilities.vision); console.log('LLM能力: 思考模式=', llmCapabilities.thinking, '视觉=', llmCapabilities.vision);
} }
// 加载 TTS 配置
if (backendConfig.system) {
ttsVoice = backendConfig.system.ttsVoice || 'zh-CN-XiaoxiaoNeural';
}
updateAgentsDisplay(); updateAgentsDisplay();
console.log('后台配置已加载', backendConfig); console.log('后台配置已加载', backendConfig);
} catch (e) { } catch (e) {
@@ -296,6 +301,13 @@ let llmCapabilities = {
vision: false // 是否支持视觉能力 vision: false // 是否支持视觉能力
}; };
// TTS 语音播放状态
let enableTTS = false; // 是否启用语音播放(新建对话默认关闭)
let currentPlayingAudio = null; // 当前播放的音频对象
let ttsVoice = 'zh-CN-XiaoxiaoNeural'; // TTS 语音
let ttsQueue = []; // TTS 待播放队列
let isTTSPlaying = false; // 是否正在播放队列
// DOM 元素(初始为 null在 openConversation 时重新获取) // DOM 元素(初始为 null在 openConversation 时重新获取)
let appContainer = null; let appContainer = null;
let messagesContainer = null; let messagesContainer = null;
@@ -521,8 +533,8 @@ function bindPageEvents() {
// ==================== 对话页面 ==================== // ==================== 对话页面 ====================
function renderChatsPage() { function renderChatsPage() {
// 只显示没有智能体的普通对话 // 只显示没有智能体的普通对话,且必须有消息内容(空对话不显示)
const normalConversations = conversations.filter(conv => !conv.agentId); const normalConversations = conversations.filter(conv => !conv.agentId && conv.messages && conv.messages.length > 0);
return ` return `
<div class="chats-page"> <div class="chats-page">
@@ -2915,6 +2927,9 @@ function showAgentChatPage() {
<p class="agent-desc-header">${currentAgent.desc}</p> <p class="agent-desc-header">${currentAgent.desc}</p>
</div> </div>
</div> </div>
<button class="feature-btn tts-btn ${enableTTS ? 'active' : ''}" id="ttsBtn" title="语音播放">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.54 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button>
</header> </header>
<div class="messages-container" id="messagesContainer"> <div class="messages-container" id="messagesContainer">
@@ -3033,6 +3048,21 @@ function showAgentChatPage() {
}); });
} }
// 绑定 TTS 开关按钮(智能体对话)
const ttsBtn = document.getElementById('ttsBtn');
if (ttsBtn) {
ttsBtn.addEventListener('click', () => {
enableTTS = !enableTTS;
ttsBtn.classList.toggle('active', enableTTS);
showToast(enableTTS ? '语音播放已开启' : '语音播放已关闭');
// 如果关闭,停止当前播放
if (!enableTTS && currentPlayingAudio) {
currentPlayingAudio.pause();
currentPlayingAudio = null;
}
});
}
// 绑定输入事件 // 绑定输入事件
userInput.addEventListener('keydown', handleKeyDown); userInput.addEventListener('keydown', handleKeyDown);
userInput.addEventListener('input', (e) => autoResize(e.target)); userInput.addEventListener('input', (e) => autoResize(e.target));
@@ -3194,8 +3224,8 @@ function searchConversations(keyword) {
keyword = keyword.toLowerCase(); keyword = keyword.toLowerCase();
// 只搜索没有智能体的普通对话 // 只搜索没有智能体的普通对话,且必须有消息内容
const normalConversations = conversations.filter(conv => !conv.agentId); const normalConversations = conversations.filter(conv => !conv.agentId && conv.messages && conv.messages.length > 0);
// 搜索标题和消息内容 // 搜索标题和消息内容
const results = normalConversations.filter(conv => { const results = normalConversations.filter(conv => {
@@ -3380,8 +3410,8 @@ function openConversation(id) {
<span class="logo">🤖</span> <span class="logo">🤖</span>
<h1>${escapeHtml(currentConversation.title)}</h1> <h1>${escapeHtml(currentConversation.title)}</h1>
</div> </div>
<button class="clear-btn" id="clearBtn" title="清空对话"> <button class="feature-btn tts-btn ${enableTTS ? 'active' : ''}" id="ttsBtn" title="语音播放">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.54 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button> </button>
</header> </header>
@@ -3496,6 +3526,21 @@ function openConversation(id) {
}); });
} }
// 绑定 TTS 开关按钮(普通对话)
const ttsBtn = document.getElementById('ttsBtn');
if (ttsBtn) {
ttsBtn.addEventListener('click', () => {
enableTTS = !enableTTS;
ttsBtn.classList.toggle('active', enableTTS);
showToast(enableTTS ? '语音播放已开启' : '语音播放已关闭');
// 如果关闭,停止当前播放
if (!enableTTS && currentPlayingAudio) {
currentPlayingAudio.pause();
currentPlayingAudio = null;
}
});
}
// 绑定置顶置底按钮事件 // 绑定置顶置底按钮事件
const scrollTopBtn = document.getElementById('scrollTopBtn'); const scrollTopBtn = document.getElementById('scrollTopBtn');
const scrollBottomBtn = document.getElementById('scrollBottomBtn'); const scrollBottomBtn = document.getElementById('scrollBottomBtn');
@@ -4158,6 +4203,8 @@ function renderMessages() {
const copyIcon = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`; const copyIcon = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
const playIcon = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.54 7-8.77s-2.99-7.86-7-8.77z"/></svg>`;
const actions = isUser const actions = isUser
? `<div class="message-actions"> ? `<div class="message-actions">
<button class="action-btn copy-btn" data-index="${index}" title="复制">${copyIcon}</button> <button class="action-btn copy-btn" data-index="${index}" title="复制">${copyIcon}</button>
@@ -4166,6 +4213,7 @@ function renderMessages() {
</button> </button>
</div>` </div>`
: `<div class="message-actions"> : `<div class="message-actions">
<button class="action-btn tts-btn" data-index="${index}" title="语音播放">${playIcon}</button>
<button class="action-btn copy-btn" data-index="${index}" title="复制">${copyIcon}</button> <button class="action-btn copy-btn" data-index="${index}" title="复制">${copyIcon}</button>
<button class="action-btn regenerate-btn" data-index="${index}" title="重新生成"> <button class="action-btn regenerate-btn" data-index="${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> <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>
@@ -4189,6 +4237,9 @@ function renderMessages() {
}).join(''); }).join('');
// 绑定消息操作按钮事件(事件委托) // 绑定消息操作按钮事件(事件委托)
messagesDiv.querySelectorAll('.tts-btn').forEach(btn => {
btn.addEventListener('click', () => playTTS(parseInt(btn.dataset.index)));
});
messagesDiv.querySelectorAll('.copy-btn').forEach(btn => { messagesDiv.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => copyMessage(parseInt(btn.dataset.index))); btn.addEventListener('click', () => copyMessage(parseInt(btn.dataset.index)));
}); });
@@ -4557,3 +4608,99 @@ async function streamGenerateWithFile(content, fileName) {
renderMessages(); renderMessages();
} }
} }
// ==================== TTS 语音播放 ====================
// TTS 手动播放(点击消息上的播放按钮)
async function playTTS(index) {
if (!currentConversation || index < 0) return;
const msg = currentConversation.messages[index];
if (!msg || msg.role !== 'assistant') return;
const text = msg.content;
if (!text) {
showToast('没有可播放的内容');
return;
}
// 如果正在播放,停止
if (currentPlayingAudio) {
currentPlayingAudio.pause();
currentPlayingAudio = null;
showToast('已停止播放');
return;
}
try {
showToast('正在生成语音...');
const response = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: cleanTTS(text), voice: ttsVoice })
});
if (!response.ok) {
const error = await response.json();
showToast(error.error || '语音生成失败');
return;
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
currentPlayingAudio = new Audio(audioUrl);
await currentPlayingAudio.play();
showToast('开始播放');
currentPlayingAudio.onended = () => {
URL.revokeObjectURL(audioUrl);
currentPlayingAudio = null;
};
} catch (e) {
console.error('TTS播放失败:', e);
showToast('语音播放失败');
}
}
// 清理TTS文本过滤Markdown特殊字符
function cleanTTS(text) {
if (!text) return '';
// 移除Markdown标题标记
text = text.replace(/^#{1,6}\s+/gm, '');
// 移除粗体/斜体标记
text = text.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1');
text = text.replace(/_{1,2}([^_]+)_{1,2}/g, '$1');
// 移除代码块
text = text.replace(/```[\s\S]*?```/g, '');
text = text.replace(/`([^`]+)`/g, '$1');
// 移除链接,只保留文本
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
// 移除引用标记
text = text.replace(/^>\s+/gm, '');
// 移除分割线
text = text.replace(/^[-*_]{3,}$/gm, '');
// 移除列表标记
text = text.replace(/^[\s]*[-*+]\s+/gm, '');
text = text.replace(/^[\s]*\d+\.\s+/gm, '');
// 移除表情符号emoji
text = text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, '');
// 清理多余空白
text = text.replace(/\n{3,}/g, '\n\n');
text = text.trim();
return text;
}
// ==================== 工具函数 ====================

View File

@@ -8,12 +8,12 @@
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<title>AI助手</title> <title>AI助手</title>
<link rel="stylesheet" href="style.css?v=3.5.1"> <link rel="stylesheet" href="style.css?v=3.15.2">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script src="marked.min.js?v=3.5.1"></script> <script src="marked.min.js?v=3.15.2"></script>
<script src="app.js?v=3.5.1"></script> <script src="app.js?v=3.15.2"></script>
</body> </body>
</html> </html>

View File

@@ -3058,3 +3058,25 @@ body {
.stop-generate-btn svg { .stop-generate-btn svg {
flex-shrink: 0; flex-shrink: 0;
} }
/* TTS 语音播放按钮 */
.tts-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: all 0.2s;
}
.tts-btn:hover {
background: rgba(102, 126, 234, 0.1);
}
.tts-btn.active {
background: var(--primary);
color: white;
}
.tts-btn svg {
display: block;
}