Merge branch 'manager-plugin-api' into web-Plug
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a>
|
||||
<a href="./README_en.md">English</a>
|
||||
· <a href="./docs/FAQ.md">常见问题</a>
|
||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
|
||||
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
[](https://github.com/xinnan-tech/xiaozhi-esp32-server)
|
||||
|
||||
<h1 align="center">Xiaozhi Backend Service xiaozhi-esp32-server</h1>
|
||||
|
||||
<p align="center">
|
||||
This project provides backend services for the open-source smart hardware project
|
||||
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a><br/>
|
||||
Implemented using Python, Java, and Vue according to the <a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">Xiaozhi Communication Protocol</a><br/>
|
||||
Helping you quickly set up your Xiaozhi server
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">中文</a>
|
||||
· <a href="./docs/FAQ.md">FAQ</a>
|
||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">Report Issues</a>
|
||||
· <a href="./README_ed.md#deployment-documentation">Deployment Guide</a>
|
||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">Release Notes</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
|
||||
</a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/contributors/xinnan-tech/xiaozhi-esp32-server?logo=github" />
|
||||
</a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">
|
||||
<img alt="Issues" src="https://img.shields.io/github/issues/xinnan-tech/xiaozhi-esp32-server?color=0088ff" />
|
||||
</a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/pulls">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/xinnan-tech/xiaozhi-esp32-server?color=0088ff" />
|
||||
</a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/LICENSE">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/badge/license-MIT-white?labelColor=black" />
|
||||
</a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server">
|
||||
<img alt="stars" src="https://img.shields.io/github/stars/xinnan-tech/xiaozhi-esp32-server?color=ffcb47&labelColor=black" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Target Users 👥
|
||||
|
||||
This project requires ESP32 hardware devices. If you have purchased ESP32-related hardware, successfully connected to Brother Xia's backend service, and want to set up your own `xiaozhi-esp32` backend service, then this project is perfect for you.
|
||||
|
||||
Want to see it in action? Check out these videos 🎥
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="Xiaozhi esp32 connecting to custom backend model" src="docs/images/demo1.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Custom voice" src="docs/images/demo2.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="Cantonese communication" src="docs/images/demo3.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="Home appliance control" src="docs/images/demo5.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
|
||||
<picture>
|
||||
<img alt="Lowest cost configuration" src="docs/images/demo4.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
||||
<picture>
|
||||
<img alt="Custom voice" src="docs/images/demo6.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Music playback" src="docs/images/demo7.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Z8XuYZEAS" target="_blank">
|
||||
<picture>
|
||||
<img alt="Weather plugin" src="docs/images/demo8.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV178XuYfEpi" target="_blank">
|
||||
<picture>
|
||||
<img alt="IOT device control" src="docs/images/demo9.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV17LXWYvENb" target="_blank">
|
||||
<picture>
|
||||
<img alt="News broadcast" src="docs/images/demo0.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Warning ⚠️
|
||||
|
||||
1. This project is open-source software. This software has no commercial relationship with any third-party API service providers (including but not limited to speech recognition, large models, speech synthesis, and other platforms) and does not provide any form of guarantee for their service quality or financial security.
|
||||
It is recommended that users prioritize service providers with relevant business licenses and carefully read their service agreements and privacy policies. This software does not host any account keys, does not participate in fund transfers, and does not bear the risk of recharge fund losses.
|
||||
|
||||
2. This project's functionality is not complete and has not passed network security testing. Please do not use it in production environments. If you deploy this project for learning in a public network environment, please ensure necessary protection measures are in place.
|
||||
|
||||
---
|
||||
|
||||
## Deployment Documentation
|
||||
|
||||

|
||||
|
||||
This project offers two deployment methods. Please choose based on your specific needs:
|
||||
|
||||
#### 🚀 Deployment Method Selection
|
||||
|
||||
| Deployment Method | Features | Use Case | Docker Deployment Guide | Source Code Deployment Guide |
|
||||
|---------|------|---------|---------|---------|
|
||||
| **Simplified Installation** | Smart dialogue, IOT functionality, data stored in configuration files | Low-configuration environment, no database required | [Docker Server Only](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [Local Source Code Server Only](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)|
|
||||
| **Full Module Installation** | Smart dialogue, IOT, OTA, Control Panel, data stored in database | Complete functionality experience |[Docker Full Module](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [Local Source Code Full Module](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) |
|
||||
|
||||
> 💡 Note: Below is the test platform deployed with the latest code. You can flash and test if needed. Concurrent users: 6, data cleared daily
|
||||
|
||||
```
|
||||
Control Panel: https://2662r3426b.vicp.fun
|
||||
|
||||
Service Test Tool: https://2662r3426b.vicp.fun/test/
|
||||
OTA Interface: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||
Websocket Interface: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
```
|
||||
|
||||
---
|
||||
## Feature List ✨
|
||||
|
||||
### Implemented ✅
|
||||
|
||||
| Feature Module | Description |
|
||||
|---------|------|
|
||||
| Communication Protocol | Based on `xiaozhi-esp32` protocol, implements data interaction through WebSocket |
|
||||
| Dialogue Interaction | Supports wake-up dialogue, manual dialogue, and real-time interruption. Auto-sleep after long periods of inactivity |
|
||||
| Intent Recognition | Supports LLM intent recognition, function call, reducing hard-coded intent judgment |
|
||||
| Multi-language Recognition | Supports Mandarin, Cantonese, English, Japanese, Korean (default using FunASR) |
|
||||
| LLM Module | Supports flexible LLM module switching, default using ChatGLMLLM, also supports Ali Bailing, DeepSeek, Ollama, etc. |
|
||||
| TTS Module | Supports EdgeTTS (default), Volcano Engine Doubao TTS, and other TTS interfaces for speech synthesis |
|
||||
| Memory Function | Supports ultra-long memory, local summary memory, and no memory modes for different scenarios |
|
||||
| IOT Function | Supports managing registered device IOT functionality, intelligent IoT control based on dialogue context |
|
||||
| Control Panel | Provides web management interface, supports agent management, user management, system configuration, etc. |
|
||||
|
||||
### In Development 🚧
|
||||
|
||||
To learn about specific development progress, [click here](https://github.com/users/xinnan-tech/projects/3)
|
||||
|
||||
If you're a software developer, here's an [Open Letter to Developers](docs/contributor_open_letter.md). Welcome to join!
|
||||
|
||||
---
|
||||
|
||||
## Product Ecosystem 👬
|
||||
Xiaozhi is an ecosystem. When using this product, you might want to check out other excellent projects in this ecosystem:
|
||||
|
||||
| Project Name | Project Link | Description |
|
||||
|:---------------------|:--------|:--------|
|
||||
| Xiaozhi Android Client | [xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) | A Flutter-based Android and iOS voice dialogue application supporting real-time voice interaction and text dialogue |
|
||||
| Xiaozhi PC Client | [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) | A Python-based AI client that allows you to experience Xiaozhi AI functionality through code without physical hardware |
|
||||
| Xiaozhi Java Server | [xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) | A Java-based open-source project providing complete backend service solutions |
|
||||
|
||||
---
|
||||
|
||||
## Supported Platforms/Components 📋
|
||||
|
||||
### LLM Language Models
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| openai API | Ali Bailing, Volcano Engine Doubao, DeepSeek, ChatGLM, Gemini | ChatGLM, Gemini |
|
||||
| ollama API | Ollama | - |
|
||||
| dify API | Dify | - |
|
||||
| fastgpt API | Fastgpt | - |
|
||||
| coze API | Coze | - |
|
||||
|
||||
Actually, any LLM supporting openai API calls can be integrated.
|
||||
|
||||
---
|
||||
|
||||
### TTS Speech Synthesis
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| API Calls | EdgeTTS, Volcano Engine Doubao TTS, Tencent Cloud, Aliyun TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS | EdgeTTS, CosyVoiceSiliconflow(partial) |
|
||||
| Local Service | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS |
|
||||
|
||||
---
|
||||
|
||||
### VAD Voice Activity Detection
|
||||
|
||||
| Type | Platform Name | Usage Method | Pricing | Notes |
|
||||
|:---:|:---------:|:----:|:----:|:--:|
|
||||
| VAD | SileroVAD | Local Use | Free | |
|
||||
|
||||
---
|
||||
|
||||
### ASR Speech Recognition
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| Local Use | FunASR, SherpaASR | FunASR, SherpaASR |
|
||||
| API Calls | DoubaoASR, FunASRServer, TencentASR, AliyunASR | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
### Memory Storage
|
||||
|
||||
| Type | Platform Name | Usage Method | Pricing | Notes |
|
||||
|:------:|:---------------:|:----:|:---------:|:--:|
|
||||
| Memory | mem0ai | API Calls | 1000 calls/month quota | |
|
||||
| Memory | mem_local_short | Local Summary | Free | |
|
||||
|
||||
---
|
||||
|
||||
### Intent Recognition
|
||||
|
||||
| Type | Platform Name | Usage Method | Pricing | Notes |
|
||||
|:------:|:-------------:|:----:|:-------:|:---------------------:|
|
||||
| Intent | intent_llm | API Calls | Based on LLM pricing | Uses large model for intent recognition, highly versatile |
|
||||
| Intent | function_call | API Calls | Based on LLM pricing | Uses large model function calls for intent, fast and effective |
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments 🙏
|
||||
|
||||
| Logo | Project/Company | Description |
|
||||
|:---:|:---:|:---|
|
||||
| <img src="./docs/images/logo_bailing.png" width="160"> | [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) | This project was inspired by [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) and implemented based on it |
|
||||
| <img src="./docs/images/logo_tenclass.png" width="160"> | [Tenclass](https://www.tenclass.com/) | Thanks to [Tenclass](https://www.tenclass.com/) for developing standard communication protocols, multi-device compatibility solutions, and high-concurrency scenario practices for the Xiaozhi ecosystem; providing comprehensive technical documentation support for this project |
|
||||
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [Xuanfeng Technology](https://github.com/Eric0308) | Thanks to [Xuanfeng Technology](https://github.com/Eric0308) for contributing function call framework, MCP communication protocol, and plugin call mechanism implementation code, significantly improving front-end device (IoT) interaction efficiency and functional extensibility through standardized instruction scheduling system and dynamic expansion capabilities |
|
||||
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [Huiyuan Design](http://ui.kwd988.net/) | Thanks to [Huiyuan Design](http://ui.kwd988.net/) for providing professional visual solutions for this project, empowering product user experience with their design experience serving over a thousand enterprises |
|
||||
| <img src="./docs/images/logo_qinren.png" width="160"> | [Xi'an Qinren Information Technology](https://www.029app.com/) | Thanks to [Xi'an Qinren Information Technology](https://www.029app.com/) for deepening this project's visual system, ensuring consistency and extensibility of overall design style in multi-scenario applications |
|
||||
|
||||
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,4 +1,8 @@
|
||||
# ESP32设备与HomeAssistant集成指南
|
||||
# 小智ESP32-开源服务端与HomeAssistant集成指南
|
||||
|
||||
[TOC]
|
||||
|
||||
-----
|
||||
|
||||
## 简介
|
||||
|
||||
@@ -7,26 +11,58 @@
|
||||
## 前提条件
|
||||
|
||||
- 已安装并配置好`HomeAssistant`
|
||||
- 已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
|
||||
- `xiaozhi-esp32-server智控台`能正常使用
|
||||
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
|
||||
|
||||
## 网络环境
|
||||
## 开始前的操作(必要)
|
||||
|
||||
### 1. 获取HA的网络网络地址信息
|
||||
|
||||
请访问你Home Assistant的网络地址,例如,我的HA的地址是192.168.4.7,端口则是默认的8123,则在浏览器打开
|
||||
|
||||
1、我的`HomeAssistant`部署在下面,版本是`2025.3.4`
|
||||
```
|
||||
http://192.168.4.7:8123
|
||||
```
|
||||
|
||||
2、我的`xiaozhi-esp32-server智控台`和`HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
|
||||
> 手动查询 HA 的 IP 地址方法**(仅限小智esp32-server和HA部署在同一个网络设备[例如同一个wifi]下)**:
|
||||
>
|
||||
> 1. 进入 Home Assistant(前端)。
|
||||
>
|
||||
> 2. 点击左下角 **设置(Settings)** → **系统(System)** → **网络(Network)**。
|
||||
>
|
||||
> 3. 滑到最底部`Home Assistant 网址(Home Assistant website)`区域,在`本地网络(local network)`中,点击`眼睛`按钮,可以看到当前使用的 IP 地址(如 `192.168.1.10`)和网络接口。点击`复制连接(copy link)`可以直接复制。
|
||||
>
|
||||
> 
|
||||
|
||||
或,您已经设置了直接可以访问的Home Assistant的OAuth地址,您也可以在浏览器内直接访问
|
||||
|
||||
```
|
||||
http://192.168.4.7:8002
|
||||
http://homeassistant.local:8123
|
||||
```
|
||||
|
||||
### 2. 登录`Home Assistant`拿到开发密钥
|
||||
|
||||
## 配置步骤
|
||||
登录`HomeAssistant`,点击`左下角头像 -> 个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key,并复制保存,后续的方法都需要使用这个api key且仅出现一次(小tips: 您可以保存生成的二维码图像,后续可以扫描二维码再此提取api key)。
|
||||
|
||||
### 1. 登录`HomeAssistant`整理需要控制的设备清单
|
||||
## 方法1:小智社区共建的HA调用功能
|
||||
|
||||
### 功能描述
|
||||
|
||||
- 如您后续需要增加新的设备,该方法需要手动重启`xiaozhi-esp32-server服务端`以此更新设备信息**(重要**)。
|
||||
|
||||
- 需要您确保已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`。
|
||||
|
||||
- 需要您确保`xiaozhi-esp32-server智控台`能正常使用。
|
||||
|
||||
- 我的`xiaozhi-esp32-server智控台`和`HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
|
||||
|
||||
```
|
||||
http://192.168.4.7:8002
|
||||
```
|
||||
|
||||
|
||||
### 配置步骤
|
||||
|
||||
#### 1. 登录`HomeAssistant`整理需要控制的设备清单
|
||||
|
||||
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`。
|
||||
|
||||
@@ -56,12 +92,7 @@ http://192.168.4.7:8002
|
||||
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
|
||||
|
||||
|
||||
### 2. 登录`HomeAssistant`拿到开发密钥
|
||||
|
||||
登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
|
||||
|
||||
|
||||
### 3. 登录`智控台`
|
||||
#### 2. 登录`智控台`
|
||||
|
||||
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
|
||||
|
||||
@@ -77,7 +108,7 @@ http://192.168.4.7:8123
|
||||
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
|
||||
|
||||
|
||||
### 4. 设置`意图识别`函数
|
||||
#### 3. 设置`意图识别`函数
|
||||
|
||||
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
|
||||
|
||||
@@ -94,18 +125,124 @@ change_role;get_weather;get_news;play_music
|
||||
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
|
||||
```
|
||||
|
||||
### 6. 手动重启xiaozhi-server
|
||||
#### 4. 手动重启xiaozhi-server
|
||||
|
||||
重启xiaozhi-server程序
|
||||
|
||||
|
||||
### 5. 确认角色配置是否设置了函数意图识别
|
||||
#### 5. 确认角色配置是否设置了函数意图识别
|
||||
|
||||
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
|
||||
|
||||
确认意图识别(Intent),是否选择“函数调用意图识别”
|
||||
|
||||
|
||||
### 6. 唤醒设别进行控制
|
||||
#### 6. 唤醒设别进行控制
|
||||
|
||||
尝试和esp32说,“打开XXX灯”
|
||||
尝试和esp32说,“打开XXX灯”
|
||||
|
||||
## 方法2:小智将Home Assistant的语音助手作为LLM工具
|
||||
|
||||
### 功能描述
|
||||
|
||||
- 该方法有一个比较严重的缺点——**该方法无法使用小智开源生态的function_call插件功能的能力**,因为使用Home Assistant作为小智的LLM工具会将意图识别能力转让给Home Assistant。但是**这个方法是能体验到原生的Home Assistant操作功能,且小智的聊天能力不变**。如实在介意可以使用同样是Home Assistant支持的[方法3](##方法3:使用Home Assistant的MCP服务(推荐)),能够最大程度体验到Home Assistant的功能。
|
||||
|
||||
### 配置步骤:
|
||||
|
||||
#### 1. 配置Home Assistant的大模型语音助手。
|
||||
|
||||
**需要您提前配置好Home Assistant的语音助手或大模型工具。**
|
||||
|
||||
#### 2. 获取Home Assistant的语言助手的Agent ID.
|
||||
|
||||
1. 进入Home Assistant页面内。左侧点击`开发者助手`。
|
||||
2. 在打开的`开发者助手`内,点击`动作`选项卡(如图示操作1),在页面内的选项栏`动作`中,找到或输入`conversation.process(对话-处理)`并选择`对话(conversation): 处理`(如图示操作2)。
|
||||
|
||||

|
||||
|
||||
3. 在页面内勾选`代理(agent)`选项,在变成常亮的`对话代理(conversation agent)`内选择您步骤一配置好的语音助手名称,如图示,我这边配置好的是`ZhipuAi`并选择。
|
||||
|
||||

|
||||
|
||||
4. 选中后,点击表单左下方的`进入YAML模式`。
|
||||
|
||||

|
||||
|
||||
5. 复制其中的agent-id的值,例如图示中我的是`01JP2DYMBDF7F4ZA2DMCF2AGX2`(仅供参考)。
|
||||
|
||||

|
||||
|
||||
6. 切换到小智开源服务端`xiaozhi-esp32-server`的`config.yaml`文件内,在LLM配置中,找到Home Assistant,设置您的Home Assistant的网络地址,Api key和刚刚查询到的agent_id。
|
||||
7. 修改`config.yaml`文件内的`selected_module`属性的`LLM`为`HomeAssistant`,`Intent`为`nointent`。
|
||||
8. 重启小智开源服务端`xiaozhi-esp32-server`即可正常使用。
|
||||
|
||||
## 方法3:使用Home Assistant的MCP服务(推荐)
|
||||
|
||||
### 功能描述
|
||||
|
||||
- 需要您提前在Home Assistant内集成并安装好HA集成——[Model Context Protocol Server](https://www.home-assistant.io/integrations/mcp_server/)。
|
||||
|
||||
- 这个方法与方法2都是HA官方提供的解决方法,与方法2不同的是,您可以正常使用小智开源服务端`xiaozhi-esp32-server`的开源共建的插件,同时允许您随意使用任何一个支持function_call功能的LLM大模型。
|
||||
|
||||
### 配置步骤
|
||||
|
||||
#### 1. 安装Home Assistant的MCP服务集成。
|
||||
|
||||
集成官方网址——[Model Context Protocol Server](https://www.home-assistant.io/integrations/mcp_server/)。。
|
||||
|
||||
或跟随以下手动操作。
|
||||
|
||||
> - 前往Home Assistant页面的**[设置 > 设备和服务(Settings > Devices & Services.)](https://my.home-assistant.io/redirect/integrations)**。
|
||||
>
|
||||
> - 在右下角,选择 **[添加集成(Add Integration)](https://my.home-assistant.io/redirect/config_flow_start?domain=mcp_server)**按钮。
|
||||
>
|
||||
> - 从列表中选择**模型上下文协议服务器(Model Context Protocol Server)**。
|
||||
>
|
||||
> - 按照屏幕上的说明完成设置。
|
||||
|
||||
#### 2. 配置小智开源服务端MCP配置信息
|
||||
|
||||
切换到小智开源服务端`xiaozhi-esp32-server`的`mcp_server_settings.json`文件内,在`"mcpServers"`的括号内添加以下内容:
|
||||
|
||||
```json
|
||||
"Home Assistant": {
|
||||
"command": "mcp-proxy",
|
||||
"args": [
|
||||
"http://YOUR_HA_HOST/mcp_server/sse"
|
||||
],
|
||||
"env": {
|
||||
"API_ACCESS_TOKEN": "YOUR_API_ACCESS_TOKEN"
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
1. **替换配置:**
|
||||
- 替换`args`内的`YOUR_HA_HOST`为您的HA服务地址,如果你的服务地址已经包含了https/http字样(例如`http://192.168.1.101:8123`),则只需要填入`192.168.1.101:8123`即可。
|
||||
- 将`env`内`API_ACCESS_TOKEN`的`YOUR_API_ACCESS_TOKEN`替换成您之前获取到的开发密钥api key。
|
||||
2. **如果你添加配置是在`"mcpServers"`的括号内后续没有新的`mcpServers`的配置时,需要把最后的逗号`,`移除**,否则可能会解析失败。
|
||||
|
||||
**最后效果参考以下(参考如下)**:
|
||||
|
||||
```json
|
||||
"mcpServers": {
|
||||
"Home Assistant": {
|
||||
"command": "mcp-proxy",
|
||||
"args": [
|
||||
"http://192.168.1.101:8123/mcp_server/sse"
|
||||
],
|
||||
"env": {
|
||||
"API_ACCESS_TOKEN": "abcd.efghi.jkl"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 配置小智开源服务端的系统配置
|
||||
|
||||
1. **选择任意一款支持function_call的LLM大模型作为小智的LLM聊天助手(但不要选择Home Assistant作为LLM工具)**,本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用,但部分时候调用不太稳定,如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115。
|
||||
|
||||
2. 切换到小智开源服务端`xiaozhi-esp32-server`的`config.yaml`文件内,设置您的LLM大模型配置,并且将`selected_module`配置的`Intent`调整为`function_call`。
|
||||
|
||||
3. 重启小智开源服务端`xiaozhi-esp32-server`即可正常使用。
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -16,4 +16,4 @@ https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh
|
||||
|
||||
# manager-web 、manager-api接口协议
|
||||
|
||||
https://2662r3426b.vicp.fun/xiaozhi/v1/doc.html
|
||||
https://2662r3426b.vicp.fun/xiaozhi/doc.html
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package xiaozhi.common.constant;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 常量
|
||||
* Copyright (c) 人人开源 All rights reserved.
|
||||
@@ -174,6 +176,21 @@ public interface Constant {
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
enum ChatHistoryConfEnum {
|
||||
IGNORE(0, "不记录"),
|
||||
RECORD_TEXT(1, "记录文本"),
|
||||
RECORD_TEXT_AUDIO(2, "文本音频都记录");
|
||||
|
||||
private final int code;
|
||||
private final String name;
|
||||
|
||||
ChatHistoryConfEnum(int code, String name) {
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
|
||||
@@ -109,6 +109,7 @@ public class AgentController {
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
entity.setSystemPrompt(template.getSystemPrompt());
|
||||
entity.setChatHistoryConf(template.getChatHistoryConf());
|
||||
entity.setLangCode(template.getLangCode());
|
||||
entity.setLanguage(template.getLanguage());
|
||||
}
|
||||
@@ -166,6 +167,9 @@ public class AgentController {
|
||||
if (dto.getSystemPrompt() != null) {
|
||||
existingEntity.setSystemPrompt(dto.getSystemPrompt());
|
||||
}
|
||||
if (dto.getChatHistoryConf() != null) {
|
||||
existingEntity.setChatHistoryConf(dto.getChatHistoryConf());
|
||||
}
|
||||
if (dto.getLangCode() != null) {
|
||||
existingEntity.setLangCode(dto.getLangCode());
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ public class AgentUpdateDTO implements Serializable {
|
||||
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false)
|
||||
private String systemPrompt;
|
||||
|
||||
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", required = false)
|
||||
private Integer chatHistoryConf;
|
||||
|
||||
@Schema(description = "语言编码", example = "zh_CN", required = false)
|
||||
private String langCode;
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ public class AgentEntity {
|
||||
@Schema(description = "意图模型标识")
|
||||
private String intentModelId;
|
||||
|
||||
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)")
|
||||
private Integer chatHistoryConf;
|
||||
|
||||
@Schema(description = "角色设定参数")
|
||||
private String systemPrompt;
|
||||
|
||||
|
||||
@@ -69,6 +69,11 @@ public class AgentTemplateEntity implements Serializable {
|
||||
*/
|
||||
private String intentModelId;
|
||||
|
||||
/**
|
||||
* 聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)
|
||||
*/
|
||||
private Integer chatHistoryConf;
|
||||
|
||||
/**
|
||||
* 角色设定参数
|
||||
*/
|
||||
|
||||
@@ -2,12 +2,14 @@ package xiaozhi.modules.agent.service.biz.impl;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
|
||||
@@ -47,8 +49,33 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
||||
Byte chatType = report.getChatType();
|
||||
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
|
||||
|
||||
// 1. base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
|
||||
// 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
|
||||
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
|
||||
if (agentEntity == null) {
|
||||
return Boolean.FALSE;
|
||||
}
|
||||
|
||||
Integer chatHistoryConf = agentEntity.getChatHistoryConf();
|
||||
String agentId = agentEntity.getId();
|
||||
|
||||
if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT.getCode())) {
|
||||
saveChatText(report, agentId, macAddress, null);
|
||||
} else if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode())) {
|
||||
String audioId = saveChatAudio(report);
|
||||
saveChatText(report, agentId, macAddress, audioId);
|
||||
}
|
||||
|
||||
// 更新设备最后对话时间
|
||||
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
|
||||
*/
|
||||
private String saveChatAudio(AgentChatHistoryReportDTO report) {
|
||||
String audioId = null;
|
||||
|
||||
if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
|
||||
try {
|
||||
byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
|
||||
@@ -56,20 +83,18 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
||||
log.info("音频数据保存成功,audioId={}", audioId);
|
||||
} catch (Exception e) {
|
||||
log.error("音频数据保存失败", e);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return audioId;
|
||||
}
|
||||
|
||||
// 2. 组装上报数据
|
||||
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
|
||||
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
|
||||
if (agentEntity == null) {
|
||||
return false;
|
||||
}
|
||||
String agentId = agentEntity.getId();
|
||||
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
|
||||
/**
|
||||
* 组装上报数据
|
||||
*/
|
||||
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId) {
|
||||
|
||||
// 2.2 构建聊天记录实体
|
||||
// 构建聊天记录实体
|
||||
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
|
||||
.macAddress(macAddress)
|
||||
.agentId(agentId)
|
||||
@@ -79,10 +104,9 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
||||
.audioId(audioId)
|
||||
.build();
|
||||
|
||||
// 3. 保存数据
|
||||
// 保存数据
|
||||
agentChatHistoryService.save(entity);
|
||||
// 4. 更新设备最后对话时间
|
||||
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
|
||||
return Boolean.TRUE;
|
||||
|
||||
log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,8 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
|
||||
intentLLMModelId = null;
|
||||
}
|
||||
} else if ("function_call".equals(map.get("type"))) {
|
||||
}
|
||||
if (map.get("functions") != null) {
|
||||
String functionStr = (String) map.get("functions");
|
||||
if (StringUtils.isNotBlank(functionStr)) {
|
||||
String[] functions = functionStr.split("\\;");
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package xiaozhi.modules.device.dao;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DeviceDao extends BaseMapper<DeviceEntity> {
|
||||
/**
|
||||
* 获取此智能体全部设备的最后连接时间
|
||||
*
|
||||
* @param agentId 智能体id
|
||||
* @return
|
||||
*/
|
||||
|
||||
@@ -118,7 +118,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
|
||||
|
||||
if (deviceById == null || deviceById.getAutoUpdate() != 0) {
|
||||
// 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
|
||||
if (deviceById != null && deviceById.getAutoUpdate() != 0) {
|
||||
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
|
||||
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
||||
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
||||
|
||||
@@ -39,7 +39,7 @@ public class SysParamsDTO implements Serializable {
|
||||
|
||||
@Schema(description = "值类型")
|
||||
@NotBlank(message = "{sysparams.valuetype.require}", groups = DefaultGroup.class)
|
||||
@Pattern(regexp = "^(string|number|boolean|array)$", message = "{sysparams.valuetype.pattern}", groups = DefaultGroup.class)
|
||||
@Pattern(regexp = "^(string|number|boolean|array|json)$", message = "{sysparams.valuetype.pattern}", groups = DefaultGroup.class)
|
||||
private String valueType;
|
||||
|
||||
@Schema(description = "备注")
|
||||
|
||||
@@ -127,6 +127,12 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
||||
break;
|
||||
case "json":
|
||||
try {
|
||||
// 首先检查是否以 { 开头,以 } 结尾
|
||||
String trimmedValue = paramValue.trim();
|
||||
if (!trimmedValue.startsWith("{") || !trimmedValue.endsWith("}")) {
|
||||
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
||||
}
|
||||
// 然后尝试解析JSON
|
||||
JsonUtils.parseObject(paramValue, Object.class);
|
||||
} catch (Exception e) {
|
||||
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 更新intent_llmM供应器
|
||||
update `ai_model_provider` set fields = '[{"key":"llm","label":"LLM模型","type":"string"},{"key":"functions","label":"函数列表","type":"dict","dict_name":"functions"}]' where id = 'SYSTEM_Intent_intent_llm';
|
||||
-- 更新ChatGLMLLM的意图识别配置
|
||||
update `ai_model_config` set config_json = '{\"type\": \"intent_llm\", \"llm\": \"LLM_ChatGLMLLM\", \"functions\": \"get_weather;get_news_from_newsnow;play_music\"}' where id = 'Intent_intent_llm';
|
||||
-- 更新函数调用意图识别配置
|
||||
UPDATE `ai_model_config` SET config_json = REPLACE(config_json, ';get_news;', ';get_news_from_newsnow;') WHERE id = 'Intent_function_call';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 添加聊天记录配置字段
|
||||
ALTER TABLE `ai_agent`
|
||||
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 2 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
|
||||
@@ -106,4 +106,11 @@ databaseChangeLog:
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202505081146.sql
|
||||
path: classpath:db/changelog/202505081146.sql
|
||||
- changeSet:
|
||||
id: 202505091409
|
||||
author: hrz
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202505091409.sql
|
||||
@@ -50,9 +50,9 @@ export default {
|
||||
}).send();
|
||||
},
|
||||
// 获取智能体配置
|
||||
getDeviceConfig(deviceId, callback) {
|
||||
getDeviceConfig(agentId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${deviceId}`)
|
||||
.url(`${getServiceUrl()}/agent/${agentId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
@@ -61,7 +61,7 @@ export default {
|
||||
.fail((err) => {
|
||||
console.error('获取配置失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getDeviceConfig(deviceId, callback);
|
||||
this.getDeviceConfig(agentId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 88 KiB |
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :visible="visible" @close="handleClose" width="400px" center>
|
||||
<el-dialog :visible="visible" @close="handleClose" width="24%" center>
|
||||
<div
|
||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center
|
||||
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="57%" center
|
||||
custom-class="custom-dialog" :show-close="false" class="center-dialog">
|
||||
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
||||
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
|
||||
@@ -54,7 +54,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" prop="remark" class="prop-remark">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注"
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
|
||||
class="custom-input-bg"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -271,7 +271,7 @@ export default {
|
||||
}
|
||||
|
||||
.center-dialog .el-dialog {
|
||||
margin: 4% 0 auto !important;
|
||||
margin: 0 0 auto !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen">
|
||||
<el-dialog :visible="visible" @close="handleClose" width="25%" center @open="handleOpen">
|
||||
<div
|
||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
<div style="height: 1px;background: #e8f0ff;" />
|
||||
<div style="margin: 22px 15px;">
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
||||
<div style="font-weight: 400;text-align: left;color: #3d4566;">
|
||||
<div style="color: red;display: inline-block;">*</div> 智能体名称:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<form>
|
||||
<el-dialog :visible.sync="value" width="400px" center>
|
||||
<el-dialog :visible.sync="dialogVisible" width="24%" center>
|
||||
<div
|
||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div
|
||||
@@ -60,11 +60,20 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogVisible: this.value,
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmNewPassword: ""
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.dialogVisible = val;
|
||||
},
|
||||
dialogVisible(val) {
|
||||
this.$emit('input', val);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['logout']), // 引入Vuex的logout action
|
||||
confirm() {
|
||||
@@ -101,7 +110,7 @@ export default {
|
||||
this.$emit('input', false);
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('input', false);
|
||||
this.dialogVisible = false;
|
||||
this.resetForm();
|
||||
},
|
||||
resetForm() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
|
||||
<el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
|
||||
<el-form :model="form" :rules="rules" ref="form" label-width="100px">
|
||||
<el-form-item label="字典标签" prop="dictLabel">
|
||||
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
|
||||
@@ -41,6 +41,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogVisible: this.visible,
|
||||
form: {
|
||||
id: null,
|
||||
dictTypeId: null,
|
||||
@@ -70,12 +71,18 @@ export default {
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
visible(val) {
|
||||
this.dialogVisible = val;
|
||||
},
|
||||
dialogVisible(val) {
|
||||
this.$emit('update:visible', val);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:visible', false)
|
||||
this.resetForm()
|
||||
this.dialogVisible = false;
|
||||
this.resetForm();
|
||||
},
|
||||
resetForm() {
|
||||
this.form = {
|
||||
@@ -102,4 +109,8 @@ export default {
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
|
||||
<el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
|
||||
<el-form :model="form" :rules="rules" ref="form" label-width="120px">
|
||||
<el-form-item label="字典类型名称" prop="dictName">
|
||||
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
|
||||
@@ -34,6 +34,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogVisible: this.visible,
|
||||
form: {
|
||||
id: null,
|
||||
dictName: '',
|
||||
@@ -46,6 +47,12 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
this.dialogVisible = val;
|
||||
},
|
||||
dialogVisible(val) {
|
||||
this.$emit('update:visible', val);
|
||||
},
|
||||
dictTypeData: {
|
||||
handler(val) {
|
||||
if (val) {
|
||||
@@ -57,7 +64,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:visible', false)
|
||||
this.dialogVisible = false;
|
||||
this.resetForm()
|
||||
},
|
||||
resetForm() {
|
||||
@@ -83,4 +90,8 @@ export default {
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
|
||||
<el-dialog :title="title" :visible.sync="dialogVisible" @close="handleClose" @open="handleOpen">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="固件名称" prop="firmwareName">
|
||||
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
|
||||
@@ -22,7 +22,7 @@
|
||||
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
|
||||
:status="uploadStatus"></el-progress>
|
||||
<div class="hint-text">
|
||||
<span>温馨提示:请上传xiaozhi.bin文件,而不是merged-binary.bin文件</span>
|
||||
<span>温馨提示:请上传合并前的xiaozhi.bin文件,而不是合并后的merged-binary.bin文件</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
@@ -59,11 +59,13 @@ export default {
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
uploadProgress: 0,
|
||||
uploadStatus: '',
|
||||
isUploading: false,
|
||||
dialogVisible: this.visible,
|
||||
rules: {
|
||||
firmwareName: [
|
||||
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
|
||||
@@ -90,10 +92,18 @@ export default {
|
||||
created() {
|
||||
// 移除 getDictDataByType 调用
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
this.dialogVisible = val;
|
||||
},
|
||||
dialogVisible(val) {
|
||||
this.$emit('update:visible', val);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 移除 getFirmwareTypes 方法
|
||||
handleClose() {
|
||||
this.$refs.form.clearValidate();
|
||||
this.dialogVisible = false;
|
||||
this.$emit('cancel');
|
||||
},
|
||||
handleCancel() {
|
||||
@@ -201,13 +211,17 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-dialog {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.upload-demo {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.el-upload__tip {
|
||||
line-height: 1.2;
|
||||
padding-top: 5px;
|
||||
padding-top: 2%;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
@@ -215,7 +229,6 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #979db1;
|
||||
font-size: 11px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dialog :visible.sync="dialogVisible" width="975px" center custom-class="custom-dialog" :show-close="false"
|
||||
class="center-dialog">
|
||||
<el-dialog :visible.sync="dialogVisible" width="57%" center custom-class="custom-dialog" :show-close="false"
|
||||
class="center-dialog" >
|
||||
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
||||
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
|
||||
修改模型
|
||||
@@ -53,7 +53,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" prop="remark" class="prop-remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注"
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
|
||||
class="custom-input-bg"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -296,7 +296,7 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="scss" scoped>
|
||||
.custom-dialog {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
@@ -316,11 +316,6 @@ export default {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.center-dialog .el-dialog {
|
||||
margin: 4% 0 auto !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.custom-close-btn {
|
||||
position: absolute;
|
||||
|
||||
@@ -487,7 +487,7 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
::v-deep .el-dialog {
|
||||
border-radius: 8px !important;
|
||||
@@ -648,12 +648,6 @@ export default {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 新增按钮组样式 */
|
||||
.action-buttons {
|
||||
bottom: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.action-buttons .el-button {
|
||||
padding: 8px 15px;
|
||||
font-size: 11px;
|
||||
@@ -692,7 +686,6 @@ export default {
|
||||
position: static;
|
||||
padding: 15px 0;
|
||||
background: white;
|
||||
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 输入框自适应 */
|
||||
|
||||
@@ -49,9 +49,13 @@
|
||||
v-loading="dictDataLoading" element-loading-text="拼命加载中"
|
||||
element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||
@selection-change="handleDictDataSelectionChange" class="data-table"
|
||||
class="data-table"
|
||||
header-row-class-name="table-header">
|
||||
<el-table-column type="selection" width="55" align="center"></el-table-column>
|
||||
<el-table-column label="选择" align="center" width="55">
|
||||
<template slot-scope="scope">
|
||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column>
|
||||
<el-table-column label="字典值" prop="dictValue" align="center"></el-table-column>
|
||||
<el-table-column label="排序" prop="sort" align="center"></el-table-column>
|
||||
@@ -153,7 +157,6 @@ export default {
|
||||
// 字典数据相关
|
||||
dictDataList: [],
|
||||
dictDataLoading: false,
|
||||
selectedDictData: [],
|
||||
isAllDictDataSelected: false,
|
||||
dictDataDialogVisible: false,
|
||||
dictDataDialogTitle: '新增字典数据',
|
||||
@@ -265,7 +268,10 @@ export default {
|
||||
dictValue: ''
|
||||
}, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.dictDataList = data.data.list
|
||||
this.dictDataList = data.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}))
|
||||
this.total = data.data.total
|
||||
} else {
|
||||
this.$message.error(data.msg || '获取字典数据失败')
|
||||
@@ -273,16 +279,11 @@ export default {
|
||||
this.dictDataLoading = false
|
||||
})
|
||||
},
|
||||
handleDictDataSelectionChange(val) {
|
||||
this.selectedDictData = val
|
||||
this.isAllDictDataSelected = val.length === this.dictDataList.length
|
||||
},
|
||||
selectAllDictData() {
|
||||
if (this.isAllDictDataSelected) {
|
||||
this.$refs.dictDataTable.clearSelection()
|
||||
} else {
|
||||
this.$refs.dictDataTable.toggleAllSelection()
|
||||
}
|
||||
this.isAllDictDataSelected = !this.isAllDictDataSelected
|
||||
this.dictDataList.forEach(row => {
|
||||
row.selected = this.isAllDictDataSelected
|
||||
})
|
||||
},
|
||||
showAddDictDataDialog() {
|
||||
if (!this.selectedDictType) {
|
||||
@@ -329,17 +330,18 @@ export default {
|
||||
})
|
||||
},
|
||||
batchDeleteDictData() {
|
||||
if (this.selectedDictData.length === 0) {
|
||||
const selectedRows = this.dictDataList.filter(row => row.selected)
|
||||
if (selectedRows.length === 0) {
|
||||
this.$message.warning('请选择要删除的字典数据')
|
||||
return
|
||||
}
|
||||
|
||||
this.$confirm('确定要删除选中的字典数据吗?', '提示', {
|
||||
this.$confirm(`确定要删除选中的${selectedRows.length}个字典数据吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
const ids = this.selectedDictData.map(item => item.id)
|
||||
const ids = selectedRows.map(item => item.id)
|
||||
dictApi.deleteDictData(ids, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success('删除成功')
|
||||
@@ -832,4 +834,18 @@ export default {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background-color: #eeeeee !important;
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner:hover) {
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background-color: #5f70f3 !important;
|
||||
border-color: #5f70f3 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -86,12 +86,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色音色:">
|
||||
<el-form-item label="角色音色">
|
||||
<el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
|
||||
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
|
||||
:value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="聊天记录配置">
|
||||
<el-select v-model="form.chatHistoryConf" placeholder="请选择" class="form-select">
|
||||
<el-option v-for="(item, index) in chatHistoryOptions" :key="`chatHistoryConf-${index}`" :label="item.label"
|
||||
:value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +125,7 @@ export default {
|
||||
agentCode: "",
|
||||
agentName: "",
|
||||
ttsVoiceId: "",
|
||||
chatHistoryConf: "",
|
||||
systemPrompt: "",
|
||||
langCode: "",
|
||||
language: "",
|
||||
@@ -155,7 +162,25 @@ export default {
|
||||
{ name: '新闻', params: {} },
|
||||
{ name: '工具', params: {} },
|
||||
{ name: '退出', params: {} }
|
||||
]
|
||||
],
|
||||
chatHistoryOptions: [
|
||||
{
|
||||
"value": 0,
|
||||
"label": "不记录"
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"label": "仅记录文本"
|
||||
},
|
||||
{
|
||||
"value": 2,
|
||||
"label": "仅记录语音"
|
||||
},
|
||||
{
|
||||
"value": 3,
|
||||
"label": "文本音频都记录"
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -171,6 +196,7 @@ export default {
|
||||
llmModelId: this.form.model.llmModelId,
|
||||
ttsModelId: this.form.model.ttsModelId,
|
||||
ttsVoiceId: this.form.ttsVoiceId,
|
||||
chatHistoryConf: this.form.chatHistoryConf,
|
||||
memModelId: this.form.model.memModelId,
|
||||
intentModelId: this.form.model.intentModelId,
|
||||
systemPrompt: this.form.systemPrompt,
|
||||
@@ -203,6 +229,7 @@ export default {
|
||||
agentCode: "",
|
||||
agentName: "",
|
||||
ttsVoiceId: "",
|
||||
chatHistoryConf: "",
|
||||
systemPrompt: "",
|
||||
langCode: "",
|
||||
language: "",
|
||||
@@ -256,6 +283,7 @@ export default {
|
||||
...this.form,
|
||||
agentName: templateData.agentName || this.form.agentName,
|
||||
ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId,
|
||||
chatHistoryConf: templateData.chatHistoryConf || this.form.chatHistoryConf,
|
||||
systemPrompt: templateData.systemPrompt || this.form.systemPrompt,
|
||||
langCode: templateData.langCode || this.form.langCode,
|
||||
model: {
|
||||
@@ -394,7 +422,6 @@ export default {
|
||||
<style scoped>
|
||||
.welcome {
|
||||
min-width: 900px;
|
||||
min-height: 506px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
position: relative;
|
||||
@@ -410,7 +437,7 @@ export default {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
padding: 1.5vh 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -420,7 +447,7 @@ export default {
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
margin: 5px 22px;
|
||||
margin: 1vh 22px;
|
||||
border-radius: 15px;
|
||||
height: calc(100vh - 24vh);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
@@ -492,7 +519,7 @@ export default {
|
||||
}
|
||||
|
||||
.form-content {
|
||||
padding: 20px 0;
|
||||
padding: 2vh 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@@ -526,11 +553,11 @@ export default {
|
||||
}
|
||||
|
||||
.template-item {
|
||||
height: 37px;
|
||||
height: 4vh;
|
||||
width: 76px;
|
||||
border-radius: 8px;
|
||||
background: #e6ebff;
|
||||
line-height: 37px;
|
||||
line-height: 4vh;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
@@ -547,7 +574,7 @@ export default {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
margin-top: 2vh;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from core.ota_server import SimpleOtaServer
|
||||
from core.utils.util import check_ffmpeg_installed
|
||||
from config.logger import setup_logging
|
||||
from core.utils.util import get_local_ip
|
||||
from aioconsole import ainput
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -34,10 +35,19 @@ async def wait_for_exit() -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def monitor_stdin():
|
||||
"""监控标准输入,消费回车键"""
|
||||
while True:
|
||||
await ainput() # 异步等待输入,消费回车
|
||||
|
||||
|
||||
async def main():
|
||||
check_ffmpeg_installed()
|
||||
config = load_config()
|
||||
|
||||
# 添加 stdin 监控任务
|
||||
stdin_task = asyncio.create_task(monitor_stdin())
|
||||
|
||||
# 启动 WebSocket 服务器
|
||||
ws_server = WebSocketServer(config)
|
||||
ws_task = asyncio.create_task(ws_server.start())
|
||||
@@ -78,19 +88,22 @@ async def main():
|
||||
)
|
||||
|
||||
try:
|
||||
await wait_for_exit() # 监听退出信号
|
||||
await wait_for_exit() # 阻塞直到收到退出信号
|
||||
except asyncio.CancelledError:
|
||||
print("任务被取消,清理资源中...")
|
||||
finally:
|
||||
# 取消所有任务(关键修复点)
|
||||
stdin_task.cancel()
|
||||
ws_task.cancel()
|
||||
if ota_task:
|
||||
ota_task.cancel()
|
||||
try:
|
||||
await ws_task
|
||||
if ota_task:
|
||||
await ota_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 等待任务终止(必须加超时)
|
||||
await asyncio.wait(
|
||||
[stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
|
||||
timeout=3.0,
|
||||
return_when=asyncio.ALL_COMPLETED
|
||||
)
|
||||
print("服务器已关闭,程序退出。")
|
||||
|
||||
|
||||
|
||||
@@ -104,12 +104,13 @@ plugins:
|
||||
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
|
||||
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
||||
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
||||
get_news:
|
||||
get_news_from_chinanews:
|
||||
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
|
||||
category_urls:
|
||||
society: "https://www.chinanews.com.cn/rss/society.xml"
|
||||
world: "https://www.chinanews.com.cn/rss/world.xml"
|
||||
finance: "https://www.chinanews.com.cn/rss/finance.xml"
|
||||
get_news_from_newsnow: {"url": "https://newsnow.busiyi.world/api/s?id="}
|
||||
home_assistant:
|
||||
devices:
|
||||
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
|
||||
@@ -156,7 +157,7 @@ selected_module:
|
||||
Memory: nomem
|
||||
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
|
||||
# 不想开通意图识别,就设置成:nointent
|
||||
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
|
||||
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
|
||||
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
|
||||
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115
|
||||
Intent: function_call
|
||||
@@ -174,6 +175,13 @@ Intent:
|
||||
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
|
||||
# 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
|
||||
llm: ChatGLMLLM
|
||||
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
|
||||
# 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载
|
||||
# 下面是加载查天气、角色切换、加载查新闻的插件示例
|
||||
functions:
|
||||
- get_weather
|
||||
- get_news_from_newsnow
|
||||
- play_music
|
||||
function_call:
|
||||
# 不需要动type
|
||||
type: function_call
|
||||
@@ -183,7 +191,8 @@ Intent:
|
||||
functions:
|
||||
- change_role
|
||||
- get_weather
|
||||
- get_news
|
||||
# - get_news_from_chinanews
|
||||
- get_news_from_newsnow
|
||||
# play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
|
||||
# 如果用了hass_play_music,就不要开启play_music,两者只留一个
|
||||
- play_music
|
||||
@@ -380,6 +389,12 @@ LLM:
|
||||
model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载
|
||||
url: http://localhost:1234/v1 # LM Studio服务地址
|
||||
api_key: lm-studio # LM Studio服务的固定API Key
|
||||
HomeAssistant:
|
||||
# 定义LLM API类型
|
||||
type: homeassistant
|
||||
base_url: http://homeassistant.local:8123
|
||||
agent_id: conversation.chatgpt
|
||||
api_key: 你的home assistant api访问令牌
|
||||
FastgptLLM:
|
||||
# 定义LLM API类型
|
||||
type: fastgpt
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
import time
|
||||
import queue
|
||||
@@ -58,9 +60,9 @@ class ConnectionHandler:
|
||||
self.config = copy.deepcopy(config)
|
||||
self.session_id = str(uuid.uuid4())
|
||||
self.logger = setup_logging()
|
||||
self.auth = AuthMiddleware(config)
|
||||
self.server = server # 保存server实例的引用
|
||||
|
||||
self.auth = AuthMiddleware(config)
|
||||
self.need_bind = False
|
||||
self.bind_code = None
|
||||
self.read_config_from_api = self.config.get("read_config_from_api", False)
|
||||
@@ -128,8 +130,10 @@ class ConnectionHandler:
|
||||
if len(cmd) > self.max_cmd_length:
|
||||
self.max_cmd_length = len(cmd)
|
||||
|
||||
self.close_after_chat = False # 是否在聊天结束后关闭连接
|
||||
self.use_function_call_mode = False
|
||||
# 是否在聊天结束后关闭连接
|
||||
self.close_after_chat = False
|
||||
self.load_function_plugin = False
|
||||
self.intent_type = "nointent"
|
||||
|
||||
self.timeout_task = None
|
||||
self.timeout_seconds = (
|
||||
@@ -239,6 +243,44 @@ class ConnectionHandler:
|
||||
elif isinstance(message, bytes):
|
||||
await handleAudioMessage(self, message)
|
||||
|
||||
async def handle_restart(self, message):
|
||||
"""处理服务器重启请求"""
|
||||
try:
|
||||
|
||||
self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...")
|
||||
|
||||
# 发送确认响应
|
||||
await self.websocket.send(json.dumps({
|
||||
"type": "server_response",
|
||||
"status": "success",
|
||||
"message": "服务器重启中..."
|
||||
}))
|
||||
|
||||
# 异步执行重启操作
|
||||
def restart_server():
|
||||
"""实际执行重启的方法"""
|
||||
time.sleep(1)
|
||||
self.logger.bind(tag=TAG).info("执行服务器重启...")
|
||||
subprocess.Popen(
|
||||
[sys.executable, "app.py"],
|
||||
stdin=sys.stdin,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
start_new_session=True
|
||||
)
|
||||
os._exit(0)
|
||||
|
||||
# 使用线程执行重启避免阻塞事件循环
|
||||
threading.Thread(target=restart_server, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}")
|
||||
await self.websocket.send(json.dumps({
|
||||
"type": "server_response",
|
||||
"status": "error",
|
||||
"message": f"Restart failed: {str(e)}"
|
||||
}))
|
||||
|
||||
def _initialize_components(self):
|
||||
"""初始化组件"""
|
||||
if self.config.get("prompt") is not None:
|
||||
@@ -370,11 +412,11 @@ class ConnectionHandler:
|
||||
self.memory.init_memory(self.device_id, self.llm)
|
||||
|
||||
def _initialize_intent(self):
|
||||
if (
|
||||
self.config["Intent"][self.config["selected_module"]["Intent"]]["type"]
|
||||
== "function_call"
|
||||
):
|
||||
self.use_function_call_mode = True
|
||||
self.intent_type = self.config["Intent"][
|
||||
self.config["selected_module"]["Intent"]
|
||||
]["type"]
|
||||
if self.intent_type == "function_call" or self.intent_type == "intent_llm":
|
||||
self.load_function_plugin = True
|
||||
"""初始化意图识别模块"""
|
||||
# 获取意图识别配置
|
||||
intent_config = self.config["Intent"]
|
||||
@@ -762,7 +804,13 @@ class ConnectionHandler:
|
||||
)
|
||||
|
||||
self.dialogue.put(
|
||||
Message(role="tool", tool_call_id=function_id, content=text)
|
||||
Message(
|
||||
role="tool",
|
||||
tool_call_id=(
|
||||
str(uuid.uuid4()) if function_id is None else function_id
|
||||
),
|
||||
content=text,
|
||||
)
|
||||
)
|
||||
self.chat_with_function_calling(text, tool_call=True)
|
||||
elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
|
||||
|
||||
@@ -5,6 +5,8 @@ from core.handle.sendAudioHandle import send_stt_message
|
||||
from core.handle.helloHandle import checkWakeupWords
|
||||
from core.utils.util import remove_punctuation_and_length
|
||||
from core.utils.dialogue import Message
|
||||
from plugins_func.register import Action
|
||||
from loguru import logger
|
||||
|
||||
TAG = __name__
|
||||
|
||||
@@ -17,7 +19,7 @@ async def handle_user_intent(conn, text):
|
||||
if await checkWakeupWords(conn, text):
|
||||
return True
|
||||
|
||||
if conn.use_function_call_mode:
|
||||
if conn.intent_type == "function_call":
|
||||
# 使用支持function calling的聊天方法,不再进行意图分析
|
||||
return False
|
||||
# 使用LLM进行意图分析
|
||||
@@ -100,24 +102,35 @@ async def process_intent_result(conn, intent_result, original_text):
|
||||
result = conn.func_handler.handle_llm_function_call(
|
||||
conn, function_call_data
|
||||
)
|
||||
if result and function_name != "play_music":
|
||||
# 获取当前最新的文本索引
|
||||
text = result.response
|
||||
if text is None:
|
||||
logger.bind(tag=TAG).debug(f"检测到Action : {result.action}")
|
||||
|
||||
if result:
|
||||
if result.action == Action.RESPONSE: # 直接回复前端
|
||||
text = result.response
|
||||
if text is not None:
|
||||
speak_and_play(conn, text)
|
||||
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
|
||||
text = result.result
|
||||
if text is not None:
|
||||
text_index = (
|
||||
conn.tts_last_text_index + 1
|
||||
if hasattr(conn, "tts_last_text_index")
|
||||
else 0
|
||||
)
|
||||
conn.recode_first_last_text(text, text_index)
|
||||
future = conn.executor.submit(
|
||||
conn.speak_and_play, text, text_index
|
||||
)
|
||||
conn.llm_finish_task = True
|
||||
conn.tts_queue.put((future, text_index))
|
||||
conn.dialogue.put(Message(role="assistant", content=text))
|
||||
conn.dialogue.put(Message(role="tool", content=text))
|
||||
llm_result = conn.intent.replyResult(text, original_text)
|
||||
if llm_result is None:
|
||||
llm_result = text
|
||||
speak_and_play(conn, llm_result)
|
||||
elif (
|
||||
result.action == Action.NOTFOUND
|
||||
or result.action == Action.ERROR
|
||||
):
|
||||
text = result.result
|
||||
if text is not None:
|
||||
speak_and_play(conn, text)
|
||||
elif function_name != "play_music":
|
||||
# For backward compatibility with original code
|
||||
# 获取当前最新的文本索引
|
||||
text = result.response
|
||||
if text is None:
|
||||
text = result.result
|
||||
if text is not None:
|
||||
speak_and_play(conn, text)
|
||||
|
||||
# 将函数执行放在线程池中
|
||||
conn.executor.submit(process_function_call)
|
||||
@@ -128,21 +141,12 @@ async def process_intent_result(conn, intent_result, original_text):
|
||||
return False
|
||||
|
||||
|
||||
def extract_text_in_brackets(s):
|
||||
"""
|
||||
从字符串中提取中括号内的文字
|
||||
|
||||
:param s: 输入字符串
|
||||
:return: 中括号内的文字,如果不存在则返回空字符串
|
||||
"""
|
||||
left_bracket_index = s.find("[")
|
||||
right_bracket_index = s.find("]")
|
||||
|
||||
if (
|
||||
left_bracket_index != -1
|
||||
and right_bracket_index != -1
|
||||
and left_bracket_index < right_bracket_index
|
||||
):
|
||||
return s[left_bracket_index + 1 : right_bracket_index]
|
||||
else:
|
||||
return ""
|
||||
def speak_and_play(conn, text):
|
||||
text_index = (
|
||||
conn.tts_last_text_index + 1 if hasattr(conn, "tts_last_text_index") else 0
|
||||
)
|
||||
conn.recode_first_last_text(text, text_index)
|
||||
future = conn.executor.submit(conn.speak_and_play, text, text_index)
|
||||
conn.llm_finish_task = True
|
||||
conn.tts_queue.put((future, text_index))
|
||||
conn.dialogue.put(Message(role="assistant", content=text))
|
||||
|
||||
@@ -317,7 +317,7 @@ async def handleIotDescriptors(conn, descriptors):
|
||||
)
|
||||
conn.iot_descriptors[descriptor["name"]] = iot_descriptor
|
||||
|
||||
if conn.use_function_call_mode:
|
||||
if conn.load_function_plugin:
|
||||
# 注册或获取设备类型
|
||||
type_id = register_device_type(descriptor)
|
||||
device_functions = device_type_registry.get_device_functions(type_id)
|
||||
|
||||
@@ -16,7 +16,7 @@ async def handleAudioMessage(conn, audio):
|
||||
if not conn.asr_server_receive:
|
||||
conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
|
||||
return
|
||||
if conn.client_listen_mode == "auto":
|
||||
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime":
|
||||
have_voice = conn.vad.is_vad(conn, audio)
|
||||
else:
|
||||
have_voice = conn.client_have_voice
|
||||
@@ -76,7 +76,7 @@ async def startToChat(conn, text):
|
||||
|
||||
# 意图未被处理,继续常规聊天流程
|
||||
await send_stt_message(conn, text)
|
||||
if conn.use_function_call_mode:
|
||||
if conn.intent_type == "function_call":
|
||||
# 使用支持function calling的聊天方法
|
||||
conn.executor.submit(conn.chat_with_function_calling, text)
|
||||
else:
|
||||
|
||||
@@ -136,5 +136,8 @@ async def handleTextMessage(conn, message):
|
||||
}
|
||||
)
|
||||
)
|
||||
# 重启服务器
|
||||
elif msg_json["action"] == "restart":
|
||||
await conn.handle_restart(msg_json)
|
||||
except json.JSONDecodeError:
|
||||
await conn.websocket.send(message)
|
||||
|
||||
@@ -9,18 +9,6 @@ logger = setup_logging()
|
||||
class IntentProviderBase(ABC):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.intent_options = [
|
||||
{
|
||||
"name": "handle_exit_intent",
|
||||
"desc": "结束聊天, 用户发来如再见之类的表示结束的话, 不想再进行对话的时候",
|
||||
},
|
||||
{
|
||||
"name": "play_music",
|
||||
"desc": "播放音乐, 用户希望你可以播放音乐, 只用于播放音乐的意图",
|
||||
},
|
||||
{"name": "get_time", "desc": "获取今天日期或者当前时间信息"},
|
||||
{"name": "continue_chat", "desc": "继续聊天"},
|
||||
]
|
||||
|
||||
def set_llm(self, llm):
|
||||
self.llm = llm
|
||||
|
||||
@@ -15,57 +15,72 @@ class IntentProvider(IntentProviderBase):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
self.llm = None
|
||||
self.promot = self.get_intent_system_prompt()
|
||||
self.promot = ""
|
||||
# 添加缓存管理
|
||||
self.intent_cache = {} # 缓存意图识别结果
|
||||
self.cache_expiry = 600 # 缓存有效期10分钟
|
||||
self.cache_max_size = 100 # 最多缓存100个意图
|
||||
self.history_count = 4 # 默认使用最近4条对话记录
|
||||
|
||||
def get_intent_system_prompt(self) -> str:
|
||||
def get_intent_system_prompt(self, functions_list: str) -> str:
|
||||
"""
|
||||
根据配置的意图选项动态生成系统提示词
|
||||
根据配置的意图选项和可用函数动态生成系统提示词
|
||||
Args:
|
||||
functions: 可用的函数列表,JSON格式字符串
|
||||
Returns:
|
||||
格式化后的系统提示词
|
||||
"""
|
||||
|
||||
# 构建函数说明部分
|
||||
functions_desc = "可用的函数列表:\n"
|
||||
for func in functions_list:
|
||||
func_info = func.get("function", {})
|
||||
name = func_info.get("name", "")
|
||||
desc = func_info.get("description", "")
|
||||
params = func_info.get("parameters", {})
|
||||
|
||||
functions_desc += f"\n函数名: {name}\n"
|
||||
functions_desc += f"描述: {desc}\n"
|
||||
|
||||
if params:
|
||||
functions_desc += "参数:\n"
|
||||
for param_name, param_info in params.get("properties", {}).items():
|
||||
param_desc = param_info.get("description", "")
|
||||
param_type = param_info.get("type", "")
|
||||
functions_desc += f"- {param_name} ({param_type}): {param_desc}\n"
|
||||
|
||||
functions_desc += "---\n"
|
||||
|
||||
prompt = (
|
||||
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图属于以下哪一类:\n"
|
||||
"<start>"
|
||||
f"{str(self.intent_options)}"
|
||||
"<end>\n"
|
||||
"处理步骤:"
|
||||
"1. 思考意图类型,生成function_call格式"
|
||||
"\n\n"
|
||||
"返回格式示例:\n"
|
||||
'1. 播放音乐意图: {"function_call": {"name": "play_music", "arguments": {"song_name": "音乐名称"}}}\n'
|
||||
'2. 结束对话意图: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
|
||||
'3. 获取当天日期时间: {"function_call": {"name": "get_time"}}\n'
|
||||
'4. 继续聊天意图: {"function_call": {"name": "continue_chat"}}\n'
|
||||
"\n"
|
||||
"注意:\n"
|
||||
'- 播放音乐:无歌名时,song_name设为"random"\n'
|
||||
"- 如果没有明显的意图,应按照继续聊天意图处理\n"
|
||||
"- 只返回纯JSON,不要任何其他内容\n"
|
||||
"\n"
|
||||
"示例分析:\n"
|
||||
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图并调用相应的函数。\n\n"
|
||||
f"{functions_desc}\n"
|
||||
"处理步骤:\n"
|
||||
"1. 分析用户输入,确定用户意图\n"
|
||||
"2. 从可用函数列表中选择最匹配的函数\n"
|
||||
"3. 如果找到匹配的函数,生成对应的function_call 格式\n"
|
||||
'4. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n\n'
|
||||
"返回格式要求:\n"
|
||||
"1. 必须返回纯JSON格式\n"
|
||||
"2. 必须包含function_call字段\n"
|
||||
"3. function_call必须包含name字段\n"
|
||||
"4. 如果函数需要参数,必须包含arguments字段\n\n"
|
||||
"示例:\n"
|
||||
"```\n"
|
||||
"用户: 你也太搞笑了\n"
|
||||
'返回: {"function_call": {"name": "continue_chat"}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 现在是几号了?现在几点了?\n"
|
||||
"用户: 现在几点了?\n"
|
||||
'返回: {"function_call": {"name": "get_time"}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 我们明天再聊吧\n"
|
||||
'返回: {"function_call": {"name": "handle_exit_intent"}}\n'
|
||||
"用户: 我想结束对话\n"
|
||||
'返回: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 播放中秋月\n"
|
||||
'返回: {"function_call": {"name": "play_music", "arguments": {"song_name": "中秋月"}}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"可用的音乐名称:\n"
|
||||
"用户: 你好啊\n"
|
||||
'返回: {"function_call": {"name": "continue_chat"}}\n'
|
||||
"```\n\n"
|
||||
"注意:\n"
|
||||
"1. 只返回JSON格式,不要包含任何其他文字\n"
|
||||
'2. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n'
|
||||
"3. 确保返回的JSON格式正确,包含所有必要的字段\n"
|
||||
)
|
||||
return prompt
|
||||
|
||||
@@ -90,6 +105,14 @@ class IntentProvider(IntentProviderBase):
|
||||
for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]:
|
||||
del self.intent_cache[key]
|
||||
|
||||
def replyResult(self, text: str, original_text: str):
|
||||
llm_result = self.llm.response_no_stream(
|
||||
system_prompt=text,
|
||||
user_prompt="请根据以上内容,像人类一样说话的口吻回复用户,要求简洁,请直接返回结果。用户现在说:"
|
||||
+ original_text,
|
||||
)
|
||||
return llm_result
|
||||
|
||||
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
|
||||
if not self.llm:
|
||||
raise ValueError("LLM provider not set")
|
||||
@@ -118,22 +141,35 @@ class IntentProvider(IntentProviderBase):
|
||||
# 清理缓存
|
||||
self.clean_cache()
|
||||
|
||||
# 构建用户最后一句话的提示
|
||||
msgStr = ""
|
||||
if self.promot == "":
|
||||
if hasattr(conn, "func_handler"):
|
||||
functions = conn.func_handler.get_functions()
|
||||
self.promot = self.get_intent_system_prompt(functions)
|
||||
|
||||
# 只使用最后两句即可
|
||||
if len(dialogue_history) >= 2:
|
||||
# 保证最少有两句话的时候处理
|
||||
msgStr += f"{dialogue_history[-2].role}: {dialogue_history[-2].content}\n"
|
||||
msgStr += f"{dialogue_history[-1].role}: {dialogue_history[-1].content}\n"
|
||||
|
||||
msgStr += f"User: {text}\n"
|
||||
user_prompt = f"当前的对话如下:\n{msgStr}"
|
||||
music_config = initialize_music_handler(conn)
|
||||
music_file_names = music_config["music_file_names"]
|
||||
prompt_music = f"{self.promot}\n<start>{music_file_names}\n<end>"
|
||||
prompt_music = f"{self.promot}\n<musicNames>{music_file_names}\n</musicNames>"
|
||||
|
||||
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
|
||||
if len(devices) > 0:
|
||||
hass_prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
|
||||
for device in devices:
|
||||
hass_prompt += device + "\n"
|
||||
prompt_music += hass_prompt
|
||||
|
||||
logger.bind(tag=TAG).debug(f"User prompt: {prompt_music}")
|
||||
|
||||
# 构建用户对话历史的提示
|
||||
msgStr = ""
|
||||
|
||||
# 获取最近的对话历史
|
||||
start_idx = max(0, len(dialogue_history) - self.history_count)
|
||||
for i in range(start_idx, len(dialogue_history)):
|
||||
msgStr += f"{dialogue_history[i].role}: {dialogue_history[i].content}\n"
|
||||
|
||||
msgStr += f"User: {text}\n"
|
||||
user_prompt = f"current dialogue:\n{msgStr}"
|
||||
|
||||
# 记录预处理完成时间
|
||||
preprocess_time = time.time() - total_start_time
|
||||
logger.bind(tag=TAG).debug(f"意图识别预处理耗时: {preprocess_time:.4f}秒")
|
||||
|
||||
@@ -61,5 +61,6 @@ class LLMProvider(LLMProviderBase):
|
||||
yield "【LLM服务响应异常】"
|
||||
|
||||
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||
logger.bind(tag=TAG).info(f"阿里百练暂未实现完整的工具调用(function call)")
|
||||
return self.response(session_id, dialogue)
|
||||
logger.bind(tag=TAG).error(
|
||||
f"阿里百练暂未实现完整的工具调用(function call),建议使用其他意图识别"
|
||||
)
|
||||
|
||||
@@ -66,5 +66,6 @@ class LLMProvider(LLMProviderBase):
|
||||
yield "【服务响应异常】"
|
||||
|
||||
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||
logger.bind(tag=TAG).info(f"fastgpt暂未实现完整的工具调用(function call)")
|
||||
return self.response(session_id, dialogue)
|
||||
logger.bind(tag=TAG).error(
|
||||
f"fastgpt暂未实现完整的工具调用(function call),建议使用其他意图识别"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
from config.logger import setup_logging
|
||||
from core.providers.llm.base import LLMProviderBase
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class LLMProvider(LLMProviderBase):
|
||||
def __init__(self, config):
|
||||
self.agent_id = config.get("agent_id") # 对应 agent_id
|
||||
self.api_key = config.get("api_key")
|
||||
self.base_url = config.get("base_url", config.get("url")) # 默认使用 base_url
|
||||
self.api_url = f"{self.base_url}/api/conversation/process" # 拼接完整的 API URL
|
||||
|
||||
def response(self, session_id, dialogue):
|
||||
try:
|
||||
# home assistant语音助手自带意图,无需使用xiaozhi ai自带的,只需要把用户说的话传递给home assistant即可
|
||||
|
||||
# 提取最后一个 role 为 'user' 的 content
|
||||
input_text = None
|
||||
if isinstance(dialogue, list): # 确保 dialogue 是一个列表
|
||||
# 逆序遍历,找到最后一个 role 为 'user' 的消息
|
||||
for message in reversed(dialogue):
|
||||
if message.get("role") == "user": # 找到 role 为 'user' 的消息
|
||||
input_text = message.get("content", "")
|
||||
break # 找到后立即退出循环
|
||||
|
||||
# 构造请求数据
|
||||
payload = {
|
||||
"text": input_text,
|
||||
"agent_id": self.agent_id,
|
||||
"conversation_id": session_id, # 使用 session_id 作为 conversation_id
|
||||
}
|
||||
# 设置请求头
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# 发起 POST 请求
|
||||
response = requests.post(self.api_url, json=payload, headers=headers)
|
||||
|
||||
# 检查请求是否成功
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析返回数据
|
||||
data = response.json()
|
||||
speech = (
|
||||
data.get("response", {})
|
||||
.get("speech", {})
|
||||
.get("plain", {})
|
||||
.get("speech", "")
|
||||
)
|
||||
|
||||
# 返回生成的内容
|
||||
if speech:
|
||||
yield speech
|
||||
else:
|
||||
logger.bind(tag=TAG).warning("API 返回数据中没有 speech 内容")
|
||||
|
||||
except RequestException as e:
|
||||
logger.bind(tag=TAG).error(f"HTTP 请求错误: {e}")
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"生成响应时出错: {e}")
|
||||
|
||||
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||
logger.bind(tag=TAG).error(
|
||||
f"homeassistant不支持(function call),建议使用其他意图识别"
|
||||
)
|
||||
@@ -38,9 +38,13 @@ class TTSProvider(TTSProviderBase):
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
response = requests.request(
|
||||
"POST", self.api_url, json=request_json, headers=headers
|
||||
)
|
||||
data = response.content
|
||||
file_to_save = open(output_file, "wb")
|
||||
file_to_save.write(data)
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
"POST", self.api_url, json=request_json, headers=headers
|
||||
)
|
||||
data = response.content
|
||||
file_to_save = open(output_file, "wb")
|
||||
file_to_save.write(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"{__name__} error: {e}")
|
||||
@@ -32,4 +32,6 @@ class TTSProvider(TTSProviderBase):
|
||||
with open(output_file, "wb") as file:
|
||||
file.write(resp.content)
|
||||
else:
|
||||
logger.bind(tag=TAG).error(f"Custom TTS请求失败: {resp.status_code} - {resp.text}")
|
||||
error_msg = f"Custom TTS请求失败: {resp.status_code} - {resp.text}"
|
||||
logger.bind(tag=TAG).error(error_msg)
|
||||
raise Exception(error_msg) # 抛出异常,让调用方捕获
|
||||
|
||||
@@ -20,14 +20,18 @@ class TTSProvider(TTSProviderBase):
|
||||
)
|
||||
|
||||
async def text_to_speak(self, text, output_file):
|
||||
communicate = edge_tts.Communicate(text, voice=self.voice)
|
||||
# 确保目录存在并创建空文件
|
||||
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
||||
with open(output_file, "wb") as f:
|
||||
pass
|
||||
try:
|
||||
communicate = edge_tts.Communicate(text, voice=self.voice)
|
||||
# 确保目录存在并创建空文件
|
||||
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
||||
with open(output_file, "wb") as f:
|
||||
pass
|
||||
|
||||
# 流式写入音频数据
|
||||
with open(output_file, "ab") as f: # 改为追加模式避免覆盖
|
||||
async for chunk in communicate.stream():
|
||||
if chunk["type"] == "audio": # 只处理音频数据块
|
||||
f.write(chunk["data"])
|
||||
# 流式写入音频数据
|
||||
with open(output_file, "ab") as f: # 改为追加模式避免覆盖
|
||||
async for chunk in communicate.stream():
|
||||
if chunk["type"] == "audio": # 只处理音频数据块
|
||||
f.write(chunk["data"])
|
||||
except Exception as e:
|
||||
error_msg = f"Edge TTS请求失败: {e}"
|
||||
raise Exception(error_msg) # 抛出异常,让调用方捕获
|
||||
@@ -177,5 +177,7 @@ class TTSProvider(TTSProviderBase):
|
||||
audio_file.write(audio_content)
|
||||
|
||||
else:
|
||||
print(f"Request failed with status code {response.status_code}")
|
||||
error_msg = f"Request failed with status code {response.status_code}"
|
||||
print(error_msg)
|
||||
print(response.json())
|
||||
raise Exception(error_msg)
|
||||
|
||||
@@ -105,6 +105,6 @@ class TTSProvider(TTSProviderBase):
|
||||
with open(output_file, "wb") as file:
|
||||
file.write(resp.content)
|
||||
else:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"GPT_SoVITS_V2 TTS请求失败: {resp.status_code} - {resp.text}"
|
||||
)
|
||||
error_msg = f"GPT_SoVITS_V2 TTS请求失败: {resp.status_code} - {resp.text}"
|
||||
logger.bind(tag=TAG).error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
|
||||
@@ -64,6 +64,7 @@ class TTSProvider(TTSProviderBase):
|
||||
with open(output_file, "wb") as file:
|
||||
file.write(resp.content)
|
||||
else:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"GPT_SoVITS_V3 TTS请求失败: {resp.status_code} - {resp.text}"
|
||||
)
|
||||
error_msg = f"GPT_SoVITS_V3 TTS请求失败: {resp.status_code} - {resp.text}"
|
||||
logger.bind(tag=TAG).error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
|
||||
|
||||
@@ -39,9 +39,12 @@ class TTSProvider(TTSProviderBase):
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
response = requests.request(
|
||||
"POST", self.api_url, json=request_json, headers=headers
|
||||
)
|
||||
data = response.content
|
||||
file_to_save = open(output_file, "wb")
|
||||
file_to_save.write(data)
|
||||
try:
|
||||
response = requests.request(
|
||||
"POST", self.api_url, json=request_json, headers=headers
|
||||
)
|
||||
data = response.content
|
||||
file_to_save = open(output_file, "wb")
|
||||
file_to_save.write(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"{__name__} error: {e}")
|
||||
@@ -58,8 +58,8 @@ class TTSProvider(TTSProviderBase):
|
||||
|
||||
resp = requests.request("POST", url, data=payload)
|
||||
if resp.status_code != 200:
|
||||
logger.bind(tag=TAG).error(f"TTS请求失败: {resp.text}")
|
||||
return None
|
||||
logger.bind(tag=TAG).error(f"TTSON 请求失败: {resp.text}")
|
||||
raise Exception(f"{__name__}: TTS请求失败")
|
||||
resp_json = resp.json()
|
||||
try:
|
||||
result = (
|
||||
@@ -71,13 +71,15 @@ class TTSProvider(TTSProviderBase):
|
||||
+ "&voice_audio_path="
|
||||
+ resp_json["voice_path"]
|
||||
)
|
||||
|
||||
audio_content = requests.get(result)
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(audio_content.content)
|
||||
return True
|
||||
voice_path = resp_json.get("voice_path")
|
||||
des_path = output_file
|
||||
shutil.move(voice_path, des_path)
|
||||
|
||||
except Exception as e:
|
||||
print("error:", e)
|
||||
|
||||
audio_content = requests.get(result)
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(audio_content.content)
|
||||
return True
|
||||
voice_path = resp_json.get("voice_path")
|
||||
des_path = output_file
|
||||
shutil.move(voice_path, des_path)
|
||||
raise Exception(f"{__name__}: TTS请求失败")
|
||||
@@ -33,7 +33,13 @@ class Dialogue:
|
||||
dialogue.append({"role": m.role, "tool_calls": m.tool_calls})
|
||||
elif m.role == "tool":
|
||||
dialogue.append(
|
||||
{"role": m.role, "tool_call_id": m.tool_call_id, "content": m.content}
|
||||
{
|
||||
"role": m.role,
|
||||
"tool_call_id": (
|
||||
str(uuid.uuid4()) if m.tool_call_id is None else m.tool_call_id
|
||||
),
|
||||
"content": m.content,
|
||||
}
|
||||
)
|
||||
else:
|
||||
dialogue.append({"role": m.role, "content": m.content})
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
"当前支持stdio/sse两种模式。"
|
||||
],
|
||||
"mcpServers": {
|
||||
"Home Assistant": {
|
||||
"command": "mcp-proxy",
|
||||
"args": [
|
||||
"http://YOUR_HA_HOST/mcp_server/sse"
|
||||
],
|
||||
"env": {
|
||||
"API_ACCESS_TOKEN": "YOUR_API_ACCESS_TOKEN"
|
||||
}
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
|
||||
@@ -8,10 +8,10 @@ from plugins_func.register import register_function, ToolType, ActionResponse, A
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
GET_NEWS_FUNCTION_DESC = {
|
||||
GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_news",
|
||||
"name": "get_news_from_chinanews",
|
||||
"description": (
|
||||
"获取最新新闻,随机选择一条新闻进行播报。"
|
||||
"用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。"
|
||||
@@ -23,20 +23,20 @@ GET_NEWS_FUNCTION_DESC = {
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别"
|
||||
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别",
|
||||
},
|
||||
"detail": {
|
||||
"type": "boolean",
|
||||
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容"
|
||||
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容",
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN"
|
||||
}
|
||||
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN",
|
||||
},
|
||||
},
|
||||
"required": ["lang"]
|
||||
}
|
||||
}
|
||||
"required": ["lang"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -51,18 +51,30 @@ def fetch_news_from_rss(rss_url):
|
||||
|
||||
# 查找所有item元素(新闻条目)
|
||||
news_items = []
|
||||
for item in root.findall('.//item'):
|
||||
title = item.find('title').text if item.find('title') is not None else "无标题"
|
||||
link = item.find('link').text if item.find('link') is not None else "#"
|
||||
description = item.find('description').text if item.find('description') is not None else "无描述"
|
||||
pubDate = item.find('pubDate').text if item.find('pubDate') is not None else "未知时间"
|
||||
for item in root.findall(".//item"):
|
||||
title = (
|
||||
item.find("title").text if item.find("title") is not None else "无标题"
|
||||
)
|
||||
link = item.find("link").text if item.find("link") is not None else "#"
|
||||
description = (
|
||||
item.find("description").text
|
||||
if item.find("description") is not None
|
||||
else "无描述"
|
||||
)
|
||||
pubDate = (
|
||||
item.find("pubDate").text
|
||||
if item.find("pubDate") is not None
|
||||
else "未知时间"
|
||||
)
|
||||
|
||||
news_items.append({
|
||||
'title': title,
|
||||
'link': link,
|
||||
'description': description,
|
||||
'pubDate': pubDate
|
||||
})
|
||||
news_items.append(
|
||||
{
|
||||
"title": title,
|
||||
"link": link,
|
||||
"description": description,
|
||||
"pubDate": pubDate,
|
||||
}
|
||||
)
|
||||
|
||||
return news_items
|
||||
except Exception as e:
|
||||
@@ -76,18 +88,24 @@ def fetch_news_detail(url):
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.content, 'html.parser')
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
|
||||
# 尝试提取正文内容 (这里的选择器需要根据实际网站结构调整)
|
||||
content_div = soup.select_one('.content_desc, .content, article, .article-content')
|
||||
content_div = soup.select_one(
|
||||
".content_desc, .content, article, .article-content"
|
||||
)
|
||||
if content_div:
|
||||
paragraphs = content_div.find_all('p')
|
||||
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
|
||||
paragraphs = content_div.find_all("p")
|
||||
content = "\n".join(
|
||||
[p.get_text().strip() for p in paragraphs if p.get_text().strip()]
|
||||
)
|
||||
return content
|
||||
else:
|
||||
# 如果找不到特定的内容区域,尝试获取所有段落
|
||||
paragraphs = soup.find_all('p')
|
||||
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
|
||||
paragraphs = soup.find_all("p")
|
||||
content = "\n".join(
|
||||
[p.get_text().strip() for p in paragraphs if p.get_text().strip()]
|
||||
)
|
||||
return content[:2000] # 限制长度
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
|
||||
@@ -111,7 +129,7 @@ def map_category(category_text):
|
||||
"财经": "finance",
|
||||
"财经新闻": "finance",
|
||||
"金融": "finance",
|
||||
"经济": "finance"
|
||||
"经济": "finance",
|
||||
}
|
||||
|
||||
# 转换为小写并去除空格
|
||||
@@ -121,20 +139,36 @@ def map_category(category_text):
|
||||
return category_map.get(normalized_category, category_text)
|
||||
|
||||
|
||||
@register_function('get_news', GET_NEWS_FUNCTION_DESC, ToolType.SYSTEM_CTL)
|
||||
def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_CN"):
|
||||
@register_function(
|
||||
"get_news_from_chinanews",
|
||||
GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC,
|
||||
ToolType.SYSTEM_CTL,
|
||||
)
|
||||
def get_news_from_chinanews(
|
||||
conn, category: str = None, detail: bool = False, lang: str = "zh_CN"
|
||||
):
|
||||
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
|
||||
try:
|
||||
# 如果detail为True,获取上一条新闻的详细内容
|
||||
if detail:
|
||||
if not hasattr(conn, 'last_news_link') or not conn.last_news_link or 'link' not in conn.last_news_link:
|
||||
return ActionResponse(Action.REQLLM, "抱歉,没有找到最近查询的新闻,请先获取一条新闻。", None)
|
||||
if (
|
||||
not hasattr(conn, "last_news_link")
|
||||
or not conn.last_news_link
|
||||
or "link" not in conn.last_news_link
|
||||
):
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
"抱歉,没有找到最近查询的新闻,请先获取一条新闻。",
|
||||
None,
|
||||
)
|
||||
|
||||
link = conn.last_news_link.get('link')
|
||||
title = conn.last_news_link.get('title', '未知标题')
|
||||
link = conn.last_news_link.get("link")
|
||||
title = conn.last_news_link.get("title", "未知标题")
|
||||
|
||||
if link == '#':
|
||||
return ActionResponse(Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None)
|
||||
if link == "#":
|
||||
return ActionResponse(
|
||||
Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None
|
||||
)
|
||||
|
||||
logger.bind(tag=TAG).debug(f"获取新闻详情: {title}, URL={link}")
|
||||
|
||||
@@ -142,8 +176,11 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
|
||||
detail_content = fetch_news_detail(link)
|
||||
|
||||
if not detail_content or detail_content == "无法获取详细内容":
|
||||
return ActionResponse(Action.REQLLM,
|
||||
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。", None)
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。",
|
||||
None,
|
||||
)
|
||||
|
||||
# 构建详情报告
|
||||
detail_report = (
|
||||
@@ -158,8 +195,10 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
|
||||
|
||||
# 否则,获取新闻列表并随机选择一条
|
||||
# 从配置中获取RSS URL
|
||||
rss_config = conn.config["plugins"]["get_news"]
|
||||
default_rss_url = rss_config.get("default_rss_url", "https://www.chinanews.com.cn/rss/society.xml")
|
||||
rss_config = conn.config["plugins"]["get_news_from_chinanews"]
|
||||
default_rss_url = rss_config.get(
|
||||
"default_rss_url", "https://www.chinanews.com.cn/rss/society.xml"
|
||||
)
|
||||
|
||||
# 将用户输入的类别映射到配置中的类别键
|
||||
mapped_category = map_category(category)
|
||||
@@ -169,23 +208,27 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
|
||||
if mapped_category and mapped_category in rss_config.get("category_urls", {}):
|
||||
rss_url = rss_config["category_urls"][mapped_category]
|
||||
|
||||
logger.bind(tag=TAG).info(f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}"
|
||||
)
|
||||
|
||||
# 获取新闻列表
|
||||
news_items = fetch_news_from_rss(rss_url)
|
||||
|
||||
if not news_items:
|
||||
return ActionResponse(Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None)
|
||||
return ActionResponse(
|
||||
Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None
|
||||
)
|
||||
|
||||
# 随机选择一条新闻
|
||||
selected_news = random.choice(news_items)
|
||||
|
||||
# 保存当前新闻链接到连接对象,以便后续查询详情
|
||||
if not hasattr(conn, 'last_news_link'):
|
||||
if not hasattr(conn, "last_news_link"):
|
||||
conn.last_news_link = {}
|
||||
conn.last_news_link = {
|
||||
'link': selected_news.get('link', '#'),
|
||||
'title': selected_news.get('title', '未知标题')
|
||||
"link": selected_news.get("link", "#"),
|
||||
"title": selected_news.get("title", "未知标题"),
|
||||
}
|
||||
|
||||
# 构建新闻报告
|
||||
@@ -203,4 +246,6 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"获取新闻出错: {e}")
|
||||
return ActionResponse(Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None)
|
||||
return ActionResponse(
|
||||
Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
import random
|
||||
import requests
|
||||
import json
|
||||
from config.logger import setup_logging
|
||||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||||
from markitdown import MarkItDown
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
# 新闻来源字典,包含名称和对应的API ID
|
||||
NEWS_SOURCES = {
|
||||
"thepaper": "澎湃新闻",
|
||||
"baidu": "百度热搜",
|
||||
"cls-depth": "财联社",
|
||||
}
|
||||
|
||||
|
||||
# 动态生成新闻源描述
|
||||
def generate_news_sources_description():
|
||||
sources_desc = []
|
||||
for source_id, source_name in NEWS_SOURCES.items():
|
||||
sources_desc.append(f"{source_name}({source_id})")
|
||||
return "、".join(sources_desc)
|
||||
|
||||
|
||||
GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_news_from_newsnow",
|
||||
"description": (
|
||||
"获取最新新闻,随机选择一条新闻进行播报。"
|
||||
f"用户可以选择不同的新闻源,如{generate_news_sources_description()}等。"
|
||||
"如果没有指定,默认从澎湃新闻获取。"
|
||||
"用户可以要求获取详细内容,此时会获取新闻的详细内容。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": f"新闻源,例如{generate_news_sources_description()}等。可选参数,如果不提供则使用默认新闻源",
|
||||
},
|
||||
"detail": {
|
||||
"type": "boolean",
|
||||
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容",
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN",
|
||||
},
|
||||
},
|
||||
"required": ["lang"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fetch_news_from_api(conn, source="thepaper"):
|
||||
"""从API获取新闻列表"""
|
||||
try:
|
||||
api_url = f"https://newsnow.busiyi.world/api/s?id={source}"
|
||||
if conn.config["plugins"].get("get_news_from_newsnow") and conn.config[
|
||||
"plugins"
|
||||
]["get_news_from_newsnow"].get("url"):
|
||||
api_url = conn.config["plugins"]["get_news_from_newsnow"]["url"] + source
|
||||
|
||||
response = requests.get(api_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "items" in data:
|
||||
return data["items"]
|
||||
else:
|
||||
logger.bind(tag=TAG).error(f"获取新闻API响应格式错误: {data}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"获取新闻API失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def fetch_news_detail(url):
|
||||
"""获取新闻详情页内容并使用MarkItDown清理HTML"""
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# 使用MarkItDown清理HTML内容
|
||||
md = MarkItDown(enable_plugins=False)
|
||||
result = md.convert(response)
|
||||
|
||||
# 获取清理后的文本内容
|
||||
clean_text = result.text_content
|
||||
|
||||
# 如果清理后的内容为空,返回提示信息
|
||||
if not clean_text or len(clean_text.strip()) == 0:
|
||||
logger.bind(tag=TAG).warning(f"清理后的新闻内容为空: {url}")
|
||||
return "无法解析新闻详情内容,可能是网站结构特殊或内容受限。"
|
||||
|
||||
return clean_text
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
|
||||
return "无法获取详细内容"
|
||||
|
||||
|
||||
@register_function(
|
||||
"get_news_from_newsnow",
|
||||
GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC,
|
||||
ToolType.SYSTEM_CTL,
|
||||
)
|
||||
def get_news_from_newsnow(
|
||||
conn, source: str = "thepaper", detail: bool = False, lang: str = "zh_CN"
|
||||
):
|
||||
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
|
||||
try:
|
||||
# 如果detail为True,获取上一条新闻的详细内容
|
||||
detail = str(detail).lower() == "true"
|
||||
if detail:
|
||||
if (
|
||||
not hasattr(conn, "last_newsnow_link")
|
||||
or not conn.last_newsnow_link
|
||||
or "url" not in conn.last_newsnow_link
|
||||
):
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
"抱歉,没有找到最近查询的新闻,请先获取一条新闻。",
|
||||
None,
|
||||
)
|
||||
|
||||
url = conn.last_newsnow_link.get("url")
|
||||
title = conn.last_newsnow_link.get("title", "未知标题")
|
||||
source_id = conn.last_newsnow_link.get("source_id", "thepaper")
|
||||
source_name = NEWS_SOURCES.get(source_id, "未知来源")
|
||||
|
||||
if not url or url == "#":
|
||||
return ActionResponse(
|
||||
Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None
|
||||
)
|
||||
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"获取新闻详情: {title}, 来源: {source_name}, URL={url}"
|
||||
)
|
||||
|
||||
# 获取新闻详情
|
||||
detail_content = fetch_news_detail(url)
|
||||
|
||||
if not detail_content or detail_content == "无法获取详细内容":
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。",
|
||||
None,
|
||||
)
|
||||
|
||||
# 构建详情报告
|
||||
detail_report = (
|
||||
f"根据下列数据,用{lang}回应用户的新闻详情查询请求:\n\n"
|
||||
f"新闻标题: {title}\n"
|
||||
# f"新闻来源: {source_name}\n"
|
||||
f"详细内容: {detail_content}\n\n"
|
||||
f"(请对上述新闻内容进行总结,提取关键信息,以自然、流畅的方式向用户播报,"
|
||||
f"不要提及这是总结,就像是在讲述一个完整的新闻故事)"
|
||||
)
|
||||
|
||||
return ActionResponse(Action.REQLLM, detail_report, None)
|
||||
|
||||
# 否则,获取新闻列表并随机选择一条
|
||||
# 验证新闻源是否有效,如果无效则使用默认源
|
||||
if source not in NEWS_SOURCES:
|
||||
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源thepaper")
|
||||
source = "thepaper"
|
||||
|
||||
source_name = NEWS_SOURCES.get(source, "澎湃新闻")
|
||||
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({source_name})")
|
||||
|
||||
# 获取新闻列表
|
||||
news_items = fetch_news_from_api(conn, source)
|
||||
|
||||
if not news_items:
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
f"抱歉,未能从{source_name}获取到新闻信息,请稍后再试或尝试其他新闻源。",
|
||||
None,
|
||||
)
|
||||
|
||||
# 随机选择一条新闻
|
||||
selected_news = random.choice(news_items)
|
||||
|
||||
# 保存当前新闻链接到连接对象,以便后续查询详情
|
||||
if not hasattr(conn, "last_newsnow_link"):
|
||||
conn.last_newsnow_link = {}
|
||||
conn.last_newsnow_link = {
|
||||
"url": selected_news.get("url", "#"),
|
||||
"title": selected_news.get("title", "未知标题"),
|
||||
"source_id": source,
|
||||
}
|
||||
|
||||
# 构建新闻报告
|
||||
news_report = (
|
||||
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
|
||||
f"新闻标题: {selected_news['title']}\n"
|
||||
# f"新闻来源: {source_name}\n"
|
||||
f"(请以自然、流畅的方式向用户播报这条新闻标题,"
|
||||
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
|
||||
)
|
||||
|
||||
return ActionResponse(Action.REQLLM, news_report, None)
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"获取新闻出错: {e}")
|
||||
return ActionResponse(
|
||||
Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None
|
||||
)
|
||||
@@ -8,7 +8,7 @@ HASS_CACHE = {}
|
||||
|
||||
|
||||
def append_devices_to_prompt(conn):
|
||||
if conn.use_function_call_mode:
|
||||
if conn.intent_type == "function_call":
|
||||
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
|
||||
"functions", []
|
||||
)
|
||||
@@ -27,7 +27,7 @@ def append_devices_to_prompt(conn):
|
||||
def initialize_hass_handler(conn):
|
||||
global HASS_CACHE
|
||||
if HASS_CACHE == {}:
|
||||
if conn.use_function_call_mode:
|
||||
if conn.load_function_plugin:
|
||||
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
|
||||
"functions", []
|
||||
)
|
||||
|
||||
@@ -27,4 +27,6 @@ cnlunar==0.2.0
|
||||
PySocks==1.7.1
|
||||
dashscope==1.23.1
|
||||
baidu-aip==4.16.13
|
||||
chardet==5.2.0
|
||||
chardet==5.2.0
|
||||
aioconsole==0.8.1
|
||||
markitdown==0.1.1
|
||||