mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 23:53:55 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f7b7da184 | ||
|
|
c0020c8769 | ||
|
|
1c5e678cef | ||
|
|
3ab1b8ebc9 | ||
|
|
7635c68ccd | ||
|
|
a3b562b895 | ||
|
|
94d1493195 | ||
|
|
3a5bbe32b9 | ||
|
|
31328e092e | ||
|
|
29671f48a8 | ||
|
|
61c2782491 | ||
|
|
15263b5223 | ||
|
|
557d1ee5b0 | ||
|
|
777265c730 | ||
|
|
e76a8d547b | ||
|
|
ff98f84f18 | ||
|
|
d0b4fa5b28 | ||
|
|
91b80f7c49 | ||
|
|
47f409246d | ||
|
|
be1ff83297 | ||
|
|
297c9e0085 | ||
|
|
ec95917b4d | ||
|
|
98bfe863fe | ||
|
|
af3d00662e | ||
|
|
39803fb0df | ||
|
|
4dad5ea6c1 | ||
|
|
ba8cb8c6f8 | ||
|
|
64f10b28e7 | ||
|
|
7f34447ace | ||
|
|
c2e000f937 | ||
|
|
831ab6d99b | ||
|
|
b246d9e567 | ||
|
|
1c823c4255 | ||
|
|
ee18fbebae | ||
|
|
fa56d06e0c | ||
|
|
e7054ea13f | ||
|
|
ba86b34a8c | ||
|
|
bde260b330 | ||
|
|
59ef51ea20 |
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="./README.md">English</a>
|
<a href="./README_en.md">English</a>
|
||||||
· <a href="./docs/FAQ.md">常见问题</a>
|
· <a href="./docs/FAQ.md">常见问题</a>
|
||||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</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>
|
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
|
||||||
|
|||||||
+273
@@ -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
-1
@@ -16,4 +16,4 @@ https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh
|
|||||||
|
|
||||||
# manager-web 、manager-api接口协议
|
# manager-web 、manager-api接口协议
|
||||||
|
|
||||||
https://2662r3426b.vicp.fun/xiaozhi/v1/doc.html
|
https://2662r3426b.vicp.fun/xiaozhi/doc.html
|
||||||
|
|||||||
@@ -177,5 +177,5 @@ public interface Constant {
|
|||||||
/**
|
/**
|
||||||
* 版本号
|
* 版本号
|
||||||
*/
|
*/
|
||||||
public static final String VERSION = "0.4.1";
|
public static final String VERSION = "0.4.2";
|
||||||
}
|
}
|
||||||
+2
-1
@@ -257,7 +257,8 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
|
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
|
||||||
intentLLMModelId = null;
|
intentLLMModelId = null;
|
||||||
}
|
}
|
||||||
} else if ("function_call".equals(map.get("type"))) {
|
}
|
||||||
|
if (map.get("functions") != null) {
|
||||||
String functionStr = (String) map.get("functions");
|
String functionStr = (String) map.get("functions");
|
||||||
if (StringUtils.isNotBlank(functionStr)) {
|
if (StringUtils.isNotBlank(functionStr)) {
|
||||||
String[] functions = functionStr.split("\\;");
|
String[] functions = functionStr.split("\\;");
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
package xiaozhi.modules.device.dao;
|
package xiaozhi.modules.device.dao;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface DeviceDao extends BaseMapper<DeviceEntity> {
|
public interface DeviceDao extends BaseMapper<DeviceEntity> {
|
||||||
/**
|
/**
|
||||||
* 获取此智能体全部设备的最后连接时间
|
* 获取此智能体全部设备的最后连接时间
|
||||||
|
*
|
||||||
* @param agentId 智能体id
|
* @param agentId 智能体id
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-1
@@ -118,7 +118,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
|
|
||||||
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
|
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();
|
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
|
||||||
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
||||||
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class SysParamsDTO implements Serializable {
|
|||||||
|
|
||||||
@Schema(description = "值类型")
|
@Schema(description = "值类型")
|
||||||
@NotBlank(message = "{sysparams.valuetype.require}", groups = DefaultGroup.class)
|
@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;
|
private String valueType;
|
||||||
|
|
||||||
@Schema(description = "备注")
|
@Schema(description = "备注")
|
||||||
|
|||||||
+6
@@ -127,6 +127,12 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
|||||||
break;
|
break;
|
||||||
case "json":
|
case "json":
|
||||||
try {
|
try {
|
||||||
|
// 首先检查是否以 { 开头,以 } 结尾
|
||||||
|
String trimmedValue = paramValue.trim();
|
||||||
|
if (!trimmedValue.startsWith("{") || !trimmedValue.endsWith("}")) {
|
||||||
|
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
||||||
|
}
|
||||||
|
// 然后尝试解析JSON
|
||||||
JsonUtils.parseObject(paramValue, Object.class);
|
JsonUtils.parseObject(paramValue, Object.class);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- 添加百度ASR模型配置
|
||||||
|
delete from `ai_model_config` where `id` = 'ASR_BaiduASR';
|
||||||
|
INSERT INTO `ai_model_config` VALUES ('ASR_BaiduASR', 'ASR', 'BaiduASR', '百度语音识别', 0, 1, '{\"type\": \"baidu\", \"app_id\": \"\", \"api_key\": \"\", \"secret_key\": \"\", \"dev_pid\": 1537, \"output_dir\": \"tmp/\"}', NULL, NULL, 7, NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
|
||||||
|
-- 添加百度ASR供应器
|
||||||
|
delete from `ai_model_provider` where `id` = 'SYSTEM_ASR_BaiduASR';
|
||||||
|
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||||
|
('SYSTEM_ASR_BaiduASR', 'ASR', 'baidu', '百度语音识别', '[{"key":"app_id","label":"应用AppID","type":"string"},{"key":"api_key","label":"API Key","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"dev_pid","label":"语言参数","type":"number"},{"key":"output_dir","label":"输出目录","type":"string"}]', 7, 1, NOW(), 1, NOW());
|
||||||
|
|
||||||
|
|
||||||
|
-- 更新百度ASR配置说明
|
||||||
|
UPDATE `ai_model_config` SET
|
||||||
|
`doc_link` = 'https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list',
|
||||||
|
`remark` = '百度ASR配置说明:
|
||||||
|
1. 访问 https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list
|
||||||
|
2. 创建新应用
|
||||||
|
3. 获取AppID、API Key和Secret Key
|
||||||
|
4. 填入配置文件中
|
||||||
|
查看资源额度:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/overview/resource/list
|
||||||
|
语言参数说明:https://ai.baidu.com/ai-doc/SPEECH/0lbxfnc9b
|
||||||
|
' WHERE `id` = 'ASR_BaiduASR';
|
||||||
|
|
||||||
|
-- 更新豆包供应器字段
|
||||||
|
update `ai_model_provider` set `fields` =
|
||||||
|
'[{"key":"appid","label":"应用ID","type":"string"},{"key":"access_token","label":"访问令牌","type":"string"},{"key":"cluster","label":"集群","type":"string"},{"key":"boosting_table_name","label":"热词文件名称","type":"string"},{"key":"correct_table_name","label":"替换词文件名称","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]'
|
||||||
|
where `id` = 'SYSTEM_ASR_DoubaoASR';
|
||||||
|
|
||||||
|
-- 更新豆包ASR配置说明
|
||||||
|
UPDATE `ai_model_config` SET
|
||||||
|
`doc_link` = 'https://console.volcengine.com/speech/app',
|
||||||
|
`remark` = '豆包ASR配置说明:
|
||||||
|
1. 需要在火山引擎控制台创建应用并获取appid和access_token
|
||||||
|
2. 支持中文语音识别
|
||||||
|
3. 需要网络连接
|
||||||
|
4. 输出文件保存在tmp/目录
|
||||||
|
申请步骤:
|
||||||
|
1. 访问 https://console.volcengine.com/speech/app
|
||||||
|
2. 创建新应用
|
||||||
|
3. 获取appid和access_token
|
||||||
|
4. 填入配置文件中
|
||||||
|
如需设置热词,请参考:https://www.volcengine.com/docs/6561/155738
|
||||||
|
' WHERE `id` = 'ASR_DoubaoASR';
|
||||||
@@ -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';
|
||||||
@@ -99,4 +99,18 @@ databaseChangeLog:
|
|||||||
changes:
|
changes:
|
||||||
- sqlFile:
|
- sqlFile:
|
||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202505022134.sql
|
path: classpath:db/changelog/202505022134.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202505081146
|
||||||
|
author: hrz
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202505081146.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202505091409
|
||||||
|
author: hrz
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202505091409.sql
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :visible="visible" @close="handleClose" width="400px" center>
|
<el-dialog :visible="visible" @close="handleClose" width="24%" center>
|
||||||
<div
|
<div
|
||||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
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="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;">
|
<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>
|
||||||
|
|
||||||
<el-form-item label="备注" prop="remark" class="prop-remark">
|
<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>
|
class="custom-input-bg"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -271,7 +271,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.center-dialog .el-dialog {
|
.center-dialog .el-dialog {
|
||||||
margin: 4% 0 auto !important;
|
margin: 0 0 auto !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen">
|
<el-dialog :visible="visible" @close="handleClose" width="25%" center @open="handleOpen">
|
||||||
<div
|
<div
|
||||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||||
<div
|
<div
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="height: 1px;background: #e8f0ff;" />
|
<div style="height: 1px;background: #e8f0ff;" />
|
||||||
<div style="margin: 22px 15px;">
|
<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 style="color: red;display: inline-block;">*</div> 智能体名称:
|
||||||
</div>
|
</div>
|
||||||
<div class="input-46" style="margin-top: 12px;">
|
<div class="input-46" style="margin-top: 12px;">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<form>
|
<form>
|
||||||
<el-dialog :visible.sync="value" width="400px" center>
|
<el-dialog :visible.sync="dialogVisible" width="24%" center>
|
||||||
<div
|
<div
|
||||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||||
<div
|
<div
|
||||||
@@ -60,11 +60,20 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
dialogVisible: this.value,
|
||||||
oldPassword: "",
|
oldPassword: "",
|
||||||
newPassword: "",
|
newPassword: "",
|
||||||
confirmNewPassword: ""
|
confirmNewPassword: ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
value(val) {
|
||||||
|
this.dialogVisible = val;
|
||||||
|
},
|
||||||
|
dialogVisible(val) {
|
||||||
|
this.$emit('input', val);
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['logout']), // 引入Vuex的logout action
|
...mapActions(['logout']), // 引入Vuex的logout action
|
||||||
confirm() {
|
confirm() {
|
||||||
@@ -101,7 +110,7 @@ export default {
|
|||||||
this.$emit('input', false);
|
this.$emit('input', false);
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
this.$emit('input', false);
|
this.dialogVisible = false;
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
},
|
},
|
||||||
resetForm() {
|
resetForm() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 :model="form" :rules="rules" ref="form" label-width="100px">
|
||||||
<el-form-item label="字典标签" prop="dictLabel">
|
<el-form-item label="字典标签" prop="dictLabel">
|
||||||
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
|
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
|
||||||
@@ -41,6 +41,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
dialogVisible: this.visible,
|
||||||
form: {
|
form: {
|
||||||
id: null,
|
id: null,
|
||||||
dictTypeId: null,
|
dictTypeId: null,
|
||||||
@@ -70,12 +71,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
immediate: true
|
immediate: true
|
||||||
|
},
|
||||||
|
visible(val) {
|
||||||
|
this.dialogVisible = val;
|
||||||
|
},
|
||||||
|
dialogVisible(val) {
|
||||||
|
this.$emit('update:visible', val);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleClose() {
|
handleClose() {
|
||||||
this.$emit('update:visible', false)
|
this.dialogVisible = false;
|
||||||
this.resetForm()
|
this.resetForm();
|
||||||
},
|
},
|
||||||
resetForm() {
|
resetForm() {
|
||||||
this.form = {
|
this.form = {
|
||||||
@@ -102,4 +109,8 @@ export default {
|
|||||||
.dialog-footer {
|
.dialog-footer {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 :model="form" :rules="rules" ref="form" label-width="120px">
|
||||||
<el-form-item label="字典类型名称" prop="dictName">
|
<el-form-item label="字典类型名称" prop="dictName">
|
||||||
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
|
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
|
||||||
@@ -34,6 +34,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
dialogVisible: this.visible,
|
||||||
form: {
|
form: {
|
||||||
id: null,
|
id: null,
|
||||||
dictName: '',
|
dictName: '',
|
||||||
@@ -46,6 +47,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
visible(val) {
|
||||||
|
this.dialogVisible = val;
|
||||||
|
},
|
||||||
|
dialogVisible(val) {
|
||||||
|
this.$emit('update:visible', val);
|
||||||
|
},
|
||||||
dictTypeData: {
|
dictTypeData: {
|
||||||
handler(val) {
|
handler(val) {
|
||||||
if (val) {
|
if (val) {
|
||||||
@@ -57,7 +64,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleClose() {
|
handleClose() {
|
||||||
this.$emit('update:visible', false)
|
this.dialogVisible = false;
|
||||||
this.resetForm()
|
this.resetForm()
|
||||||
},
|
},
|
||||||
resetForm() {
|
resetForm() {
|
||||||
@@ -83,4 +90,8 @@ export default {
|
|||||||
.dialog-footer {
|
.dialog-footer {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
|
<el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose" @open="handleOpen">
|
||||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="固件名称" prop="firmwareName">
|
<el-form-item label="固件名称" prop="firmwareName">
|
||||||
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
|
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
|
||||||
@@ -59,11 +59,13 @@ export default {
|
|||||||
default: () => []
|
default: () => []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
uploadStatus: '',
|
uploadStatus: '',
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
|
dialogVisible: this.visible,
|
||||||
rules: {
|
rules: {
|
||||||
firmwareName: [
|
firmwareName: [
|
||||||
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
|
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
|
||||||
@@ -90,10 +92,18 @@ export default {
|
|||||||
created() {
|
created() {
|
||||||
// 移除 getDictDataByType 调用
|
// 移除 getDictDataByType 调用
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
visible(val) {
|
||||||
|
this.dialogVisible = val;
|
||||||
|
},
|
||||||
|
dialogVisible(val) {
|
||||||
|
this.$emit('update:visible', val);
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 移除 getFirmwareTypes 方法
|
// 移除 getFirmwareTypes 方法
|
||||||
handleClose() {
|
handleClose() {
|
||||||
this.$refs.form.clearValidate();
|
this.dialogVisible = false;
|
||||||
this.$emit('cancel');
|
this.$emit('cancel');
|
||||||
},
|
},
|
||||||
handleCancel() {
|
handleCancel() {
|
||||||
@@ -201,13 +211,17 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
::v-deep .el-dialog {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-demo {
|
.upload-demo {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-upload__tip {
|
.el-upload__tip {
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
padding-top: 5px;
|
padding-top: 2%;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :visible.sync="dialogVisible" width="975px" center custom-class="custom-dialog" :show-close="false"
|
<el-dialog :visible.sync="dialogVisible" width="57%" center custom-class="custom-dialog" :show-close="false"
|
||||||
class="center-dialog">
|
class="center-dialog" >
|
||||||
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
<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;">
|
<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>
|
||||||
|
|
||||||
<el-form-item label="备注" prop="remark" class="prop-remark">
|
<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>
|
class="custom-input-bg"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -296,7 +296,7 @@ export default {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
.custom-dialog {
|
.custom-dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -316,11 +316,6 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-dialog .el-dialog {
|
|
||||||
margin: 4% 0 auto !important;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-close-btn {
|
.custom-close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ export default {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
::v-deep .el-dialog {
|
::v-deep .el-dialog {
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
@@ -648,12 +648,6 @@ export default {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 新增按钮组样式 */
|
|
||||||
.action-buttons {
|
|
||||||
bottom: 20px;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons .el-button {
|
.action-buttons .el-button {
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -692,7 +686,6 @@ export default {
|
|||||||
position: static;
|
position: static;
|
||||||
padding: 15px 0;
|
padding: 15px 0;
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输入框自适应 */
|
/* 输入框自适应 */
|
||||||
|
|||||||
@@ -49,9 +49,13 @@
|
|||||||
v-loading="dictDataLoading" element-loading-text="拼命加载中"
|
v-loading="dictDataLoading" element-loading-text="拼命加载中"
|
||||||
element-loading-spinner="el-icon-loading"
|
element-loading-spinner="el-icon-loading"
|
||||||
element-loading-background="rgba(255, 255, 255, 0.7)"
|
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||||
@selection-change="handleDictDataSelectionChange" class="data-table"
|
class="data-table"
|
||||||
header-row-class-name="table-header">
|
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="dictLabel" align="center"></el-table-column>
|
||||||
<el-table-column label="字典值" prop="dictValue" 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>
|
<el-table-column label="排序" prop="sort" align="center"></el-table-column>
|
||||||
@@ -153,7 +157,6 @@ export default {
|
|||||||
// 字典数据相关
|
// 字典数据相关
|
||||||
dictDataList: [],
|
dictDataList: [],
|
||||||
dictDataLoading: false,
|
dictDataLoading: false,
|
||||||
selectedDictData: [],
|
|
||||||
isAllDictDataSelected: false,
|
isAllDictDataSelected: false,
|
||||||
dictDataDialogVisible: false,
|
dictDataDialogVisible: false,
|
||||||
dictDataDialogTitle: '新增字典数据',
|
dictDataDialogTitle: '新增字典数据',
|
||||||
@@ -265,7 +268,10 @@ export default {
|
|||||||
dictValue: ''
|
dictValue: ''
|
||||||
}, ({ data }) => {
|
}, ({ data }) => {
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.dictDataList = data.data.list
|
this.dictDataList = data.data.list.map(item => ({
|
||||||
|
...item,
|
||||||
|
selected: false
|
||||||
|
}))
|
||||||
this.total = data.data.total
|
this.total = data.data.total
|
||||||
} else {
|
} else {
|
||||||
this.$message.error(data.msg || '获取字典数据失败')
|
this.$message.error(data.msg || '获取字典数据失败')
|
||||||
@@ -273,16 +279,11 @@ export default {
|
|||||||
this.dictDataLoading = false
|
this.dictDataLoading = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
handleDictDataSelectionChange(val) {
|
|
||||||
this.selectedDictData = val
|
|
||||||
this.isAllDictDataSelected = val.length === this.dictDataList.length
|
|
||||||
},
|
|
||||||
selectAllDictData() {
|
selectAllDictData() {
|
||||||
if (this.isAllDictDataSelected) {
|
this.isAllDictDataSelected = !this.isAllDictDataSelected
|
||||||
this.$refs.dictDataTable.clearSelection()
|
this.dictDataList.forEach(row => {
|
||||||
} else {
|
row.selected = this.isAllDictDataSelected
|
||||||
this.$refs.dictDataTable.toggleAllSelection()
|
})
|
||||||
}
|
|
||||||
},
|
},
|
||||||
showAddDictDataDialog() {
|
showAddDictDataDialog() {
|
||||||
if (!this.selectedDictType) {
|
if (!this.selectedDictType) {
|
||||||
@@ -329,17 +330,18 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteDictData() {
|
batchDeleteDictData() {
|
||||||
if (this.selectedDictData.length === 0) {
|
const selectedRows = this.dictDataList.filter(row => row.selected)
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
this.$message.warning('请选择要删除的字典数据')
|
this.$message.warning('请选择要删除的字典数据')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$confirm('确定要删除选中的字典数据吗?', '提示', {
|
this.$confirm(`确定要删除选中的${selectedRows.length}个字典数据吗?`, '提示', {
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
const ids = this.selectedDictData.map(item => item.id)
|
const ids = selectedRows.map(item => item.id)
|
||||||
dictApi.deleteDictData(ids, ({ data }) => {
|
dictApi.deleteDictData(ids, ({ data }) => {
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.$message.success('删除成功')
|
this.$message.success('删除成功')
|
||||||
@@ -832,4 +834,18 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
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>
|
</style>
|
||||||
@@ -318,7 +318,6 @@ export default {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.welcome {
|
.welcome {
|
||||||
min-width: 900px;
|
min-width: 900px;
|
||||||
min-height: 506px;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -334,7 +333,7 @@ export default {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 24px;
|
padding: 1.5vh 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
@@ -344,7 +343,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-wrapper {
|
.main-wrapper {
|
||||||
margin: 5px 22px;
|
margin: 1vh 22px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
height: calc(100vh - 24vh);
|
height: calc(100vh - 24vh);
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
@@ -416,7 +415,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-content {
|
.form-content {
|
||||||
padding: 20px 0;
|
padding: 2vh 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
@@ -450,11 +449,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.template-item {
|
.template-item {
|
||||||
height: 37px;
|
height: 4vh;
|
||||||
width: 76px;
|
width: 76px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #e6ebff;
|
background: #e6ebff;
|
||||||
line-height: 37px;
|
line-height: 4vh;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -471,7 +470,7 @@ export default {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 20px;
|
margin-top: 2vh;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from core.ota_server import SimpleOtaServer
|
|||||||
from core.utils.util import check_ffmpeg_installed
|
from core.utils.util import check_ffmpeg_installed
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from core.utils.util import get_local_ip
|
from core.utils.util import get_local_ip
|
||||||
|
from aioconsole import ainput
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -34,10 +35,19 @@ async def wait_for_exit() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def monitor_stdin():
|
||||||
|
"""监控标准输入,消费回车键"""
|
||||||
|
while True:
|
||||||
|
await ainput() # 异步等待输入,消费回车
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
check_ffmpeg_installed()
|
check_ffmpeg_installed()
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
|
# 添加 stdin 监控任务
|
||||||
|
stdin_task = asyncio.create_task(monitor_stdin())
|
||||||
|
|
||||||
# 启动 WebSocket 服务器
|
# 启动 WebSocket 服务器
|
||||||
ws_server = WebSocketServer(config)
|
ws_server = WebSocketServer(config)
|
||||||
ws_task = asyncio.create_task(ws_server.start())
|
ws_task = asyncio.create_task(ws_server.start())
|
||||||
@@ -78,19 +88,22 @@ async def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await wait_for_exit() # 监听退出信号
|
await wait_for_exit() # 阻塞直到收到退出信号
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
print("任务被取消,清理资源中...")
|
print("任务被取消,清理资源中...")
|
||||||
finally:
|
finally:
|
||||||
|
# 取消所有任务(关键修复点)
|
||||||
|
stdin_task.cancel()
|
||||||
ws_task.cancel()
|
ws_task.cancel()
|
||||||
if ota_task:
|
if ota_task:
|
||||||
ota_task.cancel()
|
ota_task.cancel()
|
||||||
try:
|
|
||||||
await ws_task
|
# 等待任务终止(必须加超时)
|
||||||
if ota_task:
|
await asyncio.wait(
|
||||||
await ota_task
|
[stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
|
||||||
except asyncio.CancelledError:
|
timeout=3.0,
|
||||||
pass
|
return_when=asyncio.ALL_COMPLETED
|
||||||
|
)
|
||||||
print("服务器已关闭,程序退出。")
|
print("服务器已关闭,程序退出。")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -104,12 +104,13 @@ plugins:
|
|||||||
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
|
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
|
||||||
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
||||||
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
||||||
get_news:
|
get_news_from_chinanews:
|
||||||
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
|
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
|
||||||
category_urls:
|
category_urls:
|
||||||
society: "https://www.chinanews.com.cn/rss/society.xml"
|
society: "https://www.chinanews.com.cn/rss/society.xml"
|
||||||
world: "https://www.chinanews.com.cn/rss/world.xml"
|
world: "https://www.chinanews.com.cn/rss/world.xml"
|
||||||
finance: "https://www.chinanews.com.cn/rss/finance.xml"
|
finance: "https://www.chinanews.com.cn/rss/finance.xml"
|
||||||
|
get_news_from_newsnow: {"url": "https://newsnow.busiyi.world/api/s?id="}
|
||||||
home_assistant:
|
home_assistant:
|
||||||
devices:
|
devices:
|
||||||
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
|
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
|
||||||
@@ -156,7 +157,7 @@ selected_module:
|
|||||||
Memory: nomem
|
Memory: nomem
|
||||||
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
|
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
|
||||||
# 不想开通意图识别,就设置成:nointent
|
# 不想开通意图识别,就设置成:nointent
|
||||||
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
|
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
|
||||||
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
|
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
|
||||||
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115
|
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115
|
||||||
Intent: function_call
|
Intent: function_call
|
||||||
@@ -174,6 +175,13 @@ Intent:
|
|||||||
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
|
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
|
||||||
# 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
|
# 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
|
||||||
llm: ChatGLMLLM
|
llm: ChatGLMLLM
|
||||||
|
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
|
||||||
|
# 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载
|
||||||
|
# 下面是加载查天气、角色切换、加载查新闻的插件示例
|
||||||
|
functions:
|
||||||
|
- get_weather
|
||||||
|
- get_news_from_newsnow
|
||||||
|
- play_music
|
||||||
function_call:
|
function_call:
|
||||||
# 不需要动type
|
# 不需要动type
|
||||||
type: function_call
|
type: function_call
|
||||||
@@ -183,7 +191,8 @@ Intent:
|
|||||||
functions:
|
functions:
|
||||||
- change_role
|
- change_role
|
||||||
- get_weather
|
- get_weather
|
||||||
- get_news
|
# - get_news_from_chinanews
|
||||||
|
- get_news_from_newsnow
|
||||||
# play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
|
# play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
|
||||||
# 如果用了hass_play_music,就不要开启play_music,两者只留一个
|
# 如果用了hass_play_music,就不要开启play_music,两者只留一个
|
||||||
- play_music
|
- play_music
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from loguru import logger
|
|||||||
from config.config_loader import load_config
|
from config.config_loader import load_config
|
||||||
from config.settings import check_config_file
|
from config.settings import check_config_file
|
||||||
|
|
||||||
SERVER_VERSION = "0.4.1"
|
SERVER_VERSION = "0.4.2"
|
||||||
|
|
||||||
|
|
||||||
def get_module_abbreviation(module_name, module_dict):
|
def get_module_abbreviation(module_name, module_dict):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import queue
|
import queue
|
||||||
@@ -58,9 +60,9 @@ class ConnectionHandler:
|
|||||||
self.config = copy.deepcopy(config)
|
self.config = copy.deepcopy(config)
|
||||||
self.session_id = str(uuid.uuid4())
|
self.session_id = str(uuid.uuid4())
|
||||||
self.logger = setup_logging()
|
self.logger = setup_logging()
|
||||||
self.auth = AuthMiddleware(config)
|
|
||||||
self.server = server # 保存server实例的引用
|
self.server = server # 保存server实例的引用
|
||||||
|
|
||||||
|
self.auth = AuthMiddleware(config)
|
||||||
self.need_bind = False
|
self.need_bind = False
|
||||||
self.bind_code = None
|
self.bind_code = None
|
||||||
self.read_config_from_api = self.config.get("read_config_from_api", False)
|
self.read_config_from_api = self.config.get("read_config_from_api", False)
|
||||||
@@ -128,14 +130,18 @@ class ConnectionHandler:
|
|||||||
if len(cmd) > self.max_cmd_length:
|
if len(cmd) > self.max_cmd_length:
|
||||||
self.max_cmd_length = len(cmd)
|
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_task = None
|
||||||
self.timeout_seconds = (
|
self.timeout_seconds = (
|
||||||
int(self.config.get("close_connection_no_voice_time", 120)) + 60
|
int(self.config.get("close_connection_no_voice_time", 120)) + 60
|
||||||
) # 在原来第一道关闭的基础上加60秒,进行二道关闭
|
) # 在原来第一道关闭的基础上加60秒,进行二道关闭
|
||||||
|
|
||||||
|
self.audio_format = "opus"
|
||||||
|
|
||||||
async def handle_connection(self, ws):
|
async def handle_connection(self, ws):
|
||||||
try:
|
try:
|
||||||
# 获取并验证headers
|
# 获取并验证headers
|
||||||
@@ -237,11 +243,52 @@ class ConnectionHandler:
|
|||||||
elif isinstance(message, bytes):
|
elif isinstance(message, bytes):
|
||||||
await handleAudioMessage(self, message)
|
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):
|
def _initialize_components(self):
|
||||||
"""初始化组件"""
|
"""初始化组件"""
|
||||||
self.prompt = self.config["prompt"]
|
if self.config.get("prompt") is not None:
|
||||||
self.change_system_prompt(self.prompt)
|
self.prompt = self.config["prompt"]
|
||||||
self.logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {self.prompt[:50]}...")
|
self.change_system_prompt(self.prompt)
|
||||||
|
self.logger.bind(tag=TAG).info(
|
||||||
|
f"初始化组件: prompt成功 {self.prompt[:50]}..."
|
||||||
|
)
|
||||||
|
|
||||||
"""初始化本地组件"""
|
"""初始化本地组件"""
|
||||||
if self.vad is None:
|
if self.vad is None:
|
||||||
@@ -365,11 +412,11 @@ class ConnectionHandler:
|
|||||||
self.memory.init_memory(self.device_id, self.llm)
|
self.memory.init_memory(self.device_id, self.llm)
|
||||||
|
|
||||||
def _initialize_intent(self):
|
def _initialize_intent(self):
|
||||||
if (
|
self.intent_type = self.config["Intent"][
|
||||||
self.config["Intent"][self.config["selected_module"]["Intent"]]["type"]
|
self.config["selected_module"]["Intent"]
|
||||||
== "function_call"
|
]["type"]
|
||||||
):
|
if self.intent_type == "function_call" or self.intent_type == "intent_llm":
|
||||||
self.use_function_call_mode = True
|
self.load_function_plugin = True
|
||||||
"""初始化意图识别模块"""
|
"""初始化意图识别模块"""
|
||||||
# 获取意图识别配置
|
# 获取意图识别配置
|
||||||
intent_config = self.config["Intent"]
|
intent_config = self.config["Intent"]
|
||||||
@@ -757,7 +804,13 @@ class ConnectionHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.dialogue.put(
|
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)
|
self.chat_with_function_calling(text, tool_call=True)
|
||||||
elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
|
elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
|
||||||
@@ -803,9 +856,12 @@ class ConnectionHandler:
|
|||||||
f"TTS生成:文件路径: {tts_file}"
|
f"TTS生成:文件路径: {tts_file}"
|
||||||
)
|
)
|
||||||
if os.path.exists(tts_file):
|
if os.path.exists(tts_file):
|
||||||
opus_datas, _ = self.tts.audio_to_opus_data(tts_file)
|
if self.audio_format == "pcm":
|
||||||
|
audio_datas, _ = self.tts.audio_to_pcm_data(tts_file)
|
||||||
|
else:
|
||||||
|
audio_datas, _ = self.tts.audio_to_opus_data(tts_file)
|
||||||
# 在这里上报TTS数据(使用文件路径)
|
# 在这里上报TTS数据(使用文件路径)
|
||||||
enqueue_tts_report(self, 2, text, opus_datas)
|
enqueue_tts_report(self, 2, text, audio_datas)
|
||||||
else:
|
else:
|
||||||
self.logger.bind(tag=TAG).error(
|
self.logger.bind(tag=TAG).error(
|
||||||
f"TTS出错:文件不存在{tts_file}"
|
f"TTS出错:文件不存在{tts_file}"
|
||||||
@@ -816,7 +872,7 @@ class ConnectionHandler:
|
|||||||
self.logger.bind(tag=TAG).error(f"TTS出错: {e}")
|
self.logger.bind(tag=TAG).error(f"TTS出错: {e}")
|
||||||
if not self.client_abort:
|
if not self.client_abort:
|
||||||
# 如果没有中途打断就发送语音
|
# 如果没有中途打断就发送语音
|
||||||
self.audio_play_queue.put((opus_datas, text, text_index))
|
self.audio_play_queue.put((audio_datas, text, text_index))
|
||||||
if (
|
if (
|
||||||
self.tts.delete_audio_file
|
self.tts.delete_audio_file
|
||||||
and tts_file is not None
|
and tts_file is not None
|
||||||
@@ -847,13 +903,13 @@ class ConnectionHandler:
|
|||||||
text = None
|
text = None
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
opus_datas, text, text_index = self.audio_play_queue.get(timeout=1)
|
audio_datas, text, text_index = self.audio_play_queue.get(timeout=1)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
if self.stop_event.is_set():
|
if self.stop_event.is_set():
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
sendAudioMessage(self, opus_datas, text, text_index), self.loop
|
sendAudioMessage(self, audio_datas, text, text_index), self.loop
|
||||||
)
|
)
|
||||||
future.result()
|
future.result()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
from config.logger import setup_logging
|
|
||||||
from core.handle.sendAudioHandle import send_stt_message
|
from core.handle.sendAudioHandle import send_stt_message
|
||||||
from core.utils.util import remove_punctuation_and_length
|
from core.utils.util import remove_punctuation_and_length
|
||||||
import shutil
|
import shutil
|
||||||
@@ -20,7 +19,16 @@ WAKEUP_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def handleHelloMessage(conn):
|
async def handleHelloMessage(conn, msg_json):
|
||||||
|
"""处理hello消息"""
|
||||||
|
audio_params = msg_json.get("audio_params")
|
||||||
|
if audio_params:
|
||||||
|
format = audio_params.get("format")
|
||||||
|
conn.logger.bind(tag=TAG).info(f"客户端音频格式: {format}")
|
||||||
|
conn.audio_format = format
|
||||||
|
conn.asr.set_audio_format(format)
|
||||||
|
conn.welcome_msg["audio_params"] = audio_params
|
||||||
|
|
||||||
await conn.websocket.send(json.dumps(conn.welcome_msg))
|
await conn.websocket.send(json.dumps(conn.welcome_msg))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from core.handle.sendAudioHandle import send_stt_message
|
|||||||
from core.handle.helloHandle import checkWakeupWords
|
from core.handle.helloHandle import checkWakeupWords
|
||||||
from core.utils.util import remove_punctuation_and_length
|
from core.utils.util import remove_punctuation_and_length
|
||||||
from core.utils.dialogue import Message
|
from core.utils.dialogue import Message
|
||||||
|
from plugins_func.register import Action
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ async def handle_user_intent(conn, text):
|
|||||||
if await checkWakeupWords(conn, text):
|
if await checkWakeupWords(conn, text):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if conn.use_function_call_mode:
|
if conn.intent_type == "function_call":
|
||||||
# 使用支持function calling的聊天方法,不再进行意图分析
|
# 使用支持function calling的聊天方法,不再进行意图分析
|
||||||
return False
|
return False
|
||||||
# 使用LLM进行意图分析
|
# 使用LLM进行意图分析
|
||||||
@@ -100,24 +102,35 @@ async def process_intent_result(conn, intent_result, original_text):
|
|||||||
result = conn.func_handler.handle_llm_function_call(
|
result = conn.func_handler.handle_llm_function_call(
|
||||||
conn, function_call_data
|
conn, function_call_data
|
||||||
)
|
)
|
||||||
if result and function_name != "play_music":
|
logger.bind(tag=TAG).debug(f"检测到Action : {result.action}")
|
||||||
# 获取当前最新的文本索引
|
|
||||||
text = result.response
|
if result:
|
||||||
if text is None:
|
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
|
text = result.result
|
||||||
if text is not None:
|
conn.dialogue.put(Message(role="tool", content=text))
|
||||||
text_index = (
|
llm_result = conn.intent.replyResult(text, original_text)
|
||||||
conn.tts_last_text_index + 1
|
if llm_result is None:
|
||||||
if hasattr(conn, "tts_last_text_index")
|
llm_result = text
|
||||||
else 0
|
speak_and_play(conn, llm_result)
|
||||||
)
|
elif (
|
||||||
conn.recode_first_last_text(text, text_index)
|
result.action == Action.NOTFOUND
|
||||||
future = conn.executor.submit(
|
or result.action == Action.ERROR
|
||||||
conn.speak_and_play, text, text_index
|
):
|
||||||
)
|
text = result.result
|
||||||
conn.llm_finish_task = True
|
if text is not None:
|
||||||
conn.tts_queue.put((future, text_index))
|
speak_and_play(conn, text)
|
||||||
conn.dialogue.put(Message(role="assistant", content=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)
|
conn.executor.submit(process_function_call)
|
||||||
@@ -128,21 +141,12 @@ async def process_intent_result(conn, intent_result, original_text):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def extract_text_in_brackets(s):
|
def speak_and_play(conn, text):
|
||||||
"""
|
text_index = (
|
||||||
从字符串中提取中括号内的文字
|
conn.tts_last_text_index + 1 if hasattr(conn, "tts_last_text_index") else 0
|
||||||
|
)
|
||||||
:param s: 输入字符串
|
conn.recode_first_last_text(text, text_index)
|
||||||
:return: 中括号内的文字,如果不存在则返回空字符串
|
future = conn.executor.submit(conn.speak_and_play, text, text_index)
|
||||||
"""
|
conn.llm_finish_task = True
|
||||||
left_bracket_index = s.find("[")
|
conn.tts_queue.put((future, text_index))
|
||||||
right_bracket_index = s.find("]")
|
conn.dialogue.put(Message(role="assistant", content=text))
|
||||||
|
|
||||||
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 ""
|
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ async def handleIotDescriptors(conn, descriptors):
|
|||||||
)
|
)
|
||||||
conn.iot_descriptors[descriptor["name"]] = iot_descriptor
|
conn.iot_descriptors[descriptor["name"]] = iot_descriptor
|
||||||
|
|
||||||
if conn.use_function_call_mode:
|
if conn.load_function_plugin:
|
||||||
# 注册或获取设备类型
|
# 注册或获取设备类型
|
||||||
type_id = register_device_type(descriptor)
|
type_id = register_device_type(descriptor)
|
||||||
device_functions = device_type_registry.get_device_functions(type_id)
|
device_functions = device_type_registry.get_device_functions(type_id)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from core.handle.sendAudioHandle import send_stt_message
|
|||||||
from core.handle.intentHandler import handle_user_intent
|
from core.handle.intentHandler import handle_user_intent
|
||||||
from core.utils.output_counter import check_device_output_limit
|
from core.utils.output_counter import check_device_output_limit
|
||||||
from core.handle.ttsReportHandle import enqueue_tts_report
|
from core.handle.ttsReportHandle import enqueue_tts_report
|
||||||
from core.providers.tts.base import audio_to_opus_data
|
from core.utils.util import audio_to_data
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ async def handleAudioMessage(conn, audio):
|
|||||||
if not conn.asr_server_receive:
|
if not conn.asr_server_receive:
|
||||||
conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
|
conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
|
||||||
return
|
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)
|
have_voice = conn.vad.is_vad(conn, audio)
|
||||||
else:
|
else:
|
||||||
have_voice = conn.client_have_voice
|
have_voice = conn.client_have_voice
|
||||||
@@ -76,7 +76,7 @@ async def startToChat(conn, text):
|
|||||||
|
|
||||||
# 意图未被处理,继续常规聊天流程
|
# 意图未被处理,继续常规聊天流程
|
||||||
await send_stt_message(conn, text)
|
await send_stt_message(conn, text)
|
||||||
if conn.use_function_call_mode:
|
if conn.intent_type == "function_call":
|
||||||
# 使用支持function calling的聊天方法
|
# 使用支持function calling的聊天方法
|
||||||
conn.executor.submit(conn.chat_with_function_calling, text)
|
conn.executor.submit(conn.chat_with_function_calling, text)
|
||||||
else:
|
else:
|
||||||
@@ -111,7 +111,7 @@ async def max_out_size(conn):
|
|||||||
conn.tts_last_text_index = 0
|
conn.tts_last_text_index = 0
|
||||||
conn.llm_finish_task = True
|
conn.llm_finish_task = True
|
||||||
file_path = "config/assets/max_output_size.wav"
|
file_path = "config/assets/max_output_size.wav"
|
||||||
opus_packets, _ = audio_to_opus_data(file_path)
|
opus_packets, _ = audio_to_data(file_path)
|
||||||
conn.audio_play_queue.put((opus_packets, text, 0))
|
conn.audio_play_queue.put((opus_packets, text, 0))
|
||||||
conn.close_after_chat = True
|
conn.close_after_chat = True
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ async def check_bind_device(conn):
|
|||||||
|
|
||||||
# 播放提示音
|
# 播放提示音
|
||||||
music_path = "config/assets/bind_code.wav"
|
music_path = "config/assets/bind_code.wav"
|
||||||
opus_packets, _ = audio_to_opus_data(music_path)
|
opus_packets, _ = audio_to_data(music_path)
|
||||||
conn.audio_play_queue.put((opus_packets, text, 0))
|
conn.audio_play_queue.put((opus_packets, text, 0))
|
||||||
|
|
||||||
# 逐个播放数字
|
# 逐个播放数字
|
||||||
@@ -141,7 +141,7 @@ async def check_bind_device(conn):
|
|||||||
try:
|
try:
|
||||||
digit = conn.bind_code[i]
|
digit = conn.bind_code[i]
|
||||||
num_path = f"config/assets/bind_code/{digit}.wav"
|
num_path = f"config/assets/bind_code/{digit}.wav"
|
||||||
num_packets, _ = audio_to_opus_data(num_path)
|
num_packets, _ = audio_to_data(num_path)
|
||||||
conn.audio_play_queue.put((num_packets, None, i + 1))
|
conn.audio_play_queue.put((num_packets, None, i + 1))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
||||||
@@ -153,5 +153,5 @@ async def check_bind_device(conn):
|
|||||||
conn.tts_last_text_index = 0
|
conn.tts_last_text_index = 0
|
||||||
conn.llm_finish_task = True
|
conn.llm_finish_task = True
|
||||||
music_path = "config/assets/bind_not_found.wav"
|
music_path = "config/assets/bind_not_found.wav"
|
||||||
opus_packets, _ = audio_to_opus_data(music_path)
|
opus_packets, _ = audio_to_data(music_path)
|
||||||
conn.audio_play_queue.put((opus_packets, text, 0))
|
conn.audio_play_queue.put((opus_packets, text, 0))
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ async def send_tts_message(conn, state, text=None):
|
|||||||
stop_tts_notify_voice = conn.config.get(
|
stop_tts_notify_voice = conn.config.get(
|
||||||
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
|
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
|
||||||
)
|
)
|
||||||
audios, duration = conn.tts.audio_to_opus_data(stop_tts_notify_voice)
|
audios, _ = conn.tts.audio_to_opus_data(stop_tts_notify_voice)
|
||||||
await sendAudio(conn, audios)
|
await sendAudio(conn, audios)
|
||||||
# 清除服务端讲话状态
|
# 清除服务端讲话状态
|
||||||
conn.clearSpeakStatus()
|
conn.clearSpeakStatus()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async def handleTextMessage(conn, message):
|
|||||||
await conn.websocket.send(message)
|
await conn.websocket.send(message)
|
||||||
return
|
return
|
||||||
if msg_json["type"] == "hello":
|
if msg_json["type"] == "hello":
|
||||||
await handleHelloMessage(conn)
|
await handleHelloMessage(conn, msg_json)
|
||||||
elif msg_json["type"] == "abort":
|
elif msg_json["type"] == "abort":
|
||||||
await handleAbortMessage(conn)
|
await handleAbortMessage(conn)
|
||||||
elif msg_json["type"] == "listen":
|
elif msg_json["type"] == "listen":
|
||||||
@@ -136,5 +136,8 @@ async def handleTextMessage(conn, message):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# 重启服务器
|
||||||
|
elif msg_json["action"] == "restart":
|
||||||
|
await conn.handle_restart(msg_json)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
await conn.websocket.send(message)
|
await conn.websocket.send(message)
|
||||||
|
|||||||
@@ -20,64 +20,78 @@ from core.providers.asr.base import ASRProviderBase
|
|||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
class AccessToken:
|
class AccessToken:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _encode_text(text):
|
def _encode_text(text):
|
||||||
encoded_text = parse.quote_plus(text)
|
encoded_text = parse.quote_plus(text)
|
||||||
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
|
return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _encode_dict(dic):
|
def _encode_dict(dic):
|
||||||
keys = dic.keys()
|
keys = dic.keys()
|
||||||
dic_sorted = [(key, dic[key]) for key in sorted(keys)]
|
dic_sorted = [(key, dic[key]) for key in sorted(keys)]
|
||||||
encoded_text = parse.urlencode(dic_sorted)
|
encoded_text = parse.urlencode(dic_sorted)
|
||||||
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
|
return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_token(access_key_id, access_key_secret):
|
def create_token(access_key_id, access_key_secret):
|
||||||
parameters = {'AccessKeyId': access_key_id,
|
parameters = {
|
||||||
'Action': 'CreateToken',
|
"AccessKeyId": access_key_id,
|
||||||
'Format': 'JSON',
|
"Action": "CreateToken",
|
||||||
'RegionId': 'cn-shanghai',
|
"Format": "JSON",
|
||||||
'SignatureMethod': 'HMAC-SHA1',
|
"RegionId": "cn-shanghai",
|
||||||
'SignatureNonce': str(uuid.uuid1()),
|
"SignatureMethod": "HMAC-SHA1",
|
||||||
'SignatureVersion': '1.0',
|
"SignatureNonce": str(uuid.uuid1()),
|
||||||
'Timestamp': time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"SignatureVersion": "1.0",
|
||||||
'Version': '2019-02-28'}
|
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"Version": "2019-02-28",
|
||||||
|
}
|
||||||
# 构造规范化的请求字符串
|
# 构造规范化的请求字符串
|
||||||
query_string = AccessToken._encode_dict(parameters)
|
query_string = AccessToken._encode_dict(parameters)
|
||||||
# print('规范化的请求字符串: %s' % query_string)
|
# print('规范化的请求字符串: %s' % query_string)
|
||||||
# 构造待签名字符串
|
# 构造待签名字符串
|
||||||
string_to_sign = 'GET' + '&' + AccessToken._encode_text('/') + '&' + AccessToken._encode_text(query_string)
|
string_to_sign = (
|
||||||
|
"GET"
|
||||||
|
+ "&"
|
||||||
|
+ AccessToken._encode_text("/")
|
||||||
|
+ "&"
|
||||||
|
+ AccessToken._encode_text(query_string)
|
||||||
|
)
|
||||||
# print('待签名的字符串: %s' % string_to_sign)
|
# print('待签名的字符串: %s' % string_to_sign)
|
||||||
# 计算签名
|
# 计算签名
|
||||||
secreted_string = hmac.new(bytes(access_key_secret + '&', encoding='utf-8'),
|
secreted_string = hmac.new(
|
||||||
bytes(string_to_sign, encoding='utf-8'),
|
bytes(access_key_secret + "&", encoding="utf-8"),
|
||||||
hashlib.sha1).digest()
|
bytes(string_to_sign, encoding="utf-8"),
|
||||||
|
hashlib.sha1,
|
||||||
|
).digest()
|
||||||
signature = base64.b64encode(secreted_string)
|
signature = base64.b64encode(secreted_string)
|
||||||
# print('签名: %s' % signature)
|
# print('签名: %s' % signature)
|
||||||
# 进行URL编码
|
# 进行URL编码
|
||||||
signature = AccessToken._encode_text(signature)
|
signature = AccessToken._encode_text(signature)
|
||||||
# print('URL编码后的签名: %s' % signature)
|
# print('URL编码后的签名: %s' % signature)
|
||||||
# 调用服务
|
# 调用服务
|
||||||
full_url = 'http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s' % (signature, query_string)
|
full_url = "http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s" % (
|
||||||
|
signature,
|
||||||
|
query_string,
|
||||||
|
)
|
||||||
# print('url: %s' % full_url)
|
# print('url: %s' % full_url)
|
||||||
# 提交HTTP GET请求
|
# 提交HTTP GET请求
|
||||||
response = requests.get(full_url)
|
response = requests.get(full_url)
|
||||||
if response.ok:
|
if response.ok:
|
||||||
root_obj = response.json()
|
root_obj = response.json()
|
||||||
key = 'Token'
|
key = "Token"
|
||||||
if key in root_obj:
|
if key in root_obj:
|
||||||
token = root_obj[key]['Id']
|
token = root_obj[key]["Id"]
|
||||||
expire_time = root_obj[key]['ExpireTime']
|
expire_time = root_obj[key]["ExpireTime"]
|
||||||
return token, expire_time
|
return token, expire_time
|
||||||
# print(response.text)
|
# print(response.text)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ASRProvider(ASRProviderBase):
|
class ASRProvider(ASRProviderBase):
|
||||||
def __init__(self, config: dict, delete_audio_file: bool):
|
def __init__(self, config: dict, delete_audio_file: bool):
|
||||||
|
super().__init__()
|
||||||
"""阿里云ASR初始化"""
|
"""阿里云ASR初始化"""
|
||||||
# 新增空值判断逻辑
|
# 新增空值判断逻辑
|
||||||
self.access_key_id = config.get("access_key_id")
|
self.access_key_id = config.get("access_key_id")
|
||||||
@@ -102,28 +116,23 @@ class ASRProvider(ASRProviderBase):
|
|||||||
# 确保输出目录存在
|
# 确保输出目录存在
|
||||||
os.makedirs(self.output_dir, exist_ok=True)
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _refresh_token(self):
|
def _refresh_token(self):
|
||||||
"""刷新Token并记录过期时间"""
|
"""刷新Token并记录过期时间"""
|
||||||
if self.access_key_id and self.access_key_secret:
|
if self.access_key_id and self.access_key_secret:
|
||||||
self.token, expire_time_str = AccessToken.create_token(
|
self.token, expire_time_str = AccessToken.create_token(
|
||||||
self.access_key_id,
|
self.access_key_id, self.access_key_secret
|
||||||
self.access_key_secret
|
|
||||||
)
|
)
|
||||||
if not expire_time_str:
|
if not expire_time_str:
|
||||||
raise ValueError("无法获取有效的Token过期时间")
|
raise ValueError("无法获取有效的Token过期时间")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
#统一转换为字符串处理
|
# 统一转换为字符串处理
|
||||||
expire_str = str(expire_time_str).strip()
|
expire_str = str(expire_time_str).strip()
|
||||||
|
|
||||||
if expire_str.isdigit():
|
if expire_str.isdigit():
|
||||||
expire_time = datetime.fromtimestamp(int(expire_str))
|
expire_time = datetime.fromtimestamp(int(expire_str))
|
||||||
else:
|
else:
|
||||||
expire_time = datetime.strptime(
|
expire_time = datetime.strptime(expire_str, "%Y-%m-%dT%H:%M:%SZ")
|
||||||
expire_str,
|
|
||||||
"%Y-%m-%dT%H:%M:%SZ"
|
|
||||||
)
|
|
||||||
self.expire_time = expire_time.timestamp() - 60
|
self.expire_time = expire_time.timestamp() - 60
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"无效的过期时间格式: {expire_str}") from e
|
raise ValueError(f"无效的过期时间格式: {expire_str}") from e
|
||||||
@@ -145,9 +154,12 @@ class ASRProvider(ASRProviderBase):
|
|||||||
# f"过期时间 {datetime.fromtimestamp(self.expire_time)} | "
|
# f"过期时间 {datetime.fromtimestamp(self.expire_time)} | "
|
||||||
# f"剩余 {remaining:.2f}秒")
|
# f"剩余 {remaining:.2f}秒")
|
||||||
return time.time() > self.expire_time
|
return time.time() > self.expire_time
|
||||||
def generate_filename(self, extension=".wav"):
|
|
||||||
return os.path.join(self.output_file, f"tts-{__name__}{datetime.now().date()}@{uuid.uuid4().hex}{extension}")
|
|
||||||
|
|
||||||
|
def generate_filename(self, extension=".wav"):
|
||||||
|
return os.path.join(
|
||||||
|
self.output_file,
|
||||||
|
f"tts-{__name__}{datetime.now().date()}@{uuid.uuid4().hex}{extension}",
|
||||||
|
)
|
||||||
|
|
||||||
def _construct_request_url(self) -> str:
|
def _construct_request_url(self) -> str:
|
||||||
"""构造请求URL,包含参数"""
|
"""构造请求URL,包含参数"""
|
||||||
@@ -159,20 +171,6 @@ class ASRProvider(ASRProviderBase):
|
|||||||
request += "&enable_voice_detection=false"
|
request += "&enable_voice_detection=false"
|
||||||
return request
|
return request
|
||||||
|
|
||||||
def decode_opus(self, opus_data: List[bytes], session_id: str) -> List[bytes]:
|
|
||||||
"""将Opus数据解码为PCM"""
|
|
||||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
|
||||||
pcm_data = []
|
|
||||||
|
|
||||||
for opus_packet in opus_data:
|
|
||||||
try:
|
|
||||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
|
||||||
pcm_data.append(pcm_frame)
|
|
||||||
except opuslib_next.OpusError as e:
|
|
||||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return pcm_data
|
|
||||||
|
|
||||||
def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
|
def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
|
||||||
"""PCM数据保存为WAV文件"""
|
"""PCM数据保存为WAV文件"""
|
||||||
module_name = __name__.split(".")[-1]
|
module_name = __name__.split(".")[-1]
|
||||||
@@ -183,7 +181,7 @@ class ASRProvider(ASRProviderBase):
|
|||||||
wf.setnchannels(1) # 单声道
|
wf.setnchannels(1) # 单声道
|
||||||
wf.setsampwidth(2) # 16-bit
|
wf.setsampwidth(2) # 16-bit
|
||||||
wf.setframerate(self.sample_rate)
|
wf.setframerate(self.sample_rate)
|
||||||
wf.writeframes(b''.join(pcm_data))
|
wf.writeframes(b"".join(pcm_data))
|
||||||
|
|
||||||
logger.bind(tag=TAG).debug(f"音频文件已保存至: {file_path}")
|
logger.bind(tag=TAG).debug(f"音频文件已保存至: {file_path}")
|
||||||
return file_path
|
return file_path
|
||||||
@@ -193,9 +191,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
try:
|
try:
|
||||||
# 设置HTTP头
|
# 设置HTTP头
|
||||||
headers = {
|
headers = {
|
||||||
'X-NLS-Token': self.token,
|
"X-NLS-Token": self.token,
|
||||||
'Content-type': 'application/octet-stream',
|
"Content-type": "application/octet-stream",
|
||||||
'Content-Length': str(len(pcm_data))
|
"Content-Length": str(len(pcm_data)),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 创建连接并发送请求
|
# 创建连接并发送请求
|
||||||
@@ -203,12 +201,12 @@ class ASRProvider(ASRProviderBase):
|
|||||||
request_url = self._construct_request_url()
|
request_url = self._construct_request_url()
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, lambda: conn.request(
|
await loop.run_in_executor(
|
||||||
method='POST',
|
None,
|
||||||
url=request_url,
|
lambda: conn.request(
|
||||||
body=pcm_data,
|
method="POST", url=request_url, body=pcm_data, headers=headers
|
||||||
headers=headers
|
),
|
||||||
))
|
)
|
||||||
|
|
||||||
# 获取响应
|
# 获取响应
|
||||||
response = await loop.run_in_executor(None, conn.getresponse)
|
response = await loop.run_in_executor(None, conn.getresponse)
|
||||||
@@ -218,10 +216,10 @@ class ASRProvider(ASRProviderBase):
|
|||||||
# 解析响应
|
# 解析响应
|
||||||
try:
|
try:
|
||||||
body_json = json.loads(body)
|
body_json = json.loads(body)
|
||||||
status = body_json.get('status')
|
status = body_json.get("status")
|
||||||
|
|
||||||
if status == 20000000:
|
if status == 20000000:
|
||||||
result = body_json.get('result', '')
|
result = body_json.get("result", "")
|
||||||
logger.bind(tag=TAG).debug(f"ASR结果: {result}")
|
logger.bind(tag=TAG).debug(f"ASR结果: {result}")
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
@@ -236,7 +234,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
logger.bind(tag=TAG).error(f"ASR请求失败: {e}", exc_info=True)
|
logger.bind(tag=TAG).error(f"ASR请求失败: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
async def speech_to_text(
|
||||||
|
self, opus_data: List[bytes], session_id: str
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""将语音数据转换为文本"""
|
"""将语音数据转换为文本"""
|
||||||
if self._is_token_expired():
|
if self._is_token_expired():
|
||||||
logger.warning("Token已过期,正在自动刷新...")
|
logger.warning("Token已过期,正在自动刷新...")
|
||||||
@@ -245,8 +245,11 @@ class ASRProvider(ASRProviderBase):
|
|||||||
file_path = None
|
file_path = None
|
||||||
try:
|
try:
|
||||||
# 解码Opus为PCM
|
# 解码Opus为PCM
|
||||||
pcm_data = self.decode_opus(opus_data, session_id)
|
if self.audio_format == "pcm":
|
||||||
combined_pcm_data = b''.join(pcm_data)
|
pcm_data = opus_data
|
||||||
|
else:
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
|
combined_pcm_data = b"".join(pcm_data)
|
||||||
|
|
||||||
# 判断是否保存为WAV文件
|
# 判断是否保存为WAV文件
|
||||||
if self.delete_audio_file:
|
if self.delete_audio_file:
|
||||||
@@ -264,4 +267,4 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
|
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
|
||||||
return "", file_path
|
return "", file_path
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ logger = setup_logging()
|
|||||||
|
|
||||||
class ASRProvider(ASRProviderBase):
|
class ASRProvider(ASRProviderBase):
|
||||||
def __init__(self, config: dict, delete_audio_file: bool = True):
|
def __init__(self, config: dict, delete_audio_file: bool = True):
|
||||||
|
super().__init__()
|
||||||
self.app_id = config.get("app_id")
|
self.app_id = config.get("app_id")
|
||||||
self.api_key = config.get("api_key")
|
self.api_key = config.get("api_key")
|
||||||
self.secret_key = config.get("secret_key")
|
self.secret_key = config.get("secret_key")
|
||||||
@@ -49,22 +50,6 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def decode_opus(opus_data: List[bytes]) -> bytes:
|
|
||||||
"""将Opus音频数据解码为PCM数据"""
|
|
||||||
|
|
||||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
|
||||||
pcm_data = []
|
|
||||||
|
|
||||||
for opus_packet in opus_data:
|
|
||||||
try:
|
|
||||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
|
||||||
pcm_data.append(pcm_frame)
|
|
||||||
except opuslib_next.OpusError as e:
|
|
||||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return pcm_data
|
|
||||||
|
|
||||||
async def speech_to_text(
|
async def speech_to_text(
|
||||||
self, opus_data: List[bytes], session_id: str
|
self, opus_data: List[bytes], session_id: str
|
||||||
) -> Tuple[Optional[str], Optional[str]]:
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
@@ -81,7 +66,10 @@ class ASRProvider(ASRProviderBase):
|
|||||||
return None, file_path
|
return None, file_path
|
||||||
|
|
||||||
# 将Opus音频数据解码为PCM
|
# 将Opus音频数据解码为PCM
|
||||||
pcm_data = self.decode_opus(opus_data)
|
if self.audio_format == "pcm":
|
||||||
|
pcm_data = opus_data
|
||||||
|
else:
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
combined_pcm_data = b"".join(pcm_data)
|
combined_pcm_data = b"".join(pcm_data)
|
||||||
|
|
||||||
# 判断是否保存为WAV文件
|
# 判断是否保存为WAV文件
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, Tuple, List
|
from typing import Optional, Tuple, List
|
||||||
|
import opuslib_next
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
@@ -8,12 +8,37 @@ logger = setup_logging()
|
|||||||
|
|
||||||
|
|
||||||
class ASRProviderBase(ABC):
|
class ASRProviderBase(ABC):
|
||||||
|
def __init__(self):
|
||||||
|
self.audio_format = "opus"
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
|
def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
|
||||||
"""PCM数据保存为WAV文件"""
|
"""PCM数据保存为WAV文件"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
async def speech_to_text(
|
||||||
|
self, opus_data: List[bytes], session_id: str
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""将语音数据转换为文本"""
|
"""将语音数据转换为文本"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def set_audio_format(self, format: str) -> None:
|
||||||
|
"""设置音频格式"""
|
||||||
|
self.audio_format = format
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_opus(opus_data: List[bytes]) -> bytes:
|
||||||
|
"""将Opus音频数据解码为PCM数据"""
|
||||||
|
|
||||||
|
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
||||||
|
pcm_data = []
|
||||||
|
|
||||||
|
for opus_packet in opus_data:
|
||||||
|
try:
|
||||||
|
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
||||||
|
pcm_data.append(pcm_frame)
|
||||||
|
except opuslib_next.OpusError as e:
|
||||||
|
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return pcm_data
|
||||||
|
|||||||
@@ -85,11 +85,12 @@ def parse_response(res):
|
|||||||
|
|
||||||
class ASRProvider(ASRProviderBase):
|
class ASRProvider(ASRProviderBase):
|
||||||
def __init__(self, config: dict, delete_audio_file: bool):
|
def __init__(self, config: dict, delete_audio_file: bool):
|
||||||
|
super().__init__()
|
||||||
self.appid = config.get("appid")
|
self.appid = config.get("appid")
|
||||||
self.cluster = config.get("cluster")
|
self.cluster = config.get("cluster")
|
||||||
self.access_token = config.get("access_token")
|
self.access_token = config.get("access_token")
|
||||||
self.boosting_table_name = config.get("boosting_table_name")
|
self.boosting_table_name = config.get("boosting_table_name", "")
|
||||||
self.correct_table_name = config.get("correct_table_name")
|
self.correct_table_name = config.get("correct_table_name", "")
|
||||||
self.output_dir = config.get("output_dir")
|
self.output_dir = config.get("output_dir")
|
||||||
self.delete_audio_file = delete_audio_file
|
self.delete_audio_file = delete_audio_file
|
||||||
|
|
||||||
@@ -226,21 +227,6 @@ class ASRProvider(ASRProviderBase):
|
|||||||
logger.bind(tag=TAG).error(f"ASR request failed: {e}", exc_info=True)
|
logger.bind(tag=TAG).error(f"ASR request failed: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]:
|
|
||||||
|
|
||||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
|
||||||
pcm_data = []
|
|
||||||
|
|
||||||
for opus_packet in opus_data:
|
|
||||||
try:
|
|
||||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
|
||||||
pcm_data.append(pcm_frame)
|
|
||||||
except opuslib_next.OpusError as e:
|
|
||||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return pcm_data
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def slice_data(data: bytes, chunk_size: int) -> (list, bool):
|
def slice_data(data: bytes, chunk_size: int) -> (list, bool):
|
||||||
"""
|
"""
|
||||||
@@ -265,7 +251,10 @@ class ASRProvider(ASRProviderBase):
|
|||||||
file_path = None
|
file_path = None
|
||||||
try:
|
try:
|
||||||
# 合并所有opus数据包
|
# 合并所有opus数据包
|
||||||
pcm_data = self.decode_opus(opus_data, session_id)
|
if self.audio_format == "pcm":
|
||||||
|
pcm_data = opus_data
|
||||||
|
else:
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
combined_pcm_data = b"".join(pcm_data)
|
combined_pcm_data = b"".join(pcm_data)
|
||||||
|
|
||||||
# 判断是否保存为WAV文件
|
# 判断是否保存为WAV文件
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import io
|
|||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from typing import Optional, Tuple, List
|
from typing import Optional, Tuple, List
|
||||||
import uuid
|
import uuid
|
||||||
import opuslib_next
|
|
||||||
from core.providers.asr.base import ASRProviderBase
|
from core.providers.asr.base import ASRProviderBase
|
||||||
|
|
||||||
from funasr import AutoModel
|
from funasr import AutoModel
|
||||||
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
||||||
|
|
||||||
@@ -35,6 +33,7 @@ class CaptureOutput:
|
|||||||
|
|
||||||
class ASRProvider(ASRProviderBase):
|
class ASRProvider(ASRProviderBase):
|
||||||
def __init__(self, config: dict, delete_audio_file: bool):
|
def __init__(self, config: dict, delete_audio_file: bool):
|
||||||
|
super().__init__()
|
||||||
self.model_dir = config.get("model_dir")
|
self.model_dir = config.get("model_dir")
|
||||||
self.output_dir = config.get("output_dir") # 修正配置键名
|
self.output_dir = config.get("output_dir") # 修正配置键名
|
||||||
self.delete_audio_file = delete_audio_file
|
self.delete_audio_file = delete_audio_file
|
||||||
@@ -46,7 +45,7 @@ class ASRProvider(ASRProviderBase):
|
|||||||
model=self.model_dir,
|
model=self.model_dir,
|
||||||
vad_kwargs={"max_single_segment_time": 30000},
|
vad_kwargs={"max_single_segment_time": 30000},
|
||||||
disable_update=True,
|
disable_update=True,
|
||||||
hub="hf"
|
hub="hf",
|
||||||
# device="cuda:0", # 启用GPU加速
|
# device="cuda:0", # 启用GPU加速
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,27 +63,18 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
@staticmethod
|
async def speech_to_text(
|
||||||
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]:
|
self, opus_data: List[bytes], session_id: str
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
|
||||||
pcm_data = []
|
|
||||||
|
|
||||||
for opus_packet in opus_data:
|
|
||||||
try:
|
|
||||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
|
||||||
pcm_data.append(pcm_frame)
|
|
||||||
except opuslib_next.OpusError as e:
|
|
||||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return pcm_data
|
|
||||||
|
|
||||||
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
|
||||||
"""语音转文本主处理逻辑"""
|
"""语音转文本主处理逻辑"""
|
||||||
file_path = None
|
file_path = None
|
||||||
try:
|
try:
|
||||||
# 合并所有opus数据包
|
# 合并所有opus数据包
|
||||||
pcm_data = self.decode_opus(opus_data, session_id)
|
if self.audio_format == "pcm":
|
||||||
|
pcm_data = opus_data
|
||||||
|
else:
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
|
|
||||||
combined_pcm_data = b"".join(pcm_data)
|
combined_pcm_data = b"".join(pcm_data)
|
||||||
|
|
||||||
# 判断是否保存为WAV文件
|
# 判断是否保存为WAV文件
|
||||||
@@ -103,7 +93,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
batch_size_s=60,
|
batch_size_s=60,
|
||||||
)
|
)
|
||||||
text = rich_transcription_postprocess(result[0]["text"])
|
text = rich_transcription_postprocess(result[0]["text"])
|
||||||
logger.bind(tag=TAG).debug(f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}")
|
logger.bind(tag=TAG).debug(
|
||||||
|
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
|
||||||
|
)
|
||||||
|
|
||||||
return text, file_path
|
return text, file_path
|
||||||
|
|
||||||
|
|||||||
@@ -9,22 +9,29 @@ import wave
|
|||||||
import websockets
|
import websockets
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
class ASRProvider(ASRProviderBase):
|
class ASRProvider(ASRProviderBase):
|
||||||
def __init__(self, config: dict, delete_audio_file: bool):
|
def __init__(self, config: dict, delete_audio_file: bool):
|
||||||
'''
|
"""
|
||||||
Initialize the ASRProvider with server configuration.
|
Initialize the ASRProvider with server configuration.
|
||||||
:param config: Dictionary containing 'host', 'port', and 'is_ssl'.
|
:param config: Dictionary containing 'host', 'port', and 'is_ssl'.
|
||||||
:param delete_audio_file: Boolean to indicate whether to delete audio files after processing.
|
:param delete_audio_file: Boolean to indicate whether to delete audio files after processing.
|
||||||
'''
|
"""
|
||||||
self.host = config.get('host', 'localhost')
|
super().__init__()
|
||||||
self.port = config.get('port', 10095)
|
self.host = config.get("host", "localhost")
|
||||||
self.is_ssl = config.get('is_ssl', True)
|
self.port = config.get("port", 10095)
|
||||||
|
self.is_ssl = config.get("is_ssl", True)
|
||||||
self.output_dir = config.get("output_dir")
|
self.output_dir = config.get("output_dir")
|
||||||
self.delete_audio_file = delete_audio_file
|
self.delete_audio_file = delete_audio_file
|
||||||
self.uri = f"wss://{self.host}:{self.port}" if self.is_ssl else f"ws://{self.host}:{self.port}"
|
self.uri = (
|
||||||
|
f"wss://{self.host}:{self.port}"
|
||||||
|
if self.is_ssl
|
||||||
|
else f"ws://{self.host}:{self.port}"
|
||||||
|
)
|
||||||
self.ssl_context = ssl.SSLContext() if self.is_ssl else None
|
self.ssl_context = ssl.SSLContext() if self.is_ssl else None
|
||||||
if self.ssl_context:
|
if self.ssl_context:
|
||||||
self.ssl_context.check_hostname = False
|
self.ssl_context.check_hostname = False
|
||||||
@@ -44,28 +51,11 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def decode_opus(opus_data: List[bytes]) -> bytes:
|
|
||||||
"""将Opus音频数据解码为PCM数据"""
|
|
||||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
|
||||||
pcm_data = []
|
|
||||||
|
|
||||||
for opus_packet in opus_data:
|
|
||||||
try:
|
|
||||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
|
||||||
pcm_data.append(pcm_frame)
|
|
||||||
except opuslib_next.OpusError as e:
|
|
||||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return pcm_data
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _receive_responses(self, ws) -> None:
|
async def _receive_responses(self, ws) -> None:
|
||||||
'''
|
"""
|
||||||
Asynchronous generator to receive messages from the WebSocket.
|
Asynchronous generator to receive messages from the WebSocket.
|
||||||
Yields each message as it is received.
|
Yields each message as it is received.
|
||||||
'''
|
"""
|
||||||
text = ""
|
text = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -78,30 +68,35 @@ class ASRProvider(ASRProviderBase):
|
|||||||
else:
|
else:
|
||||||
text += response_data.get("text", "")
|
text += response_data.get("text", "")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.bind(tag=TAG).error("Timeout while waiting for response from WebSocket.")
|
logger.bind(tag=TAG).error(
|
||||||
|
"Timeout while waiting for response from WebSocket."
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except websockets.exceptions.ConnectionClosed as e:
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
|
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
|
||||||
break
|
break
|
||||||
return text
|
return text
|
||||||
|
|
||||||
async def _send_data(self, ws, pcm_data: bytes, session_id: str) -> tuple:
|
async def _send_data(self, ws, pcm_data: bytes, session_id: str) -> tuple:
|
||||||
'''
|
"""
|
||||||
Internal method to handle WebSocket communication.
|
Internal method to handle WebSocket communication.
|
||||||
Reuses the persistent WebSocket connection if available.
|
Reuses the persistent WebSocket connection if available.
|
||||||
:param pcm_data: PCM audio data to send.
|
:param pcm_data: PCM audio data to send.
|
||||||
:param session_id: Unique session identifier.
|
:param session_id: Unique session identifier.
|
||||||
:return: Tuple containing recognized text and optional timestamp.
|
:return: Tuple containing recognized text and optional timestamp.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
# Send initial configuration message
|
# Send initial configuration message
|
||||||
config_message = json.dumps({
|
config_message = json.dumps(
|
||||||
"mode": "offline",
|
{
|
||||||
"chunk_size": [5, 10, 5],
|
"mode": "offline",
|
||||||
"chunk_interval": 10,
|
"chunk_size": [5, 10, 5],
|
||||||
"wav_name": session_id,
|
"chunk_interval": 10,
|
||||||
"is_speaking": True,
|
"wav_name": session_id,
|
||||||
"itn": False
|
"is_speaking": True,
|
||||||
})
|
"itn": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
await ws.send(config_message)
|
await ws.send(config_message)
|
||||||
logger.bind(tag=TAG).debug(f"Sent configuration message: {config_message}")
|
logger.bind(tag=TAG).debug(f"Sent configuration message: {config_message}")
|
||||||
|
|
||||||
@@ -114,16 +109,20 @@ class ASRProvider(ASRProviderBase):
|
|||||||
await ws.send(end_message)
|
await ws.send(end_message)
|
||||||
logger.bind(tag=TAG).debug(f"Sent end message: {end_message}")
|
logger.bind(tag=TAG).debug(f"Sent end message: {end_message}")
|
||||||
|
|
||||||
|
async def speech_to_text(
|
||||||
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
self, opus_data: List[bytes], session_id: str
|
||||||
'''
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
Convert speech data to text using FunASR.
|
Convert speech data to text using FunASR.
|
||||||
:param opus_data: List of Opus-encoded audio data chunks.
|
:param opus_data: List of Opus-encoded audio data chunks.
|
||||||
:param session_id: Unique session identifier.
|
:param session_id: Unique session identifier.
|
||||||
:return: Tuple containing recognized text and optional timestamp.
|
:return: Tuple containing recognized text and optional timestamp.
|
||||||
'''
|
"""
|
||||||
file_path = None
|
file_path = None
|
||||||
pcm_data = self.decode_opus(opus_data)
|
if self.audio_format == "pcm":
|
||||||
|
pcm_data = opus_data
|
||||||
|
else:
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
combined_pcm_data = b"".join(pcm_data)
|
combined_pcm_data = b"".join(pcm_data)
|
||||||
|
|
||||||
# 判断是否保存为WAV文件
|
# 判断是否保存为WAV文件
|
||||||
@@ -132,16 +131,19 @@ class ASRProvider(ASRProviderBase):
|
|||||||
else:
|
else:
|
||||||
file_path = self.save_audio_to_file(pcm_data, session_id)
|
file_path = self.save_audio_to_file(pcm_data, session_id)
|
||||||
|
|
||||||
async with websockets.connect(self.uri, subprotocols=["binary"], ping_interval=None, ssl=self.ssl_context) as ws:
|
async with websockets.connect(
|
||||||
|
self.uri, subprotocols=["binary"], ping_interval=None, ssl=self.ssl_context
|
||||||
|
) as ws:
|
||||||
try:
|
try:
|
||||||
# Use asyncio to handle WebSocket communication
|
# Use asyncio to handle WebSocket communication
|
||||||
send_task = asyncio.create_task(self._send_data(ws, combined_pcm_data, session_id))
|
send_task = asyncio.create_task(
|
||||||
|
self._send_data(ws, combined_pcm_data, session_id)
|
||||||
|
)
|
||||||
receive_task = asyncio.create_task(self._receive_responses(ws))
|
receive_task = asyncio.create_task(self._receive_responses(ws))
|
||||||
|
|
||||||
# Gather tasks with error handling
|
# Gather tasks with error handling
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
[send_task, receive_task],
|
[send_task, receive_task], return_when=asyncio.FIRST_EXCEPTION
|
||||||
return_when=asyncio.FIRST_EXCEPTION
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cancel any pending tasks
|
# Cancel any pending tasks
|
||||||
@@ -155,11 +157,16 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
# Get the result from the receive task
|
# Get the result from the receive task
|
||||||
result = receive_task.result()
|
result = receive_task.result()
|
||||||
return result, file_path # Return the recognized text and timestamp (if any)
|
return (
|
||||||
|
result,
|
||||||
|
file_path,
|
||||||
|
) # Return the recognized text and timestamp (if any)
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosed as e:
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
|
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
|
||||||
return "", file_path
|
return "", file_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"Error during speech-to-text conversion: {e}", exc_info=True)
|
logger.bind(tag=TAG).error(
|
||||||
return "", file_path
|
f"Error during speech-to-text conversion: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
return "", file_path
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class CaptureOutput:
|
|||||||
|
|
||||||
class ASRProvider(ASRProviderBase):
|
class ASRProvider(ASRProviderBase):
|
||||||
def __init__(self, config: dict, delete_audio_file: bool):
|
def __init__(self, config: dict, delete_audio_file: bool):
|
||||||
|
super().__init__()
|
||||||
self.model_dir = config.get("model_dir")
|
self.model_dir = config.get("model_dir")
|
||||||
self.output_dir = config.get("output_dir")
|
self.output_dir = config.get("output_dir")
|
||||||
self.delete_audio_file = delete_audio_file
|
self.delete_audio_file = delete_audio_file
|
||||||
@@ -97,21 +98,6 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]:
|
|
||||||
|
|
||||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
|
||||||
pcm_data = []
|
|
||||||
|
|
||||||
for opus_packet in opus_data:
|
|
||||||
try:
|
|
||||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
|
||||||
pcm_data.append(pcm_frame)
|
|
||||||
except opuslib_next.OpusError as e:
|
|
||||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return pcm_data
|
|
||||||
|
|
||||||
def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]:
|
def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]:
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@@ -144,7 +130,10 @@ class ASRProvider(ASRProviderBase):
|
|||||||
try:
|
try:
|
||||||
# 保存音频文件
|
# 保存音频文件
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
pcm_data = self.decode_opus(opus_data, session_id)
|
if self.audio_format == "pcm":
|
||||||
|
pcm_data = opus_data
|
||||||
|
else:
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
file_path = self.save_audio_to_file(pcm_data, session_id)
|
file_path = self.save_audio_to_file(pcm_data, session_id)
|
||||||
logger.bind(tag=TAG).debug(
|
logger.bind(tag=TAG).debug(
|
||||||
f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}"
|
f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}"
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ from config.logger import setup_logging
|
|||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
class ASRProvider(ASRProviderBase):
|
class ASRProvider(ASRProviderBase):
|
||||||
API_URL = "https://asr.tencentcloudapi.com"
|
API_URL = "https://asr.tencentcloudapi.com"
|
||||||
API_VERSION = "2019-06-14"
|
API_VERSION = "2019-06-14"
|
||||||
FORMAT = "pcm" # 支持的音频格式:pcm, wav, mp3
|
FORMAT = "pcm" # 支持的音频格式:pcm, wav, mp3
|
||||||
|
|
||||||
def __init__(self, config: dict, delete_audio_file: bool = True):
|
def __init__(self, config: dict, delete_audio_file: bool = True):
|
||||||
|
super().__init__()
|
||||||
self.secret_id = config.get("secret_id")
|
self.secret_id = config.get("secret_id")
|
||||||
self.secret_key = config.get("secret_key")
|
self.secret_key = config.get("secret_key")
|
||||||
self.output_dir = config.get("output_dir")
|
self.output_dir = config.get("output_dir")
|
||||||
@@ -45,23 +47,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
@staticmethod
|
async def speech_to_text(
|
||||||
def decode_opus(opus_data: List[bytes]) -> bytes:
|
self, opus_data: List[bytes], session_id: str
|
||||||
"""将Opus音频数据解码为PCM数据"""
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
|
||||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
|
||||||
pcm_data = []
|
|
||||||
|
|
||||||
for opus_packet in opus_data:
|
|
||||||
try:
|
|
||||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
|
||||||
pcm_data.append(pcm_frame)
|
|
||||||
except opuslib_next.OpusError as e:
|
|
||||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return pcm_data
|
|
||||||
|
|
||||||
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
|
||||||
"""将语音数据转换为文本"""
|
"""将语音数据转换为文本"""
|
||||||
if not opus_data:
|
if not opus_data:
|
||||||
logger.bind(tag=TAG).warn("音频数据为空!")
|
logger.bind(tag=TAG).warn("音频数据为空!")
|
||||||
@@ -75,7 +63,10 @@ class ASRProvider(ASRProviderBase):
|
|||||||
return None, file_path
|
return None, file_path
|
||||||
|
|
||||||
# 将Opus音频数据解码为PCM
|
# 将Opus音频数据解码为PCM
|
||||||
pcm_data = self.decode_opus(opus_data)
|
if self.audio_format == "pcm":
|
||||||
|
pcm_data = opus_data
|
||||||
|
else:
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
combined_pcm_data = b"".join(pcm_data)
|
combined_pcm_data = b"".join(pcm_data)
|
||||||
|
|
||||||
# 判断是否保存为WAV文件
|
# 判断是否保存为WAV文件
|
||||||
@@ -85,7 +76,7 @@ class ASRProvider(ASRProviderBase):
|
|||||||
self.save_audio_to_file(pcm_data, session_id)
|
self.save_audio_to_file(pcm_data, session_id)
|
||||||
|
|
||||||
# 将音频数据转换为Base64编码
|
# 将音频数据转换为Base64编码
|
||||||
base64_audio = base64.b64encode(combined_pcm_data).decode('utf-8')
|
base64_audio = base64.b64encode(combined_pcm_data).decode("utf-8")
|
||||||
|
|
||||||
# 构建请求体
|
# 构建请求体
|
||||||
request_body = self._build_request_body(base64_audio)
|
request_body = self._build_request_body(base64_audio)
|
||||||
@@ -98,7 +89,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
result = self._send_request(request_body, timestamp, authorization)
|
result = self._send_request(request_body, timestamp, authorization)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.bind(tag=TAG).debug(f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}")
|
logger.bind(tag=TAG).debug(
|
||||||
|
f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}"
|
||||||
|
)
|
||||||
|
|
||||||
return result, file_path
|
return result, file_path
|
||||||
|
|
||||||
@@ -115,7 +108,7 @@ class ASRProvider(ASRProviderBase):
|
|||||||
"SourceType": 1, # 音频数据来源为语音文件
|
"SourceType": 1, # 音频数据来源为语音文件
|
||||||
"VoiceFormat": self.FORMAT, # 音频格式
|
"VoiceFormat": self.FORMAT, # 音频格式
|
||||||
"Data": base64_audio, # Base64编码的音频数据
|
"Data": base64_audio, # Base64编码的音频数据
|
||||||
"DataLen": len(base64_audio) # 数据长度
|
"DataLen": len(base64_audio), # 数据长度
|
||||||
}
|
}
|
||||||
return json.dumps(request_map)
|
return json.dumps(request_map)
|
||||||
|
|
||||||
@@ -148,9 +141,11 @@ class ASRProvider(ASRProviderBase):
|
|||||||
action = "SentenceRecognition" # 接口名称
|
action = "SentenceRecognition" # 接口名称
|
||||||
|
|
||||||
# 构建规范头部信息,注意顺序和格式
|
# 构建规范头部信息,注意顺序和格式
|
||||||
canonical_headers = f"content-type:{content_type.lower()}\n" + \
|
canonical_headers = (
|
||||||
f"host:{host.lower()}\n" + \
|
f"content-type:{content_type.lower()}\n"
|
||||||
f"x-tc-action:{action.lower()}\n"
|
+ f"host:{host.lower()}\n"
|
||||||
|
+ f"x-tc-action:{action.lower()}\n"
|
||||||
|
)
|
||||||
|
|
||||||
signed_headers = "content-type;host;x-tc-action"
|
signed_headers = "content-type;host;x-tc-action"
|
||||||
|
|
||||||
@@ -158,21 +153,25 @@ class ASRProvider(ASRProviderBase):
|
|||||||
payload_hash = self._sha256_hex(request_body)
|
payload_hash = self._sha256_hex(request_body)
|
||||||
|
|
||||||
# 构建规范请求字符串
|
# 构建规范请求字符串
|
||||||
canonical_request = f"{http_request_method}\n" + \
|
canonical_request = (
|
||||||
f"{canonical_uri}\n" + \
|
f"{http_request_method}\n"
|
||||||
f"{canonical_query_string}\n" + \
|
+ f"{canonical_uri}\n"
|
||||||
f"{canonical_headers}\n" + \
|
+ f"{canonical_query_string}\n"
|
||||||
f"{signed_headers}\n" + \
|
+ f"{canonical_headers}\n"
|
||||||
f"{payload_hash}"
|
+ f"{signed_headers}\n"
|
||||||
|
+ f"{payload_hash}"
|
||||||
|
)
|
||||||
|
|
||||||
# 计算规范请求的哈希值
|
# 计算规范请求的哈希值
|
||||||
hashed_canonical_request = self._sha256_hex(canonical_request)
|
hashed_canonical_request = self._sha256_hex(canonical_request)
|
||||||
|
|
||||||
# 构建待签名字符串
|
# 构建待签名字符串
|
||||||
string_to_sign = f"{algorithm}\n" + \
|
string_to_sign = (
|
||||||
f"{timestamp}\n" + \
|
f"{algorithm}\n"
|
||||||
f"{credential_scope}\n" + \
|
+ f"{timestamp}\n"
|
||||||
f"{hashed_canonical_request}"
|
+ f"{credential_scope}\n"
|
||||||
|
+ f"{hashed_canonical_request}"
|
||||||
|
)
|
||||||
|
|
||||||
# 计算签名密钥
|
# 计算签名密钥
|
||||||
secret_date = self._hmac_sha256(f"TC3{self.secret_key}", date)
|
secret_date = self._hmac_sha256(f"TC3{self.secret_key}", date)
|
||||||
@@ -180,13 +179,17 @@ class ASRProvider(ASRProviderBase):
|
|||||||
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
||||||
|
|
||||||
# 计算签名
|
# 计算签名
|
||||||
signature = self._bytes_to_hex(self._hmac_sha256(secret_signing, string_to_sign))
|
signature = self._bytes_to_hex(
|
||||||
|
self._hmac_sha256(secret_signing, string_to_sign)
|
||||||
|
)
|
||||||
|
|
||||||
# 构建授权头
|
# 构建授权头
|
||||||
authorization = f"{algorithm} " + \
|
authorization = (
|
||||||
f"Credential={self.secret_id}/{credential_scope}, " + \
|
f"{algorithm} "
|
||||||
f"SignedHeaders={signed_headers}, " + \
|
+ f"Credential={self.secret_id}/{credential_scope}, "
|
||||||
f"Signature={signature}"
|
+ f"SignedHeaders={signed_headers}, "
|
||||||
|
+ f"Signature={signature}"
|
||||||
|
)
|
||||||
|
|
||||||
return timestamp, authorization
|
return timestamp, authorization
|
||||||
|
|
||||||
@@ -194,7 +197,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
logger.bind(tag=TAG).error(f"生成认证头失败: {e}", exc_info=True)
|
logger.bind(tag=TAG).error(f"生成认证头失败: {e}", exc_info=True)
|
||||||
raise RuntimeError(f"生成认证头失败: {e}")
|
raise RuntimeError(f"生成认证头失败: {e}")
|
||||||
|
|
||||||
def _send_request(self, request_body: str, timestamp: str, authorization: str) -> Optional[str]:
|
def _send_request(
|
||||||
|
self, request_body: str, timestamp: str, authorization: str
|
||||||
|
) -> Optional[str]:
|
||||||
"""发送请求到腾讯云API"""
|
"""发送请求到腾讯云API"""
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
@@ -203,7 +208,7 @@ class ASRProvider(ASRProviderBase):
|
|||||||
"X-TC-Action": "SentenceRecognition",
|
"X-TC-Action": "SentenceRecognition",
|
||||||
"X-TC-Version": self.API_VERSION,
|
"X-TC-Version": self.API_VERSION,
|
||||||
"X-TC-Timestamp": timestamp,
|
"X-TC-Timestamp": timestamp,
|
||||||
"X-TC-Region": "ap-shanghai"
|
"X-TC-Region": "ap-shanghai",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -234,16 +239,16 @@ class ASRProvider(ASRProviderBase):
|
|||||||
|
|
||||||
def _sha256_hex(self, data: str) -> str:
|
def _sha256_hex(self, data: str) -> str:
|
||||||
"""计算字符串的SHA256哈希值"""
|
"""计算字符串的SHA256哈希值"""
|
||||||
digest = hashlib.sha256(data.encode('utf-8')).digest()
|
digest = hashlib.sha256(data.encode("utf-8")).digest()
|
||||||
return self._bytes_to_hex(digest)
|
return self._bytes_to_hex(digest)
|
||||||
|
|
||||||
def _hmac_sha256(self, key, data: str) -> bytes:
|
def _hmac_sha256(self, key, data: str) -> bytes:
|
||||||
"""计算HMAC-SHA256"""
|
"""计算HMAC-SHA256"""
|
||||||
if isinstance(key, str):
|
if isinstance(key, str):
|
||||||
key = key.encode('utf-8')
|
key = key.encode("utf-8")
|
||||||
|
|
||||||
return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest()
|
return hmac.new(key, data.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
def _bytes_to_hex(self, bytes_data: bytes) -> str:
|
def _bytes_to_hex(self, bytes_data: bytes) -> str:
|
||||||
"""字节数组转十六进制字符串"""
|
"""字节数组转十六进制字符串"""
|
||||||
return ''.join(f"{b:02x}" for b in bytes_data)
|
return "".join(f"{b:02x}" for b in bytes_data)
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ logger = setup_logging()
|
|||||||
class IntentProviderBase(ABC):
|
class IntentProviderBase(ABC):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = 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):
|
def set_llm(self, llm):
|
||||||
self.llm = llm
|
self.llm = llm
|
||||||
|
|||||||
@@ -15,57 +15,72 @@ class IntentProvider(IntentProviderBase):
|
|||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.llm = None
|
self.llm = None
|
||||||
self.promot = self.get_intent_system_prompt()
|
self.promot = ""
|
||||||
# 添加缓存管理
|
# 添加缓存管理
|
||||||
self.intent_cache = {} # 缓存意图识别结果
|
self.intent_cache = {} # 缓存意图识别结果
|
||||||
self.cache_expiry = 600 # 缓存有效期10分钟
|
self.cache_expiry = 600 # 缓存有效期10分钟
|
||||||
self.cache_max_size = 100 # 最多缓存100个意图
|
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:
|
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 = (
|
prompt = (
|
||||||
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图属于以下哪一类:\n"
|
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图并调用相应的函数。\n\n"
|
||||||
"<start>"
|
f"{functions_desc}\n"
|
||||||
f"{str(self.intent_options)}"
|
"处理步骤:\n"
|
||||||
"<end>\n"
|
"1. 分析用户输入,确定用户意图\n"
|
||||||
"处理步骤:"
|
"2. 从可用函数列表中选择最匹配的函数\n"
|
||||||
"1. 思考意图类型,生成function_call格式"
|
"3. 如果找到匹配的函数,生成对应的function_call 格式\n"
|
||||||
"\n\n"
|
'4. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n\n'
|
||||||
"返回格式示例:\n"
|
"返回格式要求:\n"
|
||||||
'1. 播放音乐意图: {"function_call": {"name": "play_music", "arguments": {"song_name": "音乐名称"}}}\n'
|
"1. 必须返回纯JSON格式\n"
|
||||||
'2. 结束对话意图: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
|
"2. 必须包含function_call字段\n"
|
||||||
'3. 获取当天日期时间: {"function_call": {"name": "get_time"}}\n'
|
"3. function_call必须包含name字段\n"
|
||||||
'4. 继续聊天意图: {"function_call": {"name": "continue_chat"}}\n'
|
"4. 如果函数需要参数,必须包含arguments字段\n\n"
|
||||||
"\n"
|
"示例:\n"
|
||||||
"注意:\n"
|
|
||||||
'- 播放音乐:无歌名时,song_name设为"random"\n'
|
|
||||||
"- 如果没有明显的意图,应按照继续聊天意图处理\n"
|
|
||||||
"- 只返回纯JSON,不要任何其他内容\n"
|
|
||||||
"\n"
|
|
||||||
"示例分析:\n"
|
|
||||||
"```\n"
|
"```\n"
|
||||||
"用户: 你也太搞笑了\n"
|
"用户: 现在几点了?\n"
|
||||||
'返回: {"function_call": {"name": "continue_chat"}}\n'
|
|
||||||
"```\n"
|
|
||||||
"```\n"
|
|
||||||
"用户: 现在是几号了?现在几点了?\n"
|
|
||||||
'返回: {"function_call": {"name": "get_time"}}\n'
|
'返回: {"function_call": {"name": "get_time"}}\n'
|
||||||
"```\n"
|
"```\n"
|
||||||
"```\n"
|
"```\n"
|
||||||
"用户: 我们明天再聊吧\n"
|
"用户: 我想结束对话\n"
|
||||||
'返回: {"function_call": {"name": "handle_exit_intent"}}\n'
|
'返回: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
|
||||||
"```\n"
|
"```\n"
|
||||||
"```\n"
|
"```\n"
|
||||||
"用户: 播放中秋月\n"
|
"用户: 你好啊\n"
|
||||||
'返回: {"function_call": {"name": "play_music", "arguments": {"song_name": "中秋月"}}}\n'
|
'返回: {"function_call": {"name": "continue_chat"}}\n'
|
||||||
"```\n"
|
"```\n\n"
|
||||||
"```\n"
|
"注意:\n"
|
||||||
"可用的音乐名称:\n"
|
"1. 只返回JSON格式,不要包含任何其他文字\n"
|
||||||
|
'2. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n'
|
||||||
|
"3. 确保返回的JSON格式正确,包含所有必要的字段\n"
|
||||||
)
|
)
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
@@ -90,6 +105,14 @@ class IntentProvider(IntentProviderBase):
|
|||||||
for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]:
|
for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]:
|
||||||
del self.intent_cache[key]
|
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:
|
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
|
||||||
if not self.llm:
|
if not self.llm:
|
||||||
raise ValueError("LLM provider not set")
|
raise ValueError("LLM provider not set")
|
||||||
@@ -118,22 +141,35 @@ class IntentProvider(IntentProviderBase):
|
|||||||
# 清理缓存
|
# 清理缓存
|
||||||
self.clean_cache()
|
self.clean_cache()
|
||||||
|
|
||||||
# 构建用户最后一句话的提示
|
if self.promot == "":
|
||||||
msgStr = ""
|
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_config = initialize_music_handler(conn)
|
||||||
music_file_names = music_config["music_file_names"]
|
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}")
|
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
|
preprocess_time = time.time() - total_start_time
|
||||||
logger.bind(tag=TAG).debug(f"意图识别预处理耗时: {preprocess_time:.4f}秒")
|
logger.bind(tag=TAG).debug(f"意图识别预处理耗时: {preprocess_time:.4f}秒")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from config.logger import setup_logging
|
|||||||
import os
|
import os
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from core.utils.tts import MarkdownCleaner
|
from core.utils.tts import MarkdownCleaner
|
||||||
from core.utils.util import audio_to_opus_data
|
from core.utils.util import audio_to_data
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -53,5 +53,10 @@ class TTSProviderBase(ABC):
|
|||||||
async def text_to_speak(self, text, output_file):
|
async def text_to_speak(self, text, output_file):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def audio_to_pcm_data(self, audio_file_path):
|
||||||
|
"""音频文件转换为PCM编码"""
|
||||||
|
return audio_to_data(audio_file_path, is_opus=False)
|
||||||
|
|
||||||
def audio_to_opus_data(self, audio_file_path):
|
def audio_to_opus_data(self, audio_file_path):
|
||||||
return audio_to_opus_data(audio_file_path)
|
"""音频文件转换为Opus编码"""
|
||||||
|
return audio_to_data(audio_file_path, is_opus=True)
|
||||||
|
|||||||
@@ -38,9 +38,13 @@ class TTSProvider(TTSProviderBase):
|
|||||||
"Authorization": f"Bearer {self.access_token}",
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
response = requests.request(
|
|
||||||
"POST", self.api_url, json=request_json, headers=headers
|
try:
|
||||||
)
|
response = requests.request(
|
||||||
data = response.content
|
"POST", self.api_url, json=request_json, headers=headers
|
||||||
file_to_save = open(output_file, "wb")
|
)
|
||||||
file_to_save.write(data)
|
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:
|
with open(output_file, "wb") as file:
|
||||||
file.write(resp.content)
|
file.write(resp.content)
|
||||||
else:
|
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):
|
async def text_to_speak(self, text, output_file):
|
||||||
communicate = edge_tts.Communicate(text, voice=self.voice)
|
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:
|
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
||||||
pass
|
with open(output_file, "wb") as f:
|
||||||
|
pass
|
||||||
|
|
||||||
# 流式写入音频数据
|
# 流式写入音频数据
|
||||||
with open(output_file, "ab") as f: # 改为追加模式避免覆盖
|
with open(output_file, "ab") as f: # 改为追加模式避免覆盖
|
||||||
async for chunk in communicate.stream():
|
async for chunk in communicate.stream():
|
||||||
if chunk["type"] == "audio": # 只处理音频数据块
|
if chunk["type"] == "audio": # 只处理音频数据块
|
||||||
f.write(chunk["data"])
|
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)
|
audio_file.write(audio_content)
|
||||||
|
|
||||||
else:
|
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())
|
print(response.json())
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|||||||
@@ -105,6 +105,6 @@ class TTSProvider(TTSProviderBase):
|
|||||||
with open(output_file, "wb") as file:
|
with open(output_file, "wb") as file:
|
||||||
file.write(resp.content)
|
file.write(resp.content)
|
||||||
else:
|
else:
|
||||||
logger.bind(tag=TAG).error(
|
error_msg = f"GPT_SoVITS_V2 TTS请求失败: {resp.status_code} - {resp.text}"
|
||||||
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:
|
with open(output_file, "wb") as file:
|
||||||
file.write(resp.content)
|
file.write(resp.content)
|
||||||
else:
|
else:
|
||||||
logger.bind(tag=TAG).error(
|
error_msg = f"GPT_SoVITS_V3 TTS请求失败: {resp.status_code} - {resp.text}"
|
||||||
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}",
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
response = requests.request(
|
try:
|
||||||
"POST", self.api_url, json=request_json, headers=headers
|
response = requests.request(
|
||||||
)
|
"POST", self.api_url, json=request_json, headers=headers
|
||||||
data = response.content
|
)
|
||||||
file_to_save = open(output_file, "wb")
|
data = response.content
|
||||||
file_to_save.write(data)
|
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)
|
resp = requests.request("POST", url, data=payload)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.bind(tag=TAG).error(f"TTS请求失败: {resp.text}")
|
logger.bind(tag=TAG).error(f"TTSON 请求失败: {resp.text}")
|
||||||
return None
|
raise Exception(f"{__name__}: TTS请求失败")
|
||||||
resp_json = resp.json()
|
resp_json = resp.json()
|
||||||
try:
|
try:
|
||||||
result = (
|
result = (
|
||||||
@@ -71,13 +71,15 @@ class TTSProvider(TTSProviderBase):
|
|||||||
+ "&voice_audio_path="
|
+ "&voice_audio_path="
|
||||||
+ resp_json["voice_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:
|
except Exception as e:
|
||||||
print("error:", e)
|
print("error:", e)
|
||||||
|
raise Exception(f"{__name__}: TTS请求失败")
|
||||||
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)
|
|
||||||
@@ -33,7 +33,13 @@ class Dialogue:
|
|||||||
dialogue.append({"role": m.role, "tool_calls": m.tool_calls})
|
dialogue.append({"role": m.role, "tool_calls": m.tool_calls})
|
||||||
elif m.role == "tool":
|
elif m.role == "tool":
|
||||||
dialogue.append(
|
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:
|
else:
|
||||||
dialogue.append({"role": m.role, "content": m.content})
|
dialogue.append({"role": m.role, "content": m.content})
|
||||||
|
|||||||
@@ -862,8 +862,7 @@ def analyze_emotion(text):
|
|||||||
return top_emotions[0] # 如果都不在优先级列表里,返回第一个
|
return top_emotions[0] # 如果都不在优先级列表里,返回第一个
|
||||||
|
|
||||||
|
|
||||||
def audio_to_opus_data(audio_file_path):
|
def audio_to_data(audio_file_path, is_opus=True):
|
||||||
"""音频文件转换为Opus编码"""
|
|
||||||
# 获取文件后缀名
|
# 获取文件后缀名
|
||||||
file_type = os.path.splitext(audio_file_path)[1]
|
file_type = os.path.splitext(audio_file_path)[1]
|
||||||
if file_type:
|
if file_type:
|
||||||
@@ -889,7 +888,7 @@ def audio_to_opus_data(audio_file_path):
|
|||||||
frame_duration = 60 # 60ms per frame
|
frame_duration = 60 # 60ms per frame
|
||||||
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
|
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
|
||||||
|
|
||||||
opus_datas = []
|
datas = []
|
||||||
# 按帧处理所有音频数据(包括最后一帧可能补零)
|
# 按帧处理所有音频数据(包括最后一帧可能补零)
|
||||||
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
|
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
|
||||||
# 获取当前帧的二进制数据
|
# 获取当前帧的二进制数据
|
||||||
@@ -899,18 +898,24 @@ def audio_to_opus_data(audio_file_path):
|
|||||||
if len(chunk) < frame_size * 2:
|
if len(chunk) < frame_size * 2:
|
||||||
chunk += b"\x00" * (frame_size * 2 - len(chunk))
|
chunk += b"\x00" * (frame_size * 2 - len(chunk))
|
||||||
|
|
||||||
# 转换为numpy数组处理
|
if is_opus:
|
||||||
np_frame = np.frombuffer(chunk, dtype=np.int16)
|
# 转换为numpy数组处理
|
||||||
|
np_frame = np.frombuffer(chunk, dtype=np.int16)
|
||||||
|
# 编码Opus数据
|
||||||
|
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
|
||||||
|
else:
|
||||||
|
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
||||||
|
|
||||||
# 编码Opus数据
|
datas.append(frame_data)
|
||||||
opus_data = encoder.encode(np_frame.tobytes(), frame_size)
|
|
||||||
opus_datas.append(opus_data)
|
|
||||||
|
|
||||||
return opus_datas, duration
|
return datas, duration
|
||||||
|
|
||||||
|
|
||||||
def check_vad_update(before_config, new_config):
|
def check_vad_update(before_config, new_config):
|
||||||
if new_config["selected_module"].get("VAD") is None:
|
if (
|
||||||
|
new_config.get("selected_module") is None
|
||||||
|
or new_config["selected_module"].get("VAD") is None
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
update_vad = False
|
update_vad = False
|
||||||
current_vad_module = before_config["selected_module"]["VAD"]
|
current_vad_module = before_config["selected_module"]["VAD"]
|
||||||
@@ -931,7 +936,10 @@ def check_vad_update(before_config, new_config):
|
|||||||
|
|
||||||
|
|
||||||
def check_asr_update(before_config, new_config):
|
def check_asr_update(before_config, new_config):
|
||||||
if new_config["selected_module"].get("ASR") is None:
|
if (
|
||||||
|
new_config.get("selected_module") is None
|
||||||
|
or new_config["selected_module"].get("ASR") is None
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
update_asr = False
|
update_asr = False
|
||||||
current_asr_module = before_config["selected_module"]["ASR"]
|
current_asr_module = before_config["selected_module"]["ASR"]
|
||||||
|
|||||||
+90
-45
@@ -8,10 +8,10 @@ from plugins_func.register import register_function, ToolType, ActionResponse, A
|
|||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
GET_NEWS_FUNCTION_DESC = {
|
GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_news",
|
"name": "get_news_from_chinanews",
|
||||||
"description": (
|
"description": (
|
||||||
"获取最新新闻,随机选择一条新闻进行播报。"
|
"获取最新新闻,随机选择一条新闻进行播报。"
|
||||||
"用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。"
|
"用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。"
|
||||||
@@ -23,20 +23,20 @@ GET_NEWS_FUNCTION_DESC = {
|
|||||||
"properties": {
|
"properties": {
|
||||||
"category": {
|
"category": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别"
|
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别",
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容"
|
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容",
|
||||||
},
|
},
|
||||||
"lang": {
|
"lang": {
|
||||||
"type": "string",
|
"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元素(新闻条目)
|
# 查找所有item元素(新闻条目)
|
||||||
news_items = []
|
news_items = []
|
||||||
for item in root.findall('.//item'):
|
for item in root.findall(".//item"):
|
||||||
title = item.find('title').text if item.find('title') is not None else "无标题"
|
title = (
|
||||||
link = item.find('link').text if item.find('link') is not None else "#"
|
item.find("title").text if item.find("title") 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 "未知时间"
|
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({
|
news_items.append(
|
||||||
'title': title,
|
{
|
||||||
'link': link,
|
"title": title,
|
||||||
'description': description,
|
"link": link,
|
||||||
'pubDate': pubDate
|
"description": description,
|
||||||
})
|
"pubDate": pubDate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return news_items
|
return news_items
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -76,18 +88,24 @@ def fetch_news_detail(url):
|
|||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
response.raise_for_status()
|
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:
|
if content_div:
|
||||||
paragraphs = content_div.find_all('p')
|
paragraphs = content_div.find_all("p")
|
||||||
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
|
content = "\n".join(
|
||||||
|
[p.get_text().strip() for p in paragraphs if p.get_text().strip()]
|
||||||
|
)
|
||||||
return content
|
return content
|
||||||
else:
|
else:
|
||||||
# 如果找不到特定的内容区域,尝试获取所有段落
|
# 如果找不到特定的内容区域,尝试获取所有段落
|
||||||
paragraphs = soup.find_all('p')
|
paragraphs = soup.find_all("p")
|
||||||
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
|
content = "\n".join(
|
||||||
|
[p.get_text().strip() for p in paragraphs if p.get_text().strip()]
|
||||||
|
)
|
||||||
return content[:2000] # 限制长度
|
return content[:2000] # 限制长度
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
|
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
|
||||||
@@ -111,7 +129,7 @@ def map_category(category_text):
|
|||||||
"财经": "finance",
|
"财经": "finance",
|
||||||
"财经新闻": "finance",
|
"财经新闻": "finance",
|
||||||
"金融": "finance",
|
"金融": "finance",
|
||||||
"经济": "finance"
|
"经济": "finance",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 转换为小写并去除空格
|
# 转换为小写并去除空格
|
||||||
@@ -121,20 +139,36 @@ def map_category(category_text):
|
|||||||
return category_map.get(normalized_category, category_text)
|
return category_map.get(normalized_category, category_text)
|
||||||
|
|
||||||
|
|
||||||
@register_function('get_news', GET_NEWS_FUNCTION_DESC, ToolType.SYSTEM_CTL)
|
@register_function(
|
||||||
def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_CN"):
|
"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:
|
try:
|
||||||
# 如果detail为True,获取上一条新闻的详细内容
|
# 如果detail为True,获取上一条新闻的详细内容
|
||||||
if detail:
|
if detail:
|
||||||
if not hasattr(conn, 'last_news_link') or not conn.last_news_link or 'link' not in conn.last_news_link:
|
if (
|
||||||
return ActionResponse(Action.REQLLM, "抱歉,没有找到最近查询的新闻,请先获取一条新闻。", None)
|
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')
|
link = conn.last_news_link.get("link")
|
||||||
title = conn.last_news_link.get('title', '未知标题')
|
title = conn.last_news_link.get("title", "未知标题")
|
||||||
|
|
||||||
if link == '#':
|
if link == "#":
|
||||||
return ActionResponse(Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None)
|
return ActionResponse(
|
||||||
|
Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None
|
||||||
|
)
|
||||||
|
|
||||||
logger.bind(tag=TAG).debug(f"获取新闻详情: {title}, URL={link}")
|
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)
|
detail_content = fetch_news_detail(link)
|
||||||
|
|
||||||
if not detail_content or detail_content == "无法获取详细内容":
|
if not detail_content or detail_content == "无法获取详细内容":
|
||||||
return ActionResponse(Action.REQLLM,
|
return ActionResponse(
|
||||||
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。", None)
|
Action.REQLLM,
|
||||||
|
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
# 构建详情报告
|
# 构建详情报告
|
||||||
detail_report = (
|
detail_report = (
|
||||||
@@ -158,8 +195,10 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
|
|||||||
|
|
||||||
# 否则,获取新闻列表并随机选择一条
|
# 否则,获取新闻列表并随机选择一条
|
||||||
# 从配置中获取RSS URL
|
# 从配置中获取RSS URL
|
||||||
rss_config = conn.config["plugins"]["get_news"]
|
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")
|
default_rss_url = rss_config.get(
|
||||||
|
"default_rss_url", "https://www.chinanews.com.cn/rss/society.xml"
|
||||||
|
)
|
||||||
|
|
||||||
# 将用户输入的类别映射到配置中的类别键
|
# 将用户输入的类别映射到配置中的类别键
|
||||||
mapped_category = map_category(category)
|
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", {}):
|
if mapped_category and mapped_category in rss_config.get("category_urls", {}):
|
||||||
rss_url = rss_config["category_urls"][mapped_category]
|
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)
|
news_items = fetch_news_from_rss(rss_url)
|
||||||
|
|
||||||
if not news_items:
|
if not news_items:
|
||||||
return ActionResponse(Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None)
|
return ActionResponse(
|
||||||
|
Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None
|
||||||
|
)
|
||||||
|
|
||||||
# 随机选择一条新闻
|
# 随机选择一条新闻
|
||||||
selected_news = random.choice(news_items)
|
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 = {}
|
||||||
conn.last_news_link = {
|
conn.last_news_link = {
|
||||||
'link': selected_news.get('link', '#'),
|
"link": selected_news.get("link", "#"),
|
||||||
'title': selected_news.get('title', '未知标题')
|
"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:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"获取新闻出错: {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):
|
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(
|
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
|
||||||
"functions", []
|
"functions", []
|
||||||
)
|
)
|
||||||
@@ -27,7 +27,7 @@ def append_devices_to_prompt(conn):
|
|||||||
def initialize_hass_handler(conn):
|
def initialize_hass_handler(conn):
|
||||||
global HASS_CACHE
|
global HASS_CACHE
|
||||||
if 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(
|
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
|
||||||
"functions", []
|
"functions", []
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,4 +27,6 @@ cnlunar==0.2.0
|
|||||||
PySocks==1.7.1
|
PySocks==1.7.1
|
||||||
dashscope==1.23.1
|
dashscope==1.23.1
|
||||||
baidu-aip==4.16.13
|
baidu-aip==4.16.13
|
||||||
chardet==5.2.0
|
chardet==5.2.0
|
||||||
|
aioconsole==0.8.1
|
||||||
|
markitdown==0.1.1
|
||||||
Reference in New Issue
Block a user