Compare commits

...

3 Commits

2 changed files with 213 additions and 57 deletions

View File

@@ -1345,10 +1345,20 @@ def get_frontend_config():
conn = get_db()
cursor = conn.cursor()
# 获取默认LLM配置
cursor.execute('SELECT * FROM llm_configs WHERE is_default=1 AND is_active=1 LIMIT 1')
# 获取默认对话配置
cursor.execute('SELECT * FROM chat_configs WHERE is_default=1 LIMIT 1')
chat_config = cursor.fetchone()
# 根据对话配置中的 llm_config_id 获取对应的 LLM 配置
llm_config_id = chat_config['llm_config_id'] if chat_config else 1
cursor.execute('SELECT * FROM llm_configs WHERE id=?', (llm_config_id,))
llm = cursor.fetchone()
# 如果找不到对应的LLM配置使用默认的
if not llm:
cursor.execute('SELECT * FROM llm_configs WHERE is_default=1 LIMIT 1')
llm = cursor.fetchone()
# 获取默认工具配置(搜索等)
cursor.execute('SELECT * FROM tool_configs WHERE is_default=1 AND is_active=1')
tools = [dict(row) for row in cursor.fetchall()]
@@ -1361,10 +1371,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')
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')
system = {row['key']: row['value'] for row in cursor.fetchall()}

View File

@@ -53,9 +53,11 @@ let backendConfig = null; // 从API获取的配置
let currentUser = null; // 当前登录用户 { username, password, registeredAt }
// TTS 语音播放状态
let enableTTS = false; // 是否启用语音播放
let enableTTS = false; // 是否启用语音播放(默认关闭)
let currentPlayingAudio = null; // 当前播放的音频对象
let ttsVoice = 'zh-CN-XiaoxiaoNeural'; // TTS 语音
let ttsQueue = []; // TTS 待播放队列
let isTTSPlaying = false; // 是否正在播放队列
// 每日使用统计(未登录用户)
let dailyUsage = {
@@ -3137,6 +3139,10 @@ async function openAgent(agentId) {
}
}
// 重置 TTS 状态(新对话默认关闭)
enableTTS = false;
stopTTSQueue();
// 创建新对话并设置智能体
const newConv = {
id: backendId || Date.now().toString(),
@@ -3159,6 +3165,9 @@ async function openAgent(agentId) {
function showAgentChatPage() {
if (!currentAgent || !currentConversation) return;
// 停止之前的 TTS 播放
stopTTSQueue();
const chatHtml = `
<div class="chat-page">
<header class="chat-header">
@@ -3616,6 +3625,10 @@ async function createNewConversation() {
return;
}
// 重置 TTS 状态(新对话默认关闭)
enableTTS = false;
stopTTSQueue();
// 如果用户已登录,先在 backend 创建对话
let backendId = null;
if (currentUser && currentUser.id) {
@@ -3653,6 +3666,9 @@ async function createNewConversation() {
// 打开对话
function openConversation(id) {
// 停止之前的 TTS 播放
stopTTSQueue();
currentConversation = conversations.find(c => c.id === id);
if (!currentConversation) {
showConversationList();
@@ -4088,6 +4104,8 @@ async function streamGenerate(userMsgIndex) {
let buffer = '';
let thinkingOutputStarted = false; // 正式内容是否开始输出
let abortController = new AbortController(); // 用于中断流
let ttsAccumulatedText = ''; // TTS 累计文本
let ttsLastPlayedIndex = 0; // 上次播放的文本位置
// 绑定停止按钮事件
const stopBtn = document.getElementById('stopGenerateBtn');
@@ -4157,6 +4175,32 @@ async function streamGenerate(userMsgIndex) {
currentConversation.messages[aiMessageIndex].content += delta.content;
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content) + '<span class="streaming-cursor">▌</span>';
// 流式TTS播放积累文本并播放
if (enableTTS) {
ttsAccumulatedText += delta.content;
// 每80字符播放一段避免太短频繁请求
if (ttsAccumulatedText.length >= 80) {
// 找到合适的断句点(句子结束)
const breakPoints = ['。', '', '', '', '.', '!', '?', ';', '\n'];
let breakIndex = -1;
for (let i = ttsAccumulatedText.length - 1; i >= ttsLastPlayedIndex + 40; i--) {
if (breakPoints.includes(ttsAccumulatedText[i])) {
breakIndex = i;
break;
}
}
// 如果找到断句点或者积累超过120字符强制播放
if (breakIndex >= 0 || ttsAccumulatedText.length - ttsLastPlayedIndex >= 120) {
const textToPlay = ttsAccumulatedText.slice(ttsLastPlayedIndex, breakIndex >= 0 ? breakIndex + 1 : ttsAccumulatedText.length);
if (textToPlay.trim()) {
addToTTSQueue(textToPlay.trim());
}
ttsLastPlayedIndex = breakIndex >= 0 ? breakIndex + 1 : ttsAccumulatedText.length;
}
}
}
scrollToBottom();
}
}
@@ -4170,11 +4214,21 @@ async function streamGenerate(userMsgIndex) {
thinkingEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].thinking);
}
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
// 播放剩余未播放的文本流式TTS
if (enableTTS && ttsAccumulatedText && ttsLastPlayedIndex < ttsAccumulatedText.length) {
const remainingText = ttsAccumulatedText.slice(ttsLastPlayedIndex).trim();
if (remainingText) {
addToTTSQueue(remainingText);
}
}
} catch (error) {
console.error('Error:', error);
currentConversation.messages[aiMessageIndex].content = `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`;
contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content);
// 出错时停止TTS队列
stopTTSQueue();
} finally {
isLoading = false;
sendBtn.disabled = false;
@@ -4187,12 +4241,6 @@ async function streamGenerate(userMsgIndex) {
// 记录统计到 backend
logStatsToBackend('llm_call', currentConversation.agentId || 'chat', 1);
// 自动播放 TTS
const lastMsg = currentConversation.messages[aiMessageIndex];
if (enableTTS && lastMsg && lastMsg.content) {
autoPlayTTS(lastMsg.content);
}
// 自动总结标题第一次对话和每隔5次对话
const totalMessages = currentConversation.messages.length;
// 第一次对话(用户+AI=2条或每5次对话10条
@@ -4740,23 +4788,162 @@ function renderMessages() {
scrollToBottom();
}
// TTS 语音播放
// ==================== TTS 队列播放 ====================
// 清理 TTS 文本过滤Markdown特殊字符和表情
function cleanTTSText(text) {
if (!text) return '';
let cleaned = text;
// 移除代码块(```code```
cleaned = cleaned.replace(/```[\s\S]*?```/g, '');
// 移除行内代码(`code`
cleaned = cleaned.replace(/`[^`]+`/g, '');
// 移除标题符号(#、##、###等)
cleaned = cleaned.replace(/^#{1,6}\s*/gm, '');
// 移除列表符号(-、*、+
cleaned = cleaned.replace(/^[\-\*\+]\s+/gm, '');
// 移除数字列表1.、2.等)
cleaned = cleaned.replace(/^\d+\.\s+/gm, '');
// 处理链接 [text](url) -> 只保留text
cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
// 移除粗体/斜体符号(**text**、*text*、__text__、_text_
cleaned = cleaned.replace(/\*\*([^*]+)\*\*/g, '$1');
cleaned = cleaned.replace(/\*([^*]+)\*/g, '$1');
cleaned = cleaned.replace(/__([^_]+)__/g, '$1');
cleaned = cleaned.replace(/_([^_]+)_/g, '$1');
// 移除引用符号(>
cleaned = cleaned.replace(/^>\s*/gm, '');
// 移除分割线(---、***
cleaned = cleaned.replace(/^[\-\*]{3,}$/gm, '');
// 移除表情符号常见emoji范围
cleaned = cleaned.replace(/[\u{1F600}-\u{1F64F}]/gu, ''); // 表情
cleaned = cleaned.replace(/[\u{1F300}-\u{1F5FF}]/gu, ''); // 符号和图形
cleaned = cleaned.replace(/[\u{1F680}-\u{1F6FF}]/gu, ''); // 交通和地图
cleaned = cleaned.replace(/[\u{1F700}-\u{1F77F}]/gu, ''); // 占星术
cleaned = cleaned.replace(/[\u{1F780}-\u{1F7FF}]/gu, ''); // 几何图形
cleaned = cleaned.replace(/[\u{1F800}-\u{1F8FF}]/gu, ''); // 补充箭头
cleaned = cleaned.replace(/[\u{1F900}-\u{1F9FF}]/gu, ''); // 补充符号
cleaned = cleaned.replace(/[\u{1FA00}-\u{1FA6F}]/gu, ''); // 游戏符号
cleaned = cleaned.replace(/[\u{1FA70}-\u{1FAFF}]/gu, ''); // 补充符号B
cleaned = cleaned.replace(/[\u{2600}-\u{26FF}]/gu, ''); // 杂项符号
cleaned = cleaned.replace(/[\u{2700}-\u{27BF}]/gu, ''); // 装饰符号
cleaned = cleaned.replace(/[\u{FE00}-\u{FE0F}]/gu, ''); // 变体选择符
cleaned = cleaned.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, ''); // 旗帜
// 移除HTML实体
cleaned = cleaned.replace(/&[a-zA-Z]+;/g, '');
// 清理多余空白
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned;
}
// 添加文本到 TTS 队列
async function addToTTSQueue(text) {
if (!text || !enableTTS) return;
ttsQueue.push(text);
// 如果没有在播放,开始播放队列
if (!isTTSPlaying) {
playTTSQueue();
}
}
// 播放 TTS 队列
async function playTTSQueue() {
if (ttsQueue.length === 0) {
isTTSPlaying = false;
return;
}
isTTSPlaying = true;
while (ttsQueue.length > 0 && enableTTS) {
const rawText = ttsQueue.shift();
const text = cleanTTSText(rawText); // 清理文本
if (!text) continue; // 跳过空文本
try {
const response = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: ttsVoice })
});
if (!response.ok || !enableTTS) break;
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
// 播放音频
currentPlayingAudio = new Audio(audioUrl);
await currentPlayingAudio.play();
// 等待播放完成
await new Promise(resolve => {
currentPlayingAudio.onended = resolve;
currentPlayingAudio.onerror = resolve;
});
URL.revokeObjectURL(audioUrl);
currentPlayingAudio = null;
} catch (e) {
console.error('TTS播放失败:', e);
break;
}
}
isTTSPlaying = false;
ttsQueue = []; // 清空队列
}
// 停止 TTS 队列播放
function stopTTSQueue() {
ttsQueue = [];
isTTSPlaying = false;
if (currentPlayingAudio) {
currentPlayingAudio.pause();
currentPlayingAudio = null;
}
}
// 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;
const rawText = msg.content;
if (!rawText) {
showToast('没有可播放的内容');
return;
}
const text = cleanTTSText(rawText); // 清理文本
if (!text) {
showToast('没有可播放的内容');
return;
}
// 如果正在播放,停止播放
if (currentPlayingAudio) {
currentPlayingAudio.pause();
currentPlayingAudio = null;
// 如果正在播放或队列中有内容,停止
if (currentPlayingAudio || isTTSPlaying) {
stopTTSQueue();
showToast('已停止播放');
return;
}
@@ -4783,7 +4970,7 @@ async function playTTS(index) {
// 播放音频
currentPlayingAudio = new Audio(audioUrl);
currentPlayingAudio.play();
await currentPlayingAudio.play();
showToast('开始播放');
@@ -4799,43 +4986,6 @@ async function playTTS(index) {
}
}
// 自动播放 TTSAI回复完成后
function autoPlayTTS(text) {
if (!enableTTS || !text) return;
// 延迟一点时间,让用户先看到内容
setTimeout(async () => {
try {
const response = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: ttsVoice })
});
if (!response.ok) return;
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
// 停止之前的播放
if (currentPlayingAudio) {
currentPlayingAudio.pause();
}
currentPlayingAudio = new Audio(audioUrl);
currentPlayingAudio.play();
currentPlayingAudio.onended = () => {
URL.revokeObjectURL(audioUrl);
currentPlayingAudio = null;
};
} catch (e) {
console.error('自动TTS播放失败:', e);
}
}, 500);
}
// 折叠/展开思考内容
function toggleThinking(block) {
block.classList.toggle('expanded');