Compare commits

..
5 Commits
Author SHA1 Message Date
2b662d3a14 fix:function call bug (#268)
* 优化function call消息处理

* fix:用户说话重复记录bug

* 2025-3-10-优化llm intent (#254)

Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>

* update:回复上版提示词,无需再二次识别歌曲名

---------

Co-authored-by: 玄凤科技 <eric230308@gmail.com>
Co-authored-by: hrz <1710360675@qq.com>
Co-authored-by: Jiao Haoyang <108573524+XuSenfeng@users.noreply.github.com>
2025-03-11 00:25:33 +08:00
4f3fae81c1 fix:修复长时间不说话关闭意图bug (#267)
Co-authored-by: hrz <1710360675@qq.com>
2025-03-10 22:45:03 +08:00
7b266ad7c0 完成搜索功能开发 (#263)
Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>
2025-03-10 18:29:08 +08:00
CGDandGitHub 16601a67e3 完成解绑功能 (#264) 2025-03-10 18:21:59 +08:00
224d59d50d fix:声音卡顿问题 (#262)
Co-authored-by: hrz <1710360675@qq.com>
2025-03-10 15:56:28 +08:00
14 changed files with 200 additions and 134 deletions
+7 -7
View File
@@ -93,7 +93,7 @@ docker logs -f xiaozhi-esp32-server
```
docker stop xiaozhi-esp32-server
docker rm xiaozhi-esp32-server
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:latest
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
```
3、重新按docker方式部署
@@ -299,14 +299,14 @@ LLM:
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](../README.md#1tts-%E7%BB%8F%E5%B8%B8%E5%A4%B1%E8%B4%A5%E7%BB%8F%E5%B8%B8%E8%B6%85%E6%97%B6-)
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](../README.md#1%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E8%AF%B4%E7%9A%84%E8%AF%9D%E5%B0%8F%E6%99%BA%E8%AF%86%E5%88%AB%E5%87%BA%E6%9D%A5%E5%BE%88%E5%A4%9A%E9%9F%A9%E6%96%87%E6%97%A5%E6%96%87%E8%8B%B1%E6%96%87)
[2、为什么会出现“TTS 任务出错 文件不存在”?](../README.md#1tts-%E7%BB%8F%E5%B8%B8%E5%A4%B1%E8%B4%A5%E7%BB%8F%E5%B8%B8%E8%B6%85%E6%97%B6-)
[2、为什么会出现“TTS 任务出错 文件不存在”?](../README.md#1%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E8%AF%B4%E7%9A%84%E8%AF%9D%E5%B0%8F%E6%99%BA%E8%AF%86%E5%88%AB%E5%87%BA%E6%9D%A5%E5%BE%88%E5%A4%9A%E9%9F%A9%E6%96%87%E6%97%A5%E6%96%87%E8%8B%B1%E6%96%87)
[3、TTS 经常失败,经常超时](../README.md#1tts-%E7%BB%8F%E5%B8%B8%E5%A4%B1%E8%B4%A5%E7%BB%8F%E5%B8%B8%E8%B6%85%E6%97%B6-)
[3、TTS 经常失败,经常超时](../README.md#1%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E8%AF%B4%E7%9A%84%E8%AF%9D%E5%B0%8F%E6%99%BA%E8%AF%86%E5%88%AB%E5%87%BA%E6%9D%A5%E5%BE%88%E5%A4%9A%E9%9F%A9%E6%96%87%E6%97%A5%E6%96%87%E8%8B%B1%E6%96%87)
[4、如何提高小智对话响应速度?](../README.md#1tts-%E7%BB%8F%E5%B8%B8%E5%A4%B1%E8%B4%A5%E7%BB%8F%E5%B8%B8%E8%B6%85%E6%97%B6-)
[4、如何提高小智对话响应速度?](../README.md#1%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E8%AF%B4%E7%9A%84%E8%AF%9D%E5%B0%8F%E6%99%BA%E8%AF%86%E5%88%AB%E5%87%BA%E6%9D%A5%E5%BE%88%E5%A4%9A%E9%9F%A9%E6%96%87%E6%97%A5%E6%96%87%E8%8B%B1%E6%96%87)
[5、我说话很慢,停顿时小智老是抢话](../README.md#1tts-%E7%BB%8F%E5%B8%B8%E5%A4%B1%E8%B4%A5%E7%BB%8F%E5%B8%B8%E8%B6%85%E6%97%B6-)
[5、我说话很慢,停顿时小智老是抢话](../README.md#1%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E8%AF%B4%E7%9A%84%E8%AF%9D%E5%B0%8F%E6%99%BA%E8%AF%86%E5%88%AB%E5%87%BA%E6%9D%A5%E5%BE%88%E5%A4%9A%E9%9F%A9%E6%96%87%E6%97%A5%E6%96%87%E8%8B%B1%E6%96%87)
[6、我想通过小智控制电灯、空调、远程开关机等操作](../README.md#1tts-%E7%BB%8F%E5%B8%B8%E5%A4%B1%E8%B4%A5%E7%BB%8F%E5%B8%B8%E8%B6%85%E6%97%B6-)
[6、我想通过小智控制电灯、空调、远程开关机等操作](../README.md#1%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E8%AF%B4%E7%9A%84%E8%AF%9D%E5%B0%8F%E6%99%BA%E8%AF%86%E5%88%AB%E5%87%BA%E6%9D%A5%E5%BE%88%E5%A4%9A%E9%9F%A9%E6%96%87%E6%97%A5%E6%96%87%E8%8B%B1%E6%96%87)
+15
View File
@@ -43,4 +43,19 @@ export default {
})
}).send()
},
// 解绑设备
unbindDevice(device_id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/api/v1/user/device/unbind/${device_id}`)
.method('PUT')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.unbindDevice(device_id, callback);
});
}).send()
},
}
+3
View File
@@ -1,6 +1,9 @@
const HAVE_NO_RESULT = '暂无'
export default {
HAVE_NO_RESULT, // 项目的配置信息
PAGE: {
LOGIN: '/login',
},
STORAGE_KEY: {
TOKEN: 'TOKEN',
PUBLIC_KEY: 'PUBLIC_KEY',
+52 -14
View File
@@ -21,9 +21,9 @@
</div>
<div style="display: flex;align-items: center;gap: 8px;margin-top: 10px">
<div class="serach-box">
<el-input placeholder="输入名称搜索.." v-model="serach" style="border: none; background: transparent;" />
<el-input placeholder="输入名称搜索.." v-model="serach" style="border: none; background: transparent;" @keyup.enter.native="handleSearch" />
<img src="@/assets/home/search.png" alt=""
style="width: 12px;height: 12px;margin-right: 11px;cursor: pointer;" />
style="width: 12px;height: 12px;margin-right: 11px;cursor: pointer;" @click="handleSearch" />
</div>
<img src="@/assets/home/avatar.png" alt="" style="width: 21px;height: 21px;" />
<div class="user-info">
@@ -56,16 +56,16 @@
</div>
</div>
<div
style="display: flex;flex-wrap: wrap;margin-top: 15px;gap: 15px;justify-content: space-between;box-sizing: border-box;">
<div class="device-item" v-for="(item,index) in deviceList" :key="index">
<div style="display: flex;justify-content: space-between;">
style="display: flex;flex-wrap: wrap;margin-top: 15px;gap: 15px;justify-content: flex-start;box-sizing: border-box;">
<div class="device-item" v-for="(item,index) in filteredDeviceList" :key="index">
<div style="display: flex;justify-content: space-between; align-items: center; ">
<div style="font-weight: 700;font-size: 18px;text-align: left;color: #3d4566;">
<!-- CC:ba:97:11:a6:ac-->
{{item.list[0]?.mac_address}}
</div>
<div>
<div style="display: flex;align-items: center;">
<img src="@/assets/home/delete.png" alt=""
style="width: 18px;height: 18px;margin-right: 8px;" />
style="width: 18px;height: 18px;margin-right: 8px;" @click="unbindDevice(item.list[0]?.id)" />
<img src="@/assets/home/info.png" alt="" style="width: 18px;height: 18px;" />
</div>
</div>
@@ -229,7 +229,9 @@ export default {
name: 'home',
data() {
return {
serach: '',
serach: '', // 搜索框输入内容
deviceList: [], // 原始设备列表
filteredDeviceList: [], // 过滤后的设备列表
switchValue: false,
addDeviceDialogVisible: false,
deviceCode: "",
@@ -250,8 +252,7 @@ export default {
}],
userInfo: {
mobile: '' // 初始化用户信息
},
deviceList:[]
}
};
},
methods: {
@@ -270,14 +271,51 @@ export default {
// 获取已绑设备
getList(){
Api.user.getHomeList(({data})=>{
console.log(data.data)
this.deviceList = data.data
this.deviceList = data.data; // 保存原始设备列表
this.filteredDeviceList = data.data; // 初始化过滤后的设备列表
})
}
},
// 处理搜索
handleSearch() {
if (this.serach.trim() === '') {
// 如果搜索框为空,显示全部设备
this.filteredDeviceList = this.deviceList;
} else {
// 过滤设备列表
this.filteredDeviceList = this.deviceList.filter(device => {
return (
device.list[0]?.mac_address?.includes(this.serach) || // 匹配MAC地址
device.list[0]?.device_type?.includes(this.serach) || // 匹配设备型号
device.list[0]?.app_version?.includes(this.serach) // 匹配APP版本
);
});
}
},
// 解绑设备
unbindDevice(device_id) {
this.$confirm('确定要解绑该设备吗', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 调用解绑设备的接口
Api.user.unbindDevice(device_id, ({ data }) => {
if (data.code === 0) {
this.$message.success('解绑成功');
this.getList();
} else {
this.$message.error(data.msg || '解绑失败');
}
});
}).catch(() => {
// 用户取消操作
this.$message.info('已取消解绑');
});
},
},
mounted() {
this.fetchUserInfo(); // 组件加载时获取用户信息
this.getList()
this.getList(); // 初始化设备列表
}
}
</script>
+82 -47
View File
@@ -11,7 +11,7 @@ import websockets
from typing import Dict, Any
from core.utils.dialogue import Message, Dialogue
from core.handle.textHandle import handleTextMessage
from core.utils.util import get_string_no_punctuation_or_emoji
from core.utils.util import get_string_no_punctuation_or_emoji, extract_json_from_string
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.sendAudioHandle import sendAudioMessage
from core.handle.receiveAudioHandle import handleAudioMessage
@@ -307,8 +307,7 @@ class ConnectionHandler:
response_message = []
processed_chars = 0 # 跟踪已处理的字符位置
function_call_data = None # 存储function call数据
try:
start_time = time.time()
@@ -316,7 +315,7 @@ class ConnectionHandler:
future = asyncio.run_coroutine_threadsafe(self.memory.query_memory(query), self.loop)
memory_str = future.result()
# self.logger.bind(tag=TAG).info(f"记忆内容: {memory_str}")
#self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
# 使用支持functions的streaming接口
llm_responses = self.llm.response_with_functions(
@@ -332,48 +331,61 @@ class ConnectionHandler:
text_index = 0
# 处理流式响应
tool_call_flag = False
function_name = None
function_id = None
function_arguments = ""
content_arguments = ""
for response in llm_responses:
if response["type"] == "content":
content = response["content"]
response_message.append(content)
content, tools_call = response
if content is not None and len(content)>0:
if len(response_message)<=0 and content=="```":
tool_call_flag = True
if self.client_abort:
break
if tools_call is not None:
tool_call_flag = True
if tools_call[0].id is not None:
function_id = tools_call[0].id
if tools_call[0].function.name is not None:
function_name = tools_call[0].function.name
if tools_call[0].function.arguments is not None:
function_arguments += tools_call[0].function.arguments
end_time = time.time()
self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
if content is not None and len(content) > 0:
if tool_call_flag:
content_arguments+=content
else:
response_message.append(content)
# 处理文本分段和TTS逻辑
# 合并当前全部文本并处理未分割部分
full_text = "".join(response_message)
current_text = full_text[processed_chars:] # 从未处理的位置开始
if self.client_abort:
break
# 查找最后一个有效标点
punctuations = ("", "", "", "", "")
last_punct_pos = -1
for punct in punctuations:
pos = current_text.rfind(punct)
if pos > last_punct_pos:
last_punct_pos = pos
end_time = time.time()
self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
# 找到分割点则处理
if last_punct_pos != -1:
segment_text_raw = current_text[:last_punct_pos + 1]
segment_text = get_string_no_punctuation_or_emoji(segment_text_raw)
if segment_text:
text_index += 1
self.recode_first_last_text(segment_text, text_index)
future = self.executor.submit(self.speak_and_play, segment_text, text_index)
self.tts_queue.put(future)
processed_chars += len(segment_text_raw) # 更新已处理字符位置
# 处理文本分段和TTS逻辑
# 合并当前全部文本并处理未分割部分
full_text = "".join(response_message)
current_text = full_text[processed_chars:] # 从未处理的位置开始
elif response["type"] == "function_call":
# Extract function call data
function_call_data = {
"name": response["function_call"]["function"]["name"],
"arguments": response["function_call"]["function"]["arguments"]
}
self.logger.bind(tag=TAG).info(f"Function call detected: {function_call_data}")
# 查找最后一个有效标点
punctuations = ("", "", "", "", "")
last_punct_pos = -1
for punct in punctuations:
pos = current_text.rfind(punct)
if pos > last_punct_pos:
last_punct_pos = pos
# 找到分割点则处理
if last_punct_pos != -1:
segment_text_raw = current_text[:last_punct_pos + 1]
segment_text = get_string_no_punctuation_or_emoji(segment_text_raw)
if segment_text:
text_index += 1
self.recode_first_last_text(segment_text, text_index)
future = self.executor.submit(self.speak_and_play, segment_text, text_index)
self.tts_queue.put(future)
processed_chars += len(segment_text_raw) # 更新已处理字符位置
# 处理最后剩余的文本
full_text = "".join(response_message)
@@ -387,23 +399,46 @@ class ConnectionHandler:
self.tts_queue.put(future)
# 存储对话内容
self.dialogue.put(Message(role="assistant", content="".join(response_message)))
if len(response_message)>0:
self.dialogue.put(Message(role="assistant", content="".join(response_message)))
# 处理function call
if function_call_data:
if tool_call_flag:
if function_id is None:
a = extract_json_from_string(content_arguments)
if a is not None:
content_arguments_json = json.loads(a)
function_name = content_arguments_json["function_name"]
function_arguments = json.dumps(content_arguments_json["args"], ensure_ascii=False)
function_id = str(uuid.uuid4().hex)
else:
return []
function_arguments = json.loads(function_arguments)
self.logger.bind(tag=TAG).info(f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}")
function_call_data = {
"name": function_name,
"id": function_id,
"arguments": function_arguments
}
result = handle_llm_function_call(self, function_call_data)
if result.action == Action.RESPONSE:
text = result.response
text_index += 1
self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future)
self._handle_function_result(result, function_call_data, text_index+1)
self.llm_finish_task = True
self.logger.bind(tag=TAG).debug(json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False))
return True
def _handle_function_result(self, result, function_call_data, text_index):
if result.action == Action.RESPONSE: # 直接回复前端
text = result.response
self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text))
if result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.response
def _tts_priority_thread(self):
while not self.stop_event.is_set():
text = None
@@ -122,12 +122,10 @@ async def analyze_intent_with_llm(conn, text):
logger.bind(tag=TAG).warning("意图识别服务未初始化")
return None
# 创建对话历史记录
# 对话历史记录
dialogue = conn.dialogue
dialogue.put(Message(role="user", content=text))
try:
intent_result = await conn.intent.detect_intent(dialogue.dialogue)
intent_result = await conn.intent.detect_intent(dialogue.dialogue, text)
logger.bind(tag=TAG).info(f"意图识别结果: {intent_result}")
# 尝试解析JSON结果
@@ -67,8 +67,9 @@ async def no_voice_close_connect(conn):
else:
no_voice_time = time.time() * 1000 - conn.client_no_voice_last_time
close_connection_no_voice_time = conn.config.get("close_connection_no_voice_time", 120)
if no_voice_time > 1000 * close_connection_no_voice_time:
if not conn.close_after_chat and no_voice_time > 1000 * close_connection_no_voice_time:
conn.close_after_chat = True
conn.client_abort = False
conn.asr_server_receive = False
prompt = "时间过得真快,我都好久没说话了。请你用十个字左右话跟我告别,以“再见”或“拜拜”为结尾"
prompt = "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。"
await startToChat(conn, prompt)
@@ -13,28 +13,37 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
await send_tts_message(conn, "sentence_start", text)
# 初始化流控参数
frame_duration = 60 # 毫秒
start_time = time.perf_counter() # 使用高精度计时器
play_position = 0 # 已播放的时长(毫秒)
# 流控参数优化
original_frame_duration = 60 # 原始帧时长(毫秒
adjusted_frame_duration = int(original_frame_duration * 0.8) # 缩短20%
total_frames = len(audios) # 获取总帧数
compensation = total_frames * (original_frame_duration - adjusted_frame_duration) / 1000 # 补偿时间(秒)
start_time = time.perf_counter()
play_position = 0 # 已播放时长(毫秒)
for opus_packet in audios:
if conn.client_abort:
return
# 计算当前包的预期发送时间
# 计算带加速因子的预期时间
expected_time = start_time + (play_position / 1000)
current_time = time.perf_counter()
# 等待直到预期时间
# 流控等待(使用加速后的帧时长)
delay = expected_time - current_time
if delay > 0:
await asyncio.sleep(delay)
# 发送音频包
await conn.websocket.send(opus_packet)
play_position += frame_duration # 更新播放位置
play_position += adjusted_frame_duration # 使用调整后的帧时长
# 补偿因加速损失的时长
if compensation > 0:
await asyncio.sleep(compensation)
await send_tts_message(conn, "sentence_end", text)
# 发送结束消息(如果是最后一个文本)
if conn.llm_finish_task and text_index == conn.tts_last_text_index:
await send_tts_message(conn, 'stop', None)
@@ -5,6 +5,7 @@ from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class IntentProviderBase(ABC):
def __init__(self, config):
self.config = config
@@ -17,9 +18,9 @@ class IntentProviderBase(ABC):
def set_llm(self, llm):
self.llm = llm
logger.bind(tag=TAG).debug("Set LLM for intent provider")
@abstractmethod
async def detect_intent(self, dialogue_history: List[Dict]) -> str:
async def detect_intent(self, dialogue_history: List[Dict], text: str) -> str:
"""
检测用户最后一句话的意图
Args:
@@ -37,7 +37,7 @@ class IntentProvider(IntentProviderBase):
)
return prompt
async def detect_intent(self, dialogue_history: List[Dict]) -> str:
async def detect_intent(self, dialogue_history: List[Dict], text:str) -> str:
if not self.llm:
raise ValueError("LLM provider not set")
@@ -48,9 +48,8 @@ class IntentProvider(IntentProviderBase):
msgStr += f"User: {msg.content}\n"
elif msg.role== "assistant":
msgStr += f"Assistant: {msg.content}\n"
msgStr += f"User: {text}\n"
user_prompt = f"请分析用户的意图:\n{msgStr}"
# 使用LLM进行意图识别
intent = self.llm.response_no_stream(
system_prompt=self.promot,
@@ -5,12 +5,14 @@ from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class IntentProvider(IntentProviderBase):
async def detect_intent(self, dialogue_history: List[Dict]) -> str:
async def detect_intent(self, dialogue_history: List[Dict], text: str) -> str:
"""
默认的意图识别实现,始终返回继续聊天
Args:
dialogue_history: 对话历史记录列表
text: 本次对话记录
Returns:
固定返回"继续聊天"
"""
@@ -51,30 +51,8 @@ class LLMProvider(LLMProviderBase):
tools=functions,
)
current_function_call = None
current_content = ""
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
current_content += delta.content
yield {"type": "content", "content": delta.content}
if delta.tool_calls:
tool_call = delta.tool_calls[0]
# Handle the function call data using proper attribute access
if not current_function_call:
current_function_call = {
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments
}
}
if current_function_call:
logger.bind(tag=TAG).debug(f"ollama Function call detected: {current_function_call}")
yield {"type": "function_call", "function_call": current_function_call}
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Ollama function call: {e}")
@@ -53,30 +53,8 @@ class LLMProvider(LLMProviderBase):
tools=functions,
)
current_function_call = None
current_content = ""
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
current_content += delta.content
yield {"type": "content", "content": delta.content}
if delta.tool_calls:
tool_call = delta.tool_calls[0]
# Handle the function call data using proper attribute access
if not current_function_call:
current_function_call = {
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments
}
}
if current_function_call:
logger.bind(tag=TAG).debug(f"openai Function call detected: {current_function_call}")
yield {"type": "function_call", "function_call": current_function_call}
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
except Exception as e:
self.logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
+9
View File
@@ -4,6 +4,7 @@ import yaml
import socket
import subprocess
import logging
import re
def get_project_dir():
@@ -119,3 +120,11 @@ def check_ffmpeg_installed():
error_msg += "1、按照项目的安装文档,正确进入conda环境\n"
error_msg += "2、查阅安装文档,如何在conda环境中安装ffmpeg\n"
raise ValueError(error_msg)
def extract_json_from_string(input_string):
"""提取字符串中的 JSON 部分"""
pattern = r'(\{.*\})'
match = re.search(pattern, input_string)
if match:
return match.group(1) # 返回提取的 JSON 字符串
return None