Merge branch 'xinnan-tech:main' into DifyBug

This commit is contained in:
Jiao Haoyang
2025-03-18 17:09:51 +08:00
committed by GitHub
20 changed files with 490 additions and 157 deletions
+18 -3
View File
@@ -57,6 +57,13 @@
</picture> </picture>
</a> </a>
</td> </td>
<td>
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
<picture>
<img alt="成本最低配置" src="docs/images/demo4.png" />
</picture>
</a>
</td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -74,9 +81,16 @@
</a> </a>
</td> </td>
<td> <td>
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank"> <a href="https://www.bilibili.com/video/BV1Z8XuYZEAS" target="_blank">
<picture> <picture>
<img alt="成本最低配置" src="docs/images/demo4.png" /> <img alt="天气插件" src="docs/images/demo8.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV178XuYfEpi" target="_blank">
<picture>
<img alt="IOT指令控制设备" src="docs/images/demo9.png" />
</picture> </picture>
</a> </a>
</td> </td>
@@ -143,12 +157,13 @@ server:
支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求。 支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求。
- **记忆功能** - **记忆功能**
支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求。 支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求。
- **IOT功能**
支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制。
### 正在开发 🚧 ### 正在开发 🚧
- 多种心情模式 - 多种心情模式
- 智控台webui - 智控台webui
- iot功能
想了解具体开发进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3) 想了解具体开发进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
+1 -1
View File
@@ -35,7 +35,7 @@ John2025.3.11,广州
### 1、成为普通贡献者 ### 1、成为普通贡献者
Fork 项目,提交 PR,由开发者审核后合入主分支。 Fork 项目,提交 PR,由开发者审核后合入主分支。
### 2、成为开发者 ### 2、成为开发者
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+17 -20
View File
@@ -35,10 +35,7 @@ log:
log_file: "server.log" log_file: "server.log"
# 设置数据文件路径 # 设置数据文件路径
data_dir: data data_dir: data
iot:
Speaker:
# 设置esp32的音量,范围0-100
volume: 80
xiaozhi: xiaozhi:
type: hello type: hello
version: 1 version: 1
@@ -117,10 +114,10 @@ Memory:
# 每月有1000次免费调用 # 每月有1000次免费调用
api_key: 你的mem0ai api key api_key: 你的mem0ai api key
nomem: nomem:
# 不想使用记忆功能,可以使用nomem # 不想使用记忆功能,可以使用nomem
type: nomem type: nomem
mem_local_short: mem_local_short:
# 本地记忆功能,通过selected_module的llm总结,数据保存在本地,不会上传到服务器 # 本地记忆功能,通过selected_module的llm总结,数据保存在本地,不会上传到服务器
type: mem_local_short type: mem_local_short
ASR: ASR:
@@ -232,7 +229,7 @@ TTS:
# 定义TTS API类型 # 定义TTS API类型
type: edge type: edge
voice: zh-CN-XiaoxiaoNeural voice: zh-CN-XiaoxiaoNeural
output_file: tmp/ output_dir: tmp/
DoubaoTTS: DoubaoTTS:
# 定义TTS API类型 # 定义TTS API类型
type: doubao type: doubao
@@ -242,7 +239,7 @@ TTS:
# 地址:https://console.volcengine.com/speech/service/8 # 地址:https://console.volcengine.com/speech/service/8
api_url: https://openspeech.bytedance.com/api/v1/tts api_url: https://openspeech.bytedance.com/api/v1/tts
voice: BV001_streaming voice: BV001_streaming
output_file: tmp/ output_dir: tmp/
authorization: "Bearer;" authorization: "Bearer;"
appid: 你的火山引擎语音合成服务appid appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token access_token: 你的火山引擎语音合成服务access_token
@@ -253,7 +250,7 @@ TTS:
# token申请地址 https://cloud.siliconflow.cn/account/ak # token申请地址 https://cloud.siliconflow.cn/account/ak
model: FunAudioLLM/CosyVoice2-0.5B model: FunAudioLLM/CosyVoice2-0.5B
voice: FunAudioLLM/CosyVoice2-0.5B:alex voice: FunAudioLLM/CosyVoice2-0.5B:alex
output_file: tmp/ output_dir: tmp/
access_token: 你的硅基流动API密钥 access_token: 你的硅基流动API密钥
response_format: wav response_format: wav
CozeCnTTS: CozeCnTTS:
@@ -261,7 +258,7 @@ TTS:
# COZECN TTS # COZECN TTS
# token申请地址 https://www.coze.cn/open/oauth/pats # token申请地址 https://www.coze.cn/open/oauth/pats
voice: 7426720361733046281 voice: 7426720361733046281
output_file: tmp/ output_dir: tmp/
access_token: 你的coze web key access_token: 你的coze web key
response_format: wav response_format: wav
FishSpeech: FishSpeech:
@@ -274,7 +271,7 @@ TTS:
#--decoder-config-name firefly_gan_vq #--decoder-config-name firefly_gan_vq
#--compile #--compile
type: fishspeech type: fishspeech
output_file: tmp/ output_dir: tmp/
response_format: wav response_format: wav
reference_id: null reference_id: null
reference_audio: ["/tmp/test.wav",] reference_audio: ["/tmp/test.wav",]
@@ -298,7 +295,7 @@ TTS:
#python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/caixukun.yaml #python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/caixukun.yaml
type: gpt_sovits_v2 type: gpt_sovits_v2
url: "http://127.0.0.1:9880/tts" url: "http://127.0.0.1:9880/tts"
output_file: tmp/ output_dir: tmp/
text_lang: "auto" text_lang: "auto"
ref_audio_path: "caixukun.wav" ref_audio_path: "caixukun.wav"
prompt_text: "" prompt_text: ""
@@ -323,7 +320,7 @@ TTS:
#python api.py #python api.py
type: gpt_sovits_v3 type: gpt_sovits_v3
url: "http://127.0.0.1:9880" url: "http://127.0.0.1:9880"
output_file: tmp/ output_dir: tmp/
text_language: "auto" text_language: "auto"
refer_wav_path: "caixukun.wav" refer_wav_path: "caixukun.wav"
prompt_language: "zh" prompt_language: "zh"
@@ -344,7 +341,7 @@ TTS:
# api_key地址:https://platform.minimaxi.com/user-center/basic-information/interface-key # api_key地址:https://platform.minimaxi.com/user-center/basic-information/interface-key
# 定义TTS API类型 # 定义TTS API类型
type: minimax type: minimax
output_file: tmp/ output_dir: tmp/
group_id: 你的minimax平台groupID group_id: 你的minimax平台groupID
api_key: 你的minimax平台接口密钥 api_key: 你的minimax平台接口密钥
model: "speech-01-turbo" model: "speech-01-turbo"
@@ -381,7 +378,7 @@ TTS:
# token地址:https://nls-portal.console.aliyun.com/overview # token地址:https://nls-portal.console.aliyun.com/overview
# 定义TTS API类型 # 定义TTS API类型
type: aliyun type: aliyun
output_file: tmp/ output_dir: tmp/
appkey: 你的阿里云智能语音交互服务项目Appkey appkey: 你的阿里云智能语音交互服务项目Appkey
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret
voice: xiaoyun voice: xiaoyun
@@ -404,7 +401,7 @@ TTS:
api_url: https://api.302ai.cn/doubao/tts_hd api_url: https://api.302ai.cn/doubao/tts_hd
authorization: "Bearer " authorization: "Bearer "
voice: "zh_female_wanwanxiaohe_moon_bigtts" voice: "zh_female_wanwanxiaohe_moon_bigtts"
output_file: tmp/ output_dir: tmp/
access_token: "你的302API密钥" access_token: "你的302API密钥"
ACGNTTS: ACGNTTS:
#在线网址:https://acgn.ttson.cn/ #在线网址:https://acgn.ttson.cn/
@@ -421,7 +418,7 @@ TTS:
to_lang: ZH to_lang: ZH
url: https://u95167-bd74-2aef8085.westx.seetacloud.com:8443/flashsummary/tts?token= url: https://u95167-bd74-2aef8085.westx.seetacloud.com:8443/flashsummary/tts?token=
format: mp3 format: mp3
output_file: tmp/ output_dir: tmp/
emotion: 1 emotion: 1
OpenAITTS: OpenAITTS:
# openai官方文本转语音服务,可支持全球大多数语种 # openai官方文本转语音服务,可支持全球大多数语种
@@ -435,7 +432,7 @@ TTS:
voice: onyx voice: onyx
# 语速范围0.25-4.0 # 语速范围0.25-4.0
speed: 1 speed: 1
output_file: tmp/ output_dir: tmp/
CustomTTS: CustomTTS:
# 自定义的TTS接口服务,请求参数可自定义 # 自定义的TTS接口服务,请求参数可自定义
# 要求接口使用GET方式请求,并返回音频文件 # 要求接口使用GET方式请求,并返回音频文件
@@ -450,7 +447,7 @@ TTS:
headers: # 自定义请求头 headers: # 自定义请求头
# Authorization: Bearer xxxx # Authorization: Bearer xxxx
format: wav # 接口返回的音频格式 format: wav # 接口返回的音频格式
output_file: tmp/ output_dir: tmp/
# 模块测试配置 # 模块测试配置
module_test: module_test:
test_sentences: # 自定义测试语句 test_sentences: # 自定义测试语句
@@ -473,4 +470,4 @@ manager:
enabled: false enabled: false
ip: 0.0.0.0 ip: 0.0.0.0
port: 8002 port: 8002
use_private_config: false use_private_config: false
+46 -3
View File
@@ -7,9 +7,48 @@ from core.utils.util import read_config, get_project_dir
default_config_file = "config.yaml" default_config_file = "config.yaml"
def ensure_directories(config):
"""确保所有配置路径存在"""
dirs_to_create = set()
project_dir = get_project_dir() # 获取项目根目录
# 日志文件目录
log_dir = config.get('log', {}).get('log_dir', 'tmp')
dirs_to_create.add(os.path.join(project_dir, log_dir))
# ASR/TTS模块输出目录
for module in ['ASR', 'TTS']:
for provider in config.get(module, {}).values():
output_dir = provider.get('output_dir', '')
if output_dir:
dirs_to_create.add(output_dir)
# 根据selected_module创建模型目录
selected_modules = config.get('selected_module', {})
for module_type in ['ASR', 'LLM', 'TTS']:
selected_provider = selected_modules.get(module_type)
if not selected_provider:
continue
provider_config = config.get(module_type, {}).get(selected_provider, {})
output_dir = provider_config.get('output_dir')
if output_dir:
full_model_dir = os.path.join(project_dir, output_dir)
dirs_to_create.add(full_model_dir)
# 统一创建目录(保留原data目录创建)
for dir_path in dirs_to_create:
try:
os.makedirs(dir_path, exist_ok=True)
except PermissionError:
print(f"警告:无法创建目录 {dir_path},请检查写入权限")
def get_config_file(): def get_config_file():
global default_config_file global default_config_file
# 判断是否存在私有配置文件 """获取配置文件路径,优先使用私有配置文件(若存在)。
Returns:
str: 配置文件路径(相对路径或默认路径)
"""
config_file = default_config_file config_file = default_config_file
if os.path.exists(get_project_dir() + "data/." + default_config_file): if os.path.exists(get_project_dir() + "data/." + default_config_file):
config_file = "data/." + default_config_file config_file = "data/." + default_config_file
@@ -20,9 +59,13 @@ def load_config():
"""加载配置文件""" """加载配置文件"""
parser = argparse.ArgumentParser(description="Server configuration") parser = argparse.ArgumentParser(description="Server configuration")
config_file = get_config_file() config_file = get_config_file()
parser.add_argument("--config_path", type=str, default=config_file) parser.add_argument("--config_path", type=str, default=config_file)
args = parser.parse_args() args = parser.parse_args()
return read_config(args.config_path) config = read_config(args.config_path)
# 初始化目录
ensure_directories(config)
return config
def update_config(config): def update_config(config):
@@ -67,7 +110,7 @@ def find_missing_keys(new_config, old_config, parent_key=''):
def check_config_file(): def check_config_file():
old_config_file = get_config_file() old_config_file = get_config_file()
global default_config_file global default_config_file
if not old_config_file.startswith('data'): if not 'data' in old_config_file:
return return
old_config = read_config(get_project_dir() + old_config_file) old_config = read_config(get_project_dir() + old_config_file)
new_config = read_config(get_project_dir() + default_config_file) new_config = read_config(get_project_dir() + default_config_file)
+33 -23
View File
@@ -197,7 +197,7 @@ class ConnectionHandler:
self.dialogue.put(Message(role="system", content=self.prompt)) self.dialogue.put(Message(role="system", content=self.prompt))
self.func_handler = FunctionHandler(self.config) self.func_handler = FunctionHandler(self.config)
def change_system_prompt(self, prompt): def change_system_prompt(self, prompt):
self.prompt = prompt self.prompt = prompt
# 找到原来的role==system,替换原来的系统提示 # 找到原来的role==system,替换原来的系统提示
@@ -303,7 +303,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).debug(json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False)) self.logger.bind(tag=TAG).debug(json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False))
return True return True
def chat_with_function_calling(self, query, tool_call = False): def chat_with_function_calling(self, query, tool_call=False):
self.logger.bind(tag=TAG).debug(f"Chat with function calling start: {query}") self.logger.bind(tag=TAG).debug(f"Chat with function calling start: {query}")
"""Chat with function calling for intent detection using streaming""" """Chat with function calling for intent detection using streaming"""
if self.isNeedAuth(): if self.isNeedAuth():
@@ -311,7 +311,7 @@ class ConnectionHandler:
future = asyncio.run_coroutine_threadsafe(self._check_and_broadcast_auth_code(), self.loop) future = asyncio.run_coroutine_threadsafe(self._check_and_broadcast_auth_code(), self.loop)
future.result() future.result()
return True return True
if not tool_call: if not tool_call:
self.dialogue.put(Message(role="user", content=query)) self.dialogue.put(Message(role="user", content=query))
@@ -320,7 +320,7 @@ class ConnectionHandler:
response_message = [] response_message = []
processed_chars = 0 # 跟踪已处理的字符位置 processed_chars = 0 # 跟踪已处理的字符位置
try: try:
start_time = time.time() start_time = time.time()
@@ -328,7 +328,7 @@ class ConnectionHandler:
future = asyncio.run_coroutine_threadsafe(self.memory.query_memory(query), self.loop) future = asyncio.run_coroutine_threadsafe(self.memory.query_memory(query), self.loop)
memory_str = future.result() memory_str = future.result()
#self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}") # self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
# 使用支持functions的streaming接口 # 使用支持functions的streaming接口
llm_responses = self.llm.response_with_functions( llm_responses = self.llm.response_with_functions(
@@ -351,8 +351,8 @@ class ConnectionHandler:
content_arguments = "" content_arguments = ""
for response in llm_responses: for response in llm_responses:
content, tools_call = response content, tools_call = response
if content is not None and len(content)>0: if content is not None and len(content) > 0:
if len(response_message)<=0 and (content=="```" or "<tool_call>" in content): if len(response_message) <= 0 and (content == "```" or "<tool_call>" in content):
tool_call_flag = True tool_call_flag = True
if tools_call is not None: if tools_call is not None:
@@ -366,7 +366,7 @@ class ConnectionHandler:
if content is not None and len(content) > 0: if content is not None and len(content) > 0:
if tool_call_flag: if tool_call_flag:
content_arguments+=content content_arguments += content
else: else:
response_message.append(content) response_message.append(content)
@@ -422,16 +422,17 @@ class ConnectionHandler:
else: else:
function_arguments = json.loads(function_arguments) function_arguments = json.loads(function_arguments)
if not bHasError: if not bHasError:
self.logger.bind(tag=TAG).info(f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}") self.logger.bind(tag=TAG).info(
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}")
function_call_data = { function_call_data = {
"name": function_name, "name": function_name,
"id": function_id, "id": function_id,
"arguments": function_arguments "arguments": function_arguments
} }
result = self.func_handler.handle_llm_function_call(self, function_call_data) result = self.func_handler.handle_llm_function_call(self, function_call_data)
self._handle_function_result(result, function_call_data, text_index+1) self._handle_function_result(result, function_call_data, text_index + 1)
# 处理最后剩余的文本 # 处理最后剩余的文本
full_text = "".join(response_message) full_text = "".join(response_message)
remaining_text = full_text[processed_chars:] remaining_text = full_text[processed_chars:]
if remaining_text: if remaining_text:
@@ -443,7 +444,7 @@ class ConnectionHandler:
self.tts_queue.put(future) self.tts_queue.put(future)
# 存储对话内容 # 存储对话内容
if len(response_message)>0: if len(response_message) > 0:
self.dialogue.put(Message(role="assistant", content="".join(response_message))) self.dialogue.put(Message(role="assistant", content="".join(response_message)))
self.llm_finish_task = True self.llm_finish_task = True
@@ -452,31 +453,40 @@ class ConnectionHandler:
return True return True
def _handle_function_result(self, result, function_call_data, text_index): def _handle_function_result(self, result, function_call_data, text_index):
if result.action == Action.RESPONSE: # 直接回复前端 if result.action == Action.RESPONSE: # 直接回复前端
text = result.response text = result.response
self.recode_first_last_text(text, text_index) self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index) future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future) self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
if result.action == Action.REQLLM: # 调用函数后再请求llm生成回复 elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result text = result.result
if text is not None and len(text) > 0: if text is not None and len(text) > 0:
function_id = function_call_data["id"] function_id = function_call_data["id"]
function_name = function_call_data["name"] function_name = function_call_data["name"]
function_arguments = function_call_data["arguments"] function_arguments = function_call_data["arguments"]
self.dialogue.put(Message(role='assistant', self.dialogue.put(Message(role='assistant',
tool_calls=[{"id": function_id, tool_calls=[{"id": function_id,
"function": {"arguments": function_arguments,"name": function_name}, "function": {"arguments": function_arguments,
"type": 'function', "name": function_name},
"index": 0}])) "type": 'function',
"index": 0}]))
self.dialogue.put(Message(role="tool", tool_call_id=function_id, content=text)) self.dialogue.put(Message(role="tool", tool_call_id=function_id, content=text))
self.chat_with_function_calling(text, tool_call=True) self.chat_with_function_calling(text, tool_call=True)
if result.action == Action.NOTFOUND: elif result.action == Action.NOTFOUND:
text = result.response text = result.result
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))
else:
text = result.result
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))
def _tts_priority_thread(self): def _tts_priority_thread(self):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -50,6 +50,7 @@ class FunctionHandler:
self.function_registry.register_function("play_music") self.function_registry.register_function("play_music")
self.function_registry.register_function("plugin_loader") self.function_registry.register_function("plugin_loader")
self.function_registry.register_function("get_time") self.function_registry.register_function("get_time")
self.function_registry.register_function("raise_and_lower_the_volume")
def register_config_functions(self): def register_config_functions(self):
"""注册配置中的函数,可以不同客户端使用不同的配置""" """注册配置中的函数,可以不同客户端使用不同的配置"""
@@ -69,7 +70,7 @@ class FunctionHandler:
arguments = function_call_data["arguments"] arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {} arguments = json.loads(arguments) if arguments else {}
logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}") logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}")
if funcItem.type == ToolType.SYSTEM_CTL: if funcItem.type == ToolType.SYSTEM_CTL or funcItem.type == ToolType.IOT_CTL:
return func(conn, **arguments) return func(conn, **arguments)
elif funcItem.type == ToolType.WAIT: elif funcItem.type == ToolType.WAIT:
return func(**arguments) return func(**arguments)
+273 -97
View File
@@ -1,24 +1,151 @@
import json import json
import asyncio
from config.logger import setup_logging from config.logger import setup_logging
from plugins_func.register import device_type_registry, register_function, ActionResponse, Action, ToolType
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
def wrap_async_function(async_func):
"""包装异步函数为同步函数"""
def wrapper(*args, **kwargs):
try:
# 获取连接对象(第一个参数)
conn = args[0]
if not hasattr(conn, 'loop'):
logger.bind(tag=TAG).error("Connection对象没有loop属性")
return ActionResponse(Action.ERROR, "Connection对象没有loop属性",
"执行操作时出错: Connection对象没有loop属性")
# 使用conn对象中的事件循环
loop = conn.loop
# 在conn的事件循环中运行异步函数
future = asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), loop)
# 等待结果返回
return future.result()
except Exception as e:
logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}")
return wrapper
def create_iot_function(device_name, method_name, method_info):
"""
根据IOT设备描述生成通用的控制函数
"""
async def iot_control_function(conn, response_success=None, response_failure=None, **params):
try:
# 打印响应参数
logger.bind(tag=TAG).info(
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'")
# 发送控制命令
await send_iot_conn(conn, device_name, method_name, params)
# 等待一小段时间让状态更新
await asyncio.sleep(0.1)
# 生成结果信息
result = f"{device_name}{method_name}操作执行成功"
# 根据方法名尝试自动更新状态
await update_state_by_method(conn, device_name, method_name, params)
# 处理响应中可能的占位符
response = response_success
# 替换{value}占位符
for param_name, param_value in params.items():
# 先尝试直接替换参数值
if "{" + param_name + "}" in response:
response = response.replace("{" + param_name + "}", str(param_value))
# 如果有{value}占位符,用相关参数替换
if "{value}" in response:
response = response.replace("{value}", str(param_value))
break
return ActionResponse(Action.RESPONSE, result, response)
except Exception as e:
logger.bind(tag=TAG).error(f"执行{device_name}{method_name}操作失败: {e}")
# 操作失败时使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, str(e), response)
return wrap_async_function(iot_control_function)
def create_iot_query_function(device_name, prop_name, prop_info):
"""
根据IOT设备属性创建查询函数
"""
async def iot_query_function(conn, response_success=None, response_failure=None):
try:
# 打印响应参数
logger.bind(tag=TAG).info(
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'")
value = await get_iot_status(conn, device_name, prop_name)
# 查询成功,生成结果
if value is not None:
# 使用大模型提供的成功响应,并替换其中的占位符
response = response_success.replace("{value}", str(value))
return ActionResponse(Action.RESPONSE, str(value), response)
else:
# 查询失败,使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response)
except Exception as e:
logger.bind(tag=TAG).error(f"查询{device_name}{prop_name}时出错: {e}")
# 查询出错时使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, str(e), response)
return wrap_async_function(iot_query_function)
async def update_state_by_method(conn, device_name, method_name, params):
"""根据方法和参数自动更新设备状态"""
try:
# 规则1: 方法名为TurnOn,设置power为True
if method_name == "TurnOn":
await set_iot_status(conn, device_name, "power", True)
# 规则2: 方法名为TurnOff,设置power为False
elif method_name == "TurnOff":
await set_iot_status(conn, device_name, "power", False)
# 规则3: Set开头的方法,尝试更新对应参数
elif method_name.startswith("Set"):
# 从参数中找到可能的状态值
for param_name, param_value in params.items():
# 尝试更新对应名称的属性
await set_iot_status(conn, device_name, param_name, param_value)
# 其他方法,尝试直接从参数更新状态
else:
for param_name, param_value in params.items():
# 检查设备是否有此属性
status = await get_iot_status(conn, device_name, param_name)
if status is not None: # 属性存在
await set_iot_status(conn, device_name, param_name, param_value)
except Exception as e:
logger.bind(tag=TAG).warning(f"自动更新状态失败: {e}")
class IotDescriptor: class IotDescriptor:
""" """
A class to represent an IoT descriptor. A class to represent an IoT descriptor.
Attributes:
----------
name : str
The name of the IoT descriptor.
description : str
A brief description of the IoT descriptor.
properties : dict
A dictionary containing properties of the IoT descriptor.
methods : dict
A dictionary containing methods of the IoT descriptor.
-------
""" """
def __init__(self, name, description, properties, methods): def __init__(self, name, description, properties, methods):
@@ -29,17 +156,7 @@ class IotDescriptor:
# 根据描述创建属性 # 根据描述创建属性
for key, value in properties.items(): for key, value in properties.items():
# "volume":{"description":"当前音量 值","type":"number"} property_item = globals()[key] = {}
"""
等价于
{
'name': 名字,
'description': 描述,
'value': 0
}
"""
# setattr(self, key, {}) # 创建一个空字典, 名字是属性名
property_item = globals()[key] = {} # 创建一个空字典, 名字是属性名
property_item['name'] = key property_item['name'] = key
property_item["description"] = value["description"] property_item["description"] = value["description"]
if value["type"] == "number": if value["type"] == "number":
@@ -52,23 +169,10 @@ class IotDescriptor:
# 根据描述创建方法 # 根据描述创建方法
for key, value in methods.items(): for key, value in methods.items():
# "SetVolume": {"description":"设置音量","parameters":{"volume":{"description":"0到100之间的整数","type":"number"}}} method = globals()[key] = {}
"""
等价于
SetVolume = {
`description`: 描述,
`volume`: {
`description`: 描述,
`value`: 0
}
}
"""
# setattr(self, key, {}) # 创建一个空字典, 名字是方法名
method = globals()[key] = {} # 创建一个空字典, 名字是方法名
method["description"] = value["description"] method["description"] = value["description"]
method['name'] = key method['name'] = key
for k, v in value["parameters"].items(): for k, v in value["parameters"].items():
# 不同的参数解析
method[k] = {} method[k] = {}
method[k]["description"] = v["description"] method[k]["description"] = v["description"]
if v["type"] == "number": if v["type"] == "number":
@@ -77,58 +181,136 @@ class IotDescriptor:
method[k]["value"] = False method[k]["value"] = False
else: else:
method[k]["value"] = "" method[k]["value"] = ""
self.methods.append(method) self.methods.append(method)
async def handleIotDescriptors(conn, descriptors): def register_device_type(descriptor):
""" """注册设备类型及其功能"""
处理物联网描述 device_name = descriptor["name"]
示例: [{ type_id = device_type_registry.generate_device_type_id(descriptor)
"name":"Speaker",
"description":"当前 AI 机器人的扬声器", # 如果该类型已注册,直接返回类型ID
"properties":{ if type_id in device_type_registry.type_functions:
"volume":{"description":"当前音量 值","type":"number"} 可以有boolean, number, string三种类型 return type_id
},
"methods":{ functions = {}
"SetVolume":{
"description":"设置音量","parameters":{"volume":{"description":"0到100之间的整数","type":"number"}} # 为每个属性创建查询函数
for prop_name, prop_info in descriptor["properties"].items():
func_name = f"get_{device_name.lower()}_{prop_name.lower()}"
func_desc = {
"type": "function",
"function": {
"name": func_name,
"description": f"查询{descriptor['description']}{prop_info['description']}",
"parameters": {
"type": "object",
"properties": {
"response_success": {
"type": "string",
"description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值"
},
"response_failure": {
"type": "string",
"description": f"查询失败时的友好回复,例如:'无法获取{device_name}{prop_info['description']}'"
}
},
"required": ["response_success", "response_failure"]
}
} }
} }
}] query_func = create_iot_query_function(device_name, prop_name, prop_info)
descriptors: 描述列表 decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(query_func)
""" functions[func_name] = decorated_func
# 为每个方法创建控制函数
for method_name, method_info in descriptor["methods"].items():
func_name = f"{device_name.lower()}_{method_name.lower()}"
# 创建参数字典,添加原有参数
parameters = {
param_name: {
"type": param_info["type"],
"description": param_info["description"]
}
for param_name, param_info in method_info["parameters"].items()
}
# 添加响应参数
parameters.update({
"response_success": {
"type": "string",
"description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称"
},
"response_failure": {
"type": "string",
"description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称"
}
})
# 构建必须参数列表(原有参数 + 响应参数)
required_params = list(method_info["parameters"].keys())
required_params.extend(["response_success", "response_failure"])
func_desc = {
"type": "function",
"function": {
"name": func_name,
"description": f"{descriptor['description']} - {method_info['description']}",
"parameters": {
"type": "object",
"properties": parameters,
"required": required_params
}
}
}
control_func = create_iot_function(device_name, method_name, method_info)
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(control_func)
functions[func_name] = decorated_func
device_type_registry.register_device_type(type_id, functions)
return type_id
# 用于接受前端设备推送的搜索iot描述
async def handleIotDescriptors(conn, descriptors):
"""处理物联网描述"""
functions_changed = False
for descriptor in descriptors: for descriptor in descriptors:
# 创建IOT设备描述符
iot_descriptor = IotDescriptor(descriptor["name"], descriptor["description"], descriptor["properties"], iot_descriptor = IotDescriptor(descriptor["name"], descriptor["description"], descriptor["properties"],
descriptor["methods"]) descriptor["methods"])
conn.iot_descriptors[descriptor["name"]] = iot_descriptor conn.iot_descriptors[descriptor["name"]] = iot_descriptor
# 暂时从配置文件中设置音量,后期通过意图识别控制音量 if conn.use_function_call_mode:
default_iot_volume = 100 # 注册或获取设备类型
if "iot" in conn.config: type_id = register_device_type(descriptor)
default_iot_volume = conn.config["iot"]["Speaker"]["volume"] device_functions = device_type_registry.get_device_functions(type_id)
logger.bind(tag=TAG).info(f"服务端设置音量为{default_iot_volume}")
await send_iot_conn(conn, "Speaker", "SetVolume", {"volume": default_iot_volume}) # 在连接级注册设备函数
if hasattr(conn, 'func_handler'):
for func_name in device_functions:
conn.func_handler.function_registry.register_function(func_name)
logger.bind(tag=TAG).info(f"注册IOT函数到function handler: {func_name}")
functions_changed = True
# 如果注册了新函数,更新function描述列表
if functions_changed and hasattr(conn, 'func_handler'):
conn.func_handler.upload_functions_desc()
func_names = conn.func_handler.current_support_functions()
logger.bind(tag=TAG).info(f"设备类型: {type_id}")
logger.bind(tag=TAG).info(f"更新function描述列表完成,当前支持的函数: {func_names}")
async def handleIotStatus(conn, states): async def handleIotStatus(conn, states):
""" """处理物联网状态"""
处理物联网状态
示例: [{
"name":"Speaker",
"state":{
"volume":100
}
}]
states: 状态列表
"""
for state in states: for state in states:
for key, value in conn.iot_descriptors.items(): for key, value in conn.iot_descriptors.items():
if key == state["name"]: if key == state["name"]:
for property_item in value.properties: for property_item in value.properties:
# properties为字典列表, 记录各种属性
for k, v in state["state"].items(): for k, v in state["state"].items():
# state为字典, 记录各种属性的值, 是需要记录的信息
if property_item["name"] == k: if property_item["name"] == k:
# 检查一下属性是不是相同的
if type(v) != type(property_item["value"]): if type(v) != type(property_item["value"]):
logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配") logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配")
break break
@@ -138,41 +320,35 @@ async def handleIotStatus(conn, states):
break break
break break
async def get_iot_status(conn, name, property_name): async def get_iot_status(conn, name, property_name):
""" """获取物联网状态"""
获取物联网状态
name: 设备名称 "Speaker"
property_name: 属性名称 "volume"
返回值: 属性值, 实际的属性有int, bool和str三种类型
"""
for key, value in conn.iot_descriptors.items(): for key, value in conn.iot_descriptors.items():
if key == name: if key == name:
for property_item in value.properties: for property_item in value.properties:
if property_item["name"] == property_name: if property_item["name"] == property_name:
return property_item["value"] return property_item["value"]
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
return None return None
async def send_iot_conn(conn, name, method_name, parameters):
"""
发送物联网指令
name: 设备名称 "Speaker"
method: 方法 "SetVolume"
parameters: 参数, 是一个字典 {"volume": 100}
发送示例:
{
"type": "iot",
"commands": [
{
"name" : "Speaker",
"method": "SetVolume",
"parameters": {
"volume": 100
}
}
]
}
"""
async def set_iot_status(conn, name, property_name, value):
"""设置物联网状态"""
for key, iot_descriptor in conn.iot_descriptors.items():
if key == name:
for property_item in iot_descriptor.properties:
if property_item["name"] == property_name:
if type(value) != type(property_item["value"]):
logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配")
return
property_item["value"] = value
logger.bind(tag=TAG).info(f"物联网状态更新: {name} , {property_name} = {value}")
return
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
async def send_iot_conn(conn, name, method_name, parameters):
"""发送物联网指令"""
for key, value in conn.iot_descriptors.items(): for key, value in conn.iot_descriptors.items():
if key == name: if key == name:
# 找到了设备 # 找到了设备
@@ -2,7 +2,7 @@ from config.logger import setup_logging
import json import json
from core.handle.abortHandle import handleAbortMessage from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage from core.handle.helloHandle import handleHelloMessage
from core.handle.receiveAudioHandle import startToChat from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
TAG = __name__ TAG = __name__
@@ -24,13 +24,15 @@ async def handleTextMessage(conn, message):
elif msg_json["type"] == "listen": elif msg_json["type"] == "listen":
if "mode" in msg_json: if "mode" in msg_json:
conn.client_listen_mode = msg_json["mode"] conn.client_listen_mode = msg_json["mode"]
logger.bind(tag=TAG).debug(f"客户端拾音模式:{conn.client_listen_mode}") logger.bind(tag=TAG).debug(f"客户端拾音模式:{conn. client_listen_mode}")
if msg_json["state"] == "start": if msg_json["state"] == "start":
conn.client_have_voice = True conn.client_have_voice = True
conn.client_voice_stop = False conn.client_voice_stop = False
elif msg_json["state"] == "stop": elif msg_json["state"] == "stop":
conn.client_have_voice = True conn.client_have_voice = True
conn.client_voice_stop = True conn.client_voice_stop = True
if len(conn.asr_audio) > 0:
await handleAudioMessage(conn, b'')
elif msg_json["state"] == "detect": elif msg_json["state"] == "detect":
conn.asr_server_receive = False conn.asr_server_receive = False
conn.client_have_voice = False conn.client_have_voice = False
@@ -13,7 +13,7 @@ logger = setup_logging()
class TTSProviderBase(ABC): class TTSProviderBase(ABC):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
self.output_file = config.get("output_file") self.output_file = config.get("output_dir")
@abstractmethod @abstractmethod
def generate_filename(self): def generate_filename(self):
@@ -15,7 +15,7 @@ class TTSProvider(TTSProviderBase):
self.headers = config.get("headers", {}) self.headers = config.get("headers", {})
self.params = config.get("params") self.params = config.get("params")
self.format = config.get("format", "wav") self.format = config.get("format", "wav")
self.output_file = config.get("output_file", "tmp/") self.output_file = config.get("output_dir", "tmp/")
def generate_filename(self): def generate_filename(self):
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}.{self.format}") return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}.{self.format}")
@@ -22,7 +22,7 @@ class TTSProvider(TTSProviderBase):
self.cut_punc = config.get("cut_punc","") self.cut_punc = config.get("cut_punc","")
self.speed = config.get("speed", 1.0) self.speed = config.get("speed", 1.0)
self.inp_refs = config.get("inp_refs",[]) self.inp_refs = config.get("inp_refs",[])
self.sample_steps = config.get("inp_refs",32) self.sample_steps = config.get("sample_steps",32)
self.if_sr = config.get("if_sr",False) self.if_sr = config.get("if_sr",False)
@@ -14,7 +14,7 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("voice", "alloy") self.voice = config.get("voice", "alloy")
self.response_format = "wav" self.response_format = "wav"
self.speed = config.get("speed", 1.0) self.speed = config.get("speed", 1.0)
self.output_file = config.get("output_file", "tmp/") self.output_file = config.get("output_dir", "tmp/")
check_model_key("TTS", self.api_key) check_model_key("TTS", self.api_key)
def generate_filename(self, extension=".wav"): def generate_filename(self, extension=".wav"):
@@ -17,7 +17,7 @@ class TTSProvider(TTSProviderBase):
self.volume_change_dB = config.get("volume_change_dB", 0) self.volume_change_dB = config.get("volume_change_dB", 0)
self.speed_factor = config.get("speed_factor", 1) self.speed_factor = config.get("speed_factor", 1)
self.stream = config.get("stream", False) self.stream = config.get("stream", False)
self.output_file = config.get("output_file") self.output_file = config.get("output_dir")
self.pitch_factor = config.get("pitch_factor", 0) self.pitch_factor = config.get("pitch_factor", 0)
self.format = config.get("format", "mp3") self.format = config.get("format", "mp3")
self.emotion = config.get("emotion", 1) self.emotion = config.get("emotion", 1)
+1
View File
@@ -21,6 +21,7 @@ services:
- ./data:/opt/xiaozhi-esp32-server/data - ./data:/opt/xiaozhi-esp32-server/data
# 模型文件挂接,很重要 # 模型文件挂接,很重要
- ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
# #智控台还没开发好,还不能完全使用,会报很多错误,如果是非技术人员,请不要启用智控台服务 # #智控台还没开发好,还不能完全使用,会报很多错误,如果是非技术人员,请不要启用智控台服务
# xiaozhi-esp32-server-web: # xiaozhi-esp32-server-web:
# image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest # image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
@@ -112,7 +112,6 @@ def get_music_files(music_dir, music_ext):
def initialize_music_handler(conn): def initialize_music_handler(conn):
global MUSIC_CACHE global MUSIC_CACHE
if MUSIC_CACHE == {}: if MUSIC_CACHE == {}:
logger.bind(tag=TAG).info(f"实例化音乐:")
if "music" in conn.config: if "music" in conn.config:
MUSIC_CACHE["music_config"] = conn.config["music"] MUSIC_CACHE["music_config"] = conn.config["music"]
MUSIC_CACHE["music_dir"] = os.path.abspath( MUSIC_CACHE["music_dir"] = os.path.abspath(
@@ -0,0 +1,64 @@
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.handle.iotHandle import get_iot_status, send_iot_conn
import asyncio
TAG = __name__
logger = setup_logging()
raise_and_lower_the_volume_function_desc = {
"type": "function",
"function": {
"name": "raise_and_lower_the_volume",
"description": "用户觉得声音过高或过低,或者用户想提高或降低音量。比如用户说太大声了,参数为:lower,比如用户说提高音量,参数为:raise",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "动作名称,要么是raise,要么是lower"
}
},
"required": ["action"]
}
}
}
@register_function('raise_and_lower_the_volume', raise_and_lower_the_volume_function_desc, ToolType.IOT_CTL)
def raise_and_lower_the_volume(conn, action: str):
"""
获取当前设备音量
"""
future = asyncio.run_coroutine_threadsafe(
_raise_and_lower_the_volume(conn, action),
conn.loop
)
try:
new_volume = future.result() # 同步等待异步操作完成
logger.bind(tag=TAG).info(f"音量操作完成: {new_volume}")
response = f"音量已调整到{new_volume}"
except Exception as e:
logger.bind(tag=TAG).error(f"音量操作失败: {e}")
response = f"音量调整失败: {e}"
return ActionResponse(action=Action.RESPONSE, result="指令已接收", response=response)
async def _raise_and_lower_the_volume(conn, action):
volume = await get_iot_status(conn, "Speaker", "volume")
if volume is None:
raise Exception("你的设备不支持音量控制")
if action == 'raise':
volume += 10
elif action == 'lower':
volume -= 10
# 限制音量范围在0到100之间
if volume < 0:
volume = 0
elif volume > 100:
volume = 100
await send_iot_conn(conn, "Speaker", "SetVolume", {"volume": volume})
return volume
@@ -11,6 +11,7 @@ class ToolType(Enum):
WAIT = (2, "调用工具,等待函数返回") WAIT = (2, "调用工具,等待函数返回")
CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责") CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责")
SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数") SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数")
IOT_CTL = (5, "IOT设备控制,需要传递conn参数")
def __init__(self, code, message): def __init__(self, code, message):
self.code = code self.code = code
@@ -18,6 +19,7 @@ class ToolType(Enum):
class Action(Enum): class Action(Enum):
ERROR = (-1, "错误")
NOTFOUND = (0, "没有找到函数") NOTFOUND = (0, "没有找到函数")
NONE = (1, "啥也不干") NONE = (1, "啥也不干")
RESPONSE = (2, "直接回复") RESPONSE = (2, "直接回复")
@@ -40,8 +42,31 @@ class FunctionItem:
self.func = func self.func = func
self.type = type self.type = type
class DeviceTypeRegistry:
"""设备类型注册表,用于管理IOT设备类型及其函数"""
def __init__(self):
self.type_functions = {} # type_signature -> {func_name: FunctionItem}
def generate_device_type_id(self, descriptor):
"""通过设备能力描述生成类型ID"""
properties = sorted(descriptor["properties"].keys())
methods = sorted(descriptor["methods"].keys())
# 使用属性和方法的组合作为设备类型的唯一标识
type_signature = f"{descriptor['name']}:{','.join(properties)}:{','.join(methods)}"
return type_signature
def get_device_functions(self, type_id):
"""获取设备类型对应的所有函数"""
return self.type_functions.get(type_id, {})
def register_device_type(self, type_id, functions):
"""注册设备类型及其函数"""
if type_id not in self.type_functions:
self.type_functions[type_id] = functions
# 初始化函数注册字典 # 初始化函数注册字典
all_function_registry = {} all_function_registry = {}
device_type_registry = DeviceTypeRegistry()
def register_function(name, desc, type=None): def register_function(name, desc, type=None):
"""注册函数到函数注册字典的装饰器""" """注册函数到函数注册字典的装饰器"""
+1 -1
View File
@@ -19,4 +19,4 @@ loguru==0.7.3
requests==2.32.3 requests==2.32.3
cozepy==0.12.0 cozepy==0.12.0
mem0ai==0.1.62 mem0ai==0.1.62
bs4==0.0.2 bs4==0.0.2