Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30796c6f81 | ||
|
|
86b5eb1818 | ||
|
|
696405a8bf | ||
|
|
a2c2eab6b3 | ||
|
|
ead9ee81fb | ||
|
|
bd140af714 | ||
|
|
96008fa78f | ||
|
|
da4d0f515c | ||
|
|
67e6a22da9 | ||
|
|
b791fb1494 | ||
|
|
9e329bf19f | ||
|
|
3906e8a942 | ||
|
|
f74faea065 | ||
|
|
7d3759acce | ||
|
|
172962f711 | ||
|
|
36109e3b88 | ||
|
|
7c63b7c062 | ||
|
|
1efaf048f5 | ||
|
|
ac52714498 | ||
|
|
c3de89f504 | ||
|
|
6f6a645d0a | ||
|
|
5605352144 | ||
|
|
ef433f446d | ||
|
|
fe8f200364 | ||
|
|
be3a030302 | ||
|
|
cd6d4488d1 | ||
|
|
da3a5df5c3 | ||
|
|
4864407d61 | ||
|
|
f6f7879d6e | ||
|
|
dab1f14a59 | ||
|
|
51ca914206 | ||
|
|
933ffb8ca2 | ||
|
|
2f5f20a257 | ||
|
|
92544f89f3 | ||
|
|
f1cf1acbfa | ||
|
|
f22008389c | ||
|
|
91de372cbc | ||
|
|
811bd94934 | ||
|
|
5987488b3d | ||
|
|
966c9e972d | ||
|
|
464261a1b7 | ||
|
|
363dcef81a | ||
|
|
4439617c50 | ||
|
|
5d2fa3d65d | ||
|
|
8930ab0af6 | ||
|
|
6af6fe22fc | ||
|
|
dd577a7843 | ||
|
|
f772155674 | ||
|
|
3ce204338a | ||
|
|
168bc9ef45 | ||
|
|
bfc3d4888b | ||
|
|
0148b115db | ||
|
|
0b11953f6f | ||
|
|
6e210faacf | ||
|
|
4ef755e10b | ||
|
|
62b7cc5509 | ||
|
|
0e1ec05110 | ||
|
|
dd38fc74db | ||
|
|
d619d7ac76 | ||
|
|
37f7511ba0 | ||
|
|
afd03ba1b5 | ||
|
|
ac7b02d28a | ||
|
|
4a0bdbf779 | ||
|
|
164dada46a | ||
|
|
fd1c4ec792 | ||
|
|
add000d221 | ||
|
|
def598f728 | ||
|
|
0294885fbf | ||
|
|
c0ec4338f8 | ||
|
|
fb7f6f73bd | ||
|
|
e04c38e557 | ||
|
|
bfb5420733 | ||
|
|
9e6fc32903 | ||
|
|
217ceb1c7a | ||
|
|
846f89664e | ||
|
|
0e30f69e96 | ||
|
|
af9eb538f4 | ||
|
|
66a0beea33 | ||
|
|
411e73bb35 | ||
|
|
852e222f5d | ||
|
|
054ceab4f4 | ||
|
|
d9149ff572 | ||
|
|
7f331690a0 | ||
|
|
a5698dfff0 | ||
|
|
1e5866da06 | ||
|
|
3f7b7da184 | ||
|
|
c0020c8769 | ||
|
|
1c5e678cef | ||
|
|
3ab1b8ebc9 | ||
|
|
7635c68ccd | ||
|
|
a3b562b895 | ||
|
|
94d1493195 | ||
|
|
3a5bbe32b9 | ||
|
|
7e712c5003 | ||
|
|
31328e092e | ||
|
|
29671f48a8 | ||
|
|
61c2782491 | ||
|
|
15263b5223 | ||
|
|
557d1ee5b0 | ||
|
|
777265c730 | ||
|
|
e76a8d547b | ||
|
|
ff98f84f18 | ||
|
|
d0b4fa5b28 | ||
|
|
91b80f7c49 | ||
|
|
47f409246d | ||
|
|
be1ff83297 | ||
|
|
297c9e0085 | ||
|
|
ec95917b4d | ||
|
|
98bfe863fe | ||
|
|
af3d00662e | ||
|
|
39803fb0df | ||
|
|
4dad5ea6c1 | ||
|
|
ba8cb8c6f8 | ||
|
|
64f10b28e7 | ||
|
|
7f34447ace | ||
|
|
c2e000f937 | ||
|
|
831ab6d99b | ||
|
|
b246d9e567 | ||
|
|
919c2ffd46 | ||
|
|
e6d63a811e | ||
|
|
92227098b7 | ||
|
|
571080c1d6 | ||
|
|
d07feb837d | ||
|
|
1c823c4255 | ||
|
|
63e378524b | ||
|
|
ea5f54e421 | ||
|
|
ee18fbebae | ||
|
|
fa56d06e0c | ||
|
|
72f7514114 | ||
|
|
44e1f00ffc | ||
|
|
e7054ea13f | ||
|
|
4a3ac2cfcd | ||
|
|
bbc31e01c4 | ||
|
|
ba86b34a8c | ||
|
|
770a198772 | ||
|
|
a26bee3696 | ||
|
|
abb8f4f963 | ||
|
|
43d2adff70 | ||
|
|
474eeb9e68 | ||
|
|
e2dffea423 | ||
|
|
3d5eaba46f | ||
|
|
1bb8fbd56f | ||
|
|
b699886953 | ||
|
|
12c957d48b | ||
|
|
48e890c1b1 | ||
|
|
05331f001a | ||
|
|
bde260b330 | ||
|
|
59ef51ea20 | ||
|
|
aa77bfdfc4 | ||
|
|
a2baef8911 | ||
|
|
3fcfb65d45 | ||
|
|
8f266bea0d | ||
|
|
0a765f4aac | ||
|
|
ee3f0555d1 | ||
|
|
0c0386f4ba | ||
|
|
6c2b1cb589 | ||
|
|
9fc1285c09 | ||
|
|
e6e8ccee50 | ||
|
|
f9632af016 | ||
|
|
81359bf419 | ||
|
|
a144570ed7 | ||
|
|
0732b72e8f | ||
|
|
8332445a52 | ||
|
|
c9111d1bfc | ||
|
|
e7d999278d | ||
|
|
4043d20ea6 | ||
|
|
afe88ccfd6 | ||
|
|
56c3a04809 | ||
|
|
4530345a1f | ||
|
|
4a89ec8515 | ||
|
|
cb5f1c6485 | ||
|
|
15c0677a4b | ||
|
|
dc68b8148a | ||
|
|
1976034f12 | ||
|
|
727621fdac | ||
|
|
0f3806d358 | ||
|
|
dfb2bf3923 | ||
|
|
b4944f23ef | ||
|
|
09c17b2dc5 | ||
|
|
6f7ff9f858 | ||
|
|
7d74e853ae | ||
|
|
1d627d570d | ||
|
|
9b0088f4f0 | ||
|
|
f92c0313a6 | ||
|
|
b9fc0da215 | ||
|
|
39fe057f9f | ||
|
|
f55d6c2e60 |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -213,7 +213,7 @@ CREATE DATABASE xiaozhi_esp32_server CHARACTER SET utf8mb4 COLLATE utf8mb4_unico
|
|||||||
如果还没有MySQL,你可以通过docker安装mysql
|
如果还没有MySQL,你可以通过docker安装mysql
|
||||||
|
|
||||||
```
|
```
|
||||||
docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -d mysql:latest
|
docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -e TZ=Asia/Shanghai -d mysql:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2.安装redis
|
## 2.安装redis
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
登录AutoDL,租赁镜像
|
||||||
|
选择镜像:
|
||||||
|
```
|
||||||
|
PyTorch / 2.1.0 / 3.10(ubuntu22.04) / cuda 12.1
|
||||||
|
```
|
||||||
|
|
||||||
|
机器开机后,设置学术加速
|
||||||
|
```
|
||||||
|
source /etc/network_turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
进入工作目录
|
||||||
|
```
|
||||||
|
cd autodl-tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
拉取项目
|
||||||
|
```
|
||||||
|
git clone https://gitclone.com/github.com/fishaudio/fish-speech.git ; cd fish-speech
|
||||||
|
```
|
||||||
|
|
||||||
|
安装依赖
|
||||||
|
```
|
||||||
|
pip install -e.
|
||||||
|
```
|
||||||
|
|
||||||
|
如果报错,安装portaudio
|
||||||
|
```
|
||||||
|
apt-get install portaudio19-dev -y
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后执行
|
||||||
|
```
|
||||||
|
pip install torch==2.3.1 torchvision==0.18.1 torchaudio==2.3.1 --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
```
|
||||||
|
|
||||||
|
下载模型
|
||||||
|
```
|
||||||
|
cd tools
|
||||||
|
python download_models.py
|
||||||
|
```
|
||||||
|
|
||||||
|
下载完模型后运行接口
|
||||||
|
```
|
||||||
|
python -m tools.api_server --listen 0.0.0.0:6006
|
||||||
|
```
|
||||||
|
|
||||||
|
然后用浏览器去到aotodl实例页面
|
||||||
|
```
|
||||||
|
https://autodl.com/console/instance/list
|
||||||
|
```
|
||||||
|
|
||||||
|
如下图点击你刚才机器的`自定义服务`按钮,开启端口转发服务
|
||||||
|

|
||||||
|
|
||||||
|
端口转发服务设置完成后,你本地电脑打开网址`http://localhost:6006/`,就可以访问fish-speech的接口了
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
如果你是单模块部署,核心配置如下
|
||||||
|
```
|
||||||
|
selected_module:
|
||||||
|
TTS: FishSpeech
|
||||||
|
TTS:
|
||||||
|
FishSpeech:
|
||||||
|
reference_audio: ["config/assets/wakeup_words.wav",]
|
||||||
|
reference_text: ["哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",]
|
||||||
|
api_key: "123"
|
||||||
|
api_url: "http://127.0.0.1:6006/v1/tts"
|
||||||
|
```
|
||||||
|
|
||||||
|
然后重启服务
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
# ESP32设备与HomeAssistant集成指南
|
# 小智ESP32-开源服务端与HomeAssistant集成指南
|
||||||
|
|
||||||
|
[TOC]
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
|
|
||||||
@@ -7,26 +11,58 @@
|
|||||||
## 前提条件
|
## 前提条件
|
||||||
|
|
||||||
- 已安装并配置好`HomeAssistant`
|
- 已安装并配置好`HomeAssistant`
|
||||||
- 已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
|
|
||||||
- `xiaozhi-esp32-server智控台`能正常使用
|
|
||||||
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
|
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
|
||||||
|
|
||||||
## 网络环境
|
## 开始前的操作(必要)
|
||||||
|
|
||||||
|
### 1. 获取HA的网络网络地址信息
|
||||||
|
|
||||||
|
请访问你Home Assistant的网络地址,例如,我的HA的地址是192.168.4.7,端口则是默认的8123,则在浏览器打开
|
||||||
|
|
||||||
1、我的`HomeAssistant`部署在下面,版本是`2025.3.4`
|
|
||||||
```
|
```
|
||||||
http://192.168.4.7:8123
|
http://192.168.4.7:8123
|
||||||
```
|
```
|
||||||
|
|
||||||
2、我的`xiaozhi-esp32-server智控台`和`HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
|
> 手动查询 HA 的 IP 地址方法**(仅限小智esp32-server和HA部署在同一个网络设备[例如同一个wifi]下)**:
|
||||||
|
>
|
||||||
|
> 1. 进入 Home Assistant(前端)。
|
||||||
|
>
|
||||||
|
> 2. 点击左下角 **设置(Settings)** → **系统(System)** → **网络(Network)**。
|
||||||
|
>
|
||||||
|
> 3. 滑到最底部`Home Assistant 网址(Home Assistant website)`区域,在`本地网络(local network)`中,点击`眼睛`按钮,可以看到当前使用的 IP 地址(如 `192.168.1.10`)和网络接口。点击`复制连接(copy link)`可以直接复制。
|
||||||
|
>
|
||||||
|
> 
|
||||||
|
|
||||||
|
或,您已经设置了直接可以访问的Home Assistant的OAuth地址,您也可以在浏览器内直接访问
|
||||||
|
|
||||||
```
|
```
|
||||||
http://192.168.4.7:8002
|
http://homeassistant.local:8123
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 2. 登录`Home Assistant`拿到开发密钥
|
||||||
|
|
||||||
## 配置步骤
|
登录`HomeAssistant`,点击`左下角头像 -> 个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key,并复制保存,后续的方法都需要使用这个api key且仅出现一次(小tips: 您可以保存生成的二维码图像,后续可以扫描二维码再此提取api key)。
|
||||||
|
|
||||||
### 1. 登录`HomeAssistant`整理需要控制的设备清单
|
## 方法1:小智社区共建的HA调用功能
|
||||||
|
|
||||||
|
### 功能描述
|
||||||
|
|
||||||
|
- 如您后续需要增加新的设备,该方法需要手动重启`xiaozhi-esp32-server服务端`以此更新设备信息**(重要**)。
|
||||||
|
|
||||||
|
- 需要您确保已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`。
|
||||||
|
|
||||||
|
- 需要您确保`xiaozhi-esp32-server智控台`能正常使用。
|
||||||
|
|
||||||
|
- 我的`xiaozhi-esp32-server智控台`和`HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
|
||||||
|
|
||||||
|
```
|
||||||
|
http://192.168.4.7:8002
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
#### 1. 登录`HomeAssistant`整理需要控制的设备清单
|
||||||
|
|
||||||
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`。
|
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`。
|
||||||
|
|
||||||
@@ -56,12 +92,7 @@ http://192.168.4.7:8002
|
|||||||
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
|
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
|
||||||
|
|
||||||
|
|
||||||
### 2. 登录`HomeAssistant`拿到开发密钥
|
#### 2. 登录`智控台`
|
||||||
|
|
||||||
登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
|
|
||||||
|
|
||||||
|
|
||||||
### 3. 登录`智控台`
|
|
||||||
|
|
||||||
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
|
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
|
||||||
|
|
||||||
@@ -77,7 +108,7 @@ http://192.168.4.7:8123
|
|||||||
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
|
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
|
||||||
|
|
||||||
|
|
||||||
### 4. 设置`意图识别`函数
|
#### 3. 设置`意图识别`函数
|
||||||
|
|
||||||
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
|
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
|
||||||
|
|
||||||
@@ -94,18 +125,124 @@ change_role;get_weather;get_news;play_music
|
|||||||
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
|
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 手动重启xiaozhi-server
|
#### 4. 手动重启xiaozhi-server
|
||||||
|
|
||||||
重启xiaozhi-server程序
|
重启xiaozhi-server程序
|
||||||
|
|
||||||
|
|
||||||
### 5. 确认角色配置是否设置了函数意图识别
|
#### 5. 确认角色配置是否设置了函数意图识别
|
||||||
|
|
||||||
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
|
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
|
||||||
|
|
||||||
确认意图识别(Intent),是否选择“函数调用意图识别”
|
确认意图识别(Intent),是否选择“函数调用意图识别”
|
||||||
|
|
||||||
|
|
||||||
### 6. 唤醒设别进行控制
|
#### 6. 唤醒设别进行控制
|
||||||
|
|
||||||
尝试和esp32说,“打开XXX灯”
|
尝试和esp32说,“打开XXX灯”
|
||||||
|
|
||||||
|
## 方法2:小智将Home Assistant的语音助手作为LLM工具
|
||||||
|
|
||||||
|
### 功能描述
|
||||||
|
|
||||||
|
- 该方法有一个比较严重的缺点——**该方法无法使用小智开源生态的function_call插件功能的能力**,因为使用Home Assistant作为小智的LLM工具会将意图识别能力转让给Home Assistant。但是**这个方法是能体验到原生的Home Assistant操作功能,且小智的聊天能力不变**。如实在介意可以使用同样是Home Assistant支持的[方法3](##方法3:使用Home Assistant的MCP服务(推荐)),能够最大程度体验到Home Assistant的功能。
|
||||||
|
|
||||||
|
### 配置步骤:
|
||||||
|
|
||||||
|
#### 1. 配置Home Assistant的大模型语音助手。
|
||||||
|
|
||||||
|
**需要您提前配置好Home Assistant的语音助手或大模型工具。**
|
||||||
|
|
||||||
|
#### 2. 获取Home Assistant的语言助手的Agent ID.
|
||||||
|
|
||||||
|
1. 进入Home Assistant页面内。左侧点击`开发者助手`。
|
||||||
|
2. 在打开的`开发者助手`内,点击`动作`选项卡(如图示操作1),在页面内的选项栏`动作`中,找到或输入`conversation.process(对话-处理)`并选择`对话(conversation): 处理`(如图示操作2)。
|
||||||
|
|
||||||
|

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

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

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

|
||||||
|
|
||||||
|
6. 切换到小智开源服务端`xiaozhi-esp32-server`的`config.yaml`文件内,在LLM配置中,找到Home Assistant,设置您的Home Assistant的网络地址,Api key和刚刚查询到的agent_id。
|
||||||
|
7. 修改`config.yaml`文件内的`selected_module`属性的`LLM`为`HomeAssistant`,`Intent`为`nointent`。
|
||||||
|
8. 重启小智开源服务端`xiaozhi-esp32-server`即可正常使用。
|
||||||
|
|
||||||
|
## 方法3:使用Home Assistant的MCP服务(推荐)
|
||||||
|
|
||||||
|
### 功能描述
|
||||||
|
|
||||||
|
- 需要您提前在Home Assistant内集成并安装好HA集成——[Model Context Protocol Server](https://www.home-assistant.io/integrations/mcp_server/)。
|
||||||
|
|
||||||
|
- 这个方法与方法2都是HA官方提供的解决方法,与方法2不同的是,您可以正常使用小智开源服务端`xiaozhi-esp32-server`的开源共建的插件,同时允许您随意使用任何一个支持function_call功能的LLM大模型。
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
#### 1. 安装Home Assistant的MCP服务集成。
|
||||||
|
|
||||||
|
集成官方网址——[Model Context Protocol Server](https://www.home-assistant.io/integrations/mcp_server/)。。
|
||||||
|
|
||||||
|
或跟随以下手动操作。
|
||||||
|
|
||||||
|
> - 前往Home Assistant页面的**[设置 > 设备和服务(Settings > Devices & Services.)](https://my.home-assistant.io/redirect/integrations)**。
|
||||||
|
>
|
||||||
|
> - 在右下角,选择 **[添加集成(Add Integration)](https://my.home-assistant.io/redirect/config_flow_start?domain=mcp_server)**按钮。
|
||||||
|
>
|
||||||
|
> - 从列表中选择**模型上下文协议服务器(Model Context Protocol Server)**。
|
||||||
|
>
|
||||||
|
> - 按照屏幕上的说明完成设置。
|
||||||
|
|
||||||
|
#### 2. 配置小智开源服务端MCP配置信息
|
||||||
|
|
||||||
|
切换到小智开源服务端`xiaozhi-esp32-server`的`mcp_server_settings.json`文件内,在`"mcpServers"`的括号内添加以下内容:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Home Assistant": {
|
||||||
|
"command": "mcp-proxy",
|
||||||
|
"args": [
|
||||||
|
"http://YOUR_HA_HOST/mcp_server/sse"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"API_ACCESS_TOKEN": "YOUR_API_ACCESS_TOKEN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
1. **替换配置:**
|
||||||
|
- 替换`args`内的`YOUR_HA_HOST`为您的HA服务地址,如果你的服务地址已经包含了https/http字样(例如`http://192.168.1.101:8123`),则只需要填入`192.168.1.101:8123`即可。
|
||||||
|
- 将`env`内`API_ACCESS_TOKEN`的`YOUR_API_ACCESS_TOKEN`替换成您之前获取到的开发密钥api key。
|
||||||
|
2. **如果你添加配置是在`"mcpServers"`的括号内后续没有新的`mcpServers`的配置时,需要把最后的逗号`,`移除**,否则可能会解析失败。
|
||||||
|
|
||||||
|
**最后效果参考以下(参考如下)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"mcpServers": {
|
||||||
|
"Home Assistant": {
|
||||||
|
"command": "mcp-proxy",
|
||||||
|
"args": [
|
||||||
|
"http://192.168.1.101:8123/mcp_server/sse"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"API_ACCESS_TOKEN": "abcd.efghi.jkl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 配置小智开源服务端的系统配置
|
||||||
|
|
||||||
|
1. **选择任意一款支持function_call的LLM大模型作为小智的LLM聊天助手(但不要选择Home Assistant作为LLM工具)**,本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用,但部分时候调用不太稳定,如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115。
|
||||||
|
|
||||||
|
2. 切换到小智开源服务端`xiaozhi-esp32-server`的`config.yaml`文件内,设置您的LLM大模型配置,并且将`selected_module`配置的`Intent`调整为`function_call`。
|
||||||
|
|
||||||
|
3. 重启小智开源服务端`xiaozhi-esp32-server`即可正常使用。
|
||||||
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -16,4 +16,4 @@ https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh
|
|||||||
|
|
||||||
# manager-web 、manager-api接口协议
|
# manager-web 、manager-api接口协议
|
||||||
|
|
||||||
https://2662r3426b.vicp.fun/xiaozhi/v1/doc.html
|
https://2662r3426b.vicp.fun/xiaozhi/doc.html
|
||||||
|
|||||||
@@ -197,6 +197,10 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||||
|
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<!-- 阿里云maven仓库 -->
|
<!-- 阿里云maven仓库 -->
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package xiaozhi.common.config;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.RejectedExecutionHandler;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
@EnableAspectJAutoProxy(exposeProxy = true)
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
@Bean(name = "taskExecutor")
|
||||||
|
public Executor taskExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
executor.setMaxPoolSize(4);
|
||||||
|
executor.setQueueCapacity(1000);
|
||||||
|
executor.setThreadNamePrefix("AsyncThread-");
|
||||||
|
// 设置拒绝策略:由调用线程执行
|
||||||
|
executor.setRejectedExecutionHandler(new RejectedExecutionHandler() {
|
||||||
|
@Override
|
||||||
|
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
|
||||||
|
try {
|
||||||
|
// 如果线程池已满,则由调用线程执行
|
||||||
|
r.run();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("执行异步任务失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package xiaozhi.common.constant;
|
package xiaozhi.common.constant;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 常量
|
* 常量
|
||||||
* Copyright (c) 人人开源 All rights reserved.
|
* Copyright (c) 人人开源 All rights reserved.
|
||||||
@@ -109,6 +111,11 @@ public interface Constant {
|
|||||||
*/
|
*/
|
||||||
String FILE_EXTENSION_SEG = ".";
|
String FILE_EXTENSION_SEG = ".";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无记忆
|
||||||
|
*/
|
||||||
|
String MEMORY_NO_MEM = "Memory_nomem";
|
||||||
|
|
||||||
enum SysBaseParam {
|
enum SysBaseParam {
|
||||||
/**
|
/**
|
||||||
* 系统全称
|
* 系统全称
|
||||||
@@ -174,8 +181,23 @@ public interface Constant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
enum ChatHistoryConfEnum {
|
||||||
|
IGNORE(0, "不记录"),
|
||||||
|
RECORD_TEXT(1, "记录文本"),
|
||||||
|
RECORD_TEXT_AUDIO(2, "文本音频都记录");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
ChatHistoryConfEnum(int code, String name) {
|
||||||
|
this.code = code;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 版本号
|
* 版本号
|
||||||
*/
|
*/
|
||||||
public static final String VERSION = "0.3.13";
|
public static final String VERSION = "0.4.3";
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,13 @@ public class RedisKeys {
|
|||||||
return "agent:device:count:" + id;
|
return "agent:device:count:" + id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取智能体最后连接时间缓存key
|
||||||
|
*/
|
||||||
|
public static String getAgentDeviceLastConnectedAtById(String id) {
|
||||||
|
return "agent:device:lastConnected:" + id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取系统配置缓存key
|
* 获取系统配置缓存key
|
||||||
*/
|
*/
|
||||||
@@ -103,4 +110,11 @@ public class RedisKeys {
|
|||||||
public static String getDictDataByTypeKey(String dictType) {
|
public static String getDictDataByTypeKey(String dictType) {
|
||||||
return "sys:dict:data:" + dictType;
|
return "sys:dict:data:" + dictType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取智能体音频ID的缓存key
|
||||||
|
*/
|
||||||
|
public static String getAgentAudioIdKey(String uuid) {
|
||||||
|
return "agent:audio:id:" + uuid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ package xiaozhi.modules.agent.controller;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -25,16 +30,24 @@ import jakarta.validation.Valid;
|
|||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import xiaozhi.common.constant.Constant;
|
import xiaozhi.common.constant.Constant;
|
||||||
import xiaozhi.common.page.PageData;
|
import xiaozhi.common.page.PageData;
|
||||||
|
import xiaozhi.common.redis.RedisKeys;
|
||||||
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.common.user.UserDetail;
|
import xiaozhi.common.user.UserDetail;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.common.utils.Result;
|
import xiaozhi.common.utils.Result;
|
||||||
|
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||||
|
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
|
||||||
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
||||||
import xiaozhi.modules.agent.dto.AgentDTO;
|
import xiaozhi.modules.agent.dto.AgentDTO;
|
||||||
|
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
|
||||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||||
|
import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||||
|
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||||
import xiaozhi.modules.agent.service.AgentService;
|
import xiaozhi.modules.agent.service.AgentService;
|
||||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||||
|
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||||
import xiaozhi.modules.device.service.DeviceService;
|
import xiaozhi.modules.device.service.DeviceService;
|
||||||
import xiaozhi.modules.security.user.SecurityUser;
|
import xiaozhi.modules.security.user.SecurityUser;
|
||||||
|
|
||||||
@@ -46,6 +59,9 @@ public class AgentController {
|
|||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final AgentTemplateService agentTemplateService;
|
private final AgentTemplateService agentTemplateService;
|
||||||
private final DeviceService deviceService;
|
private final DeviceService deviceService;
|
||||||
|
private final AgentChatHistoryService agentChatHistoryService;
|
||||||
|
private final AgentChatAudioService agentChatAudioService;
|
||||||
|
private final RedisUtils redisUtils;
|
||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
@Operation(summary = "获取用户智能体列表")
|
@Operation(summary = "获取用户智能体列表")
|
||||||
@@ -80,7 +96,7 @@ public class AgentController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@Operation(summary = "创建智能体")
|
@Operation(summary = "创建智能体")
|
||||||
@RequiresPermissions("sys:role:normal")
|
@RequiresPermissions("sys:role:normal")
|
||||||
public Result<Void> save(@RequestBody @Valid AgentCreateDTO dto) {
|
public Result<String> save(@RequestBody @Valid AgentCreateDTO dto) {
|
||||||
AgentEntity entity = ConvertUtils.sourceToTarget(dto, AgentEntity.class);
|
AgentEntity entity = ConvertUtils.sourceToTarget(dto, AgentEntity.class);
|
||||||
|
|
||||||
// 获取默认模板
|
// 获取默认模板
|
||||||
@@ -95,6 +111,8 @@ public class AgentController {
|
|||||||
entity.setMemModelId(template.getMemModelId());
|
entity.setMemModelId(template.getMemModelId());
|
||||||
entity.setIntentModelId(template.getIntentModelId());
|
entity.setIntentModelId(template.getIntentModelId());
|
||||||
entity.setSystemPrompt(template.getSystemPrompt());
|
entity.setSystemPrompt(template.getSystemPrompt());
|
||||||
|
entity.setSummaryMemory(template.getSummaryMemory());
|
||||||
|
entity.setChatHistoryConf(template.getChatHistoryConf());
|
||||||
entity.setLangCode(template.getLangCode());
|
entity.setLangCode(template.getLangCode());
|
||||||
entity.setLanguage(template.getLanguage());
|
entity.setLanguage(template.getLanguage());
|
||||||
}
|
}
|
||||||
@@ -108,13 +126,29 @@ public class AgentController {
|
|||||||
// ID、智能体编码和排序会在Service层自动生成
|
// ID、智能体编码和排序会在Service层自动生成
|
||||||
agentService.insert(entity);
|
agentService.insert(entity);
|
||||||
|
|
||||||
return new Result<>();
|
return new Result<String>().ok(entity.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/saveMemory/{macAddress}")
|
||||||
|
@Operation(summary = "根据设备id更新智能体")
|
||||||
|
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
|
||||||
|
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
||||||
|
if (device == null) {
|
||||||
|
return new Result<>();
|
||||||
|
}
|
||||||
|
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
|
||||||
|
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
|
||||||
|
return updateAgentById(device.getAgentId(), agentUpdateDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@Operation(summary = "更新智能体")
|
@Operation(summary = "更新智能体")
|
||||||
@RequiresPermissions("sys:role:normal")
|
@RequiresPermissions("sys:role:normal")
|
||||||
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
|
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
|
||||||
|
return updateAgentById(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<Void> updateAgentById(String id, AgentUpdateDTO dto) {
|
||||||
// 先查询现有实体
|
// 先查询现有实体
|
||||||
AgentEntity existingEntity = agentService.getAgentById(id);
|
AgentEntity existingEntity = agentService.getAgentById(id);
|
||||||
if (existingEntity == null) {
|
if (existingEntity == null) {
|
||||||
@@ -152,6 +186,12 @@ public class AgentController {
|
|||||||
if (dto.getSystemPrompt() != null) {
|
if (dto.getSystemPrompt() != null) {
|
||||||
existingEntity.setSystemPrompt(dto.getSystemPrompt());
|
existingEntity.setSystemPrompt(dto.getSystemPrompt());
|
||||||
}
|
}
|
||||||
|
if (dto.getSummaryMemory() != null) {
|
||||||
|
existingEntity.setSummaryMemory(dto.getSummaryMemory());
|
||||||
|
}
|
||||||
|
if (dto.getChatHistoryConf() != null) {
|
||||||
|
existingEntity.setChatHistoryConf(dto.getChatHistoryConf());
|
||||||
|
}
|
||||||
if (dto.getLangCode() != null) {
|
if (dto.getLangCode() != null) {
|
||||||
existingEntity.setLangCode(dto.getLangCode());
|
existingEntity.setLangCode(dto.getLangCode());
|
||||||
}
|
}
|
||||||
@@ -167,8 +207,16 @@ public class AgentController {
|
|||||||
existingEntity.setUpdater(user.getId());
|
existingEntity.setUpdater(user.getId());
|
||||||
existingEntity.setUpdatedAt(new Date());
|
existingEntity.setUpdatedAt(new Date());
|
||||||
|
|
||||||
|
// 更新记忆策略
|
||||||
|
if (existingEntity.getMemModelId() == null || existingEntity.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
|
||||||
|
// 删除所有记录
|
||||||
|
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, true);
|
||||||
|
existingEntity.setSummaryMemory("");
|
||||||
|
} else if (existingEntity.getChatHistoryConf() != null && existingEntity.getChatHistoryConf() == 1) {
|
||||||
|
// 删除音频数据
|
||||||
|
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false);
|
||||||
|
}
|
||||||
agentService.updateById(existingEntity);
|
agentService.updateById(existingEntity);
|
||||||
|
|
||||||
return new Result<>();
|
return new Result<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +226,8 @@ public class AgentController {
|
|||||||
public Result<Void> delete(@PathVariable String id) {
|
public Result<Void> delete(@PathVariable String id) {
|
||||||
// 先删除关联的设备
|
// 先删除关联的设备
|
||||||
deviceService.deleteByAgentId(id);
|
deviceService.deleteByAgentId(id);
|
||||||
|
// 删除关联的聊天记录
|
||||||
|
agentChatHistoryService.deleteByAgentId(id, true, true);
|
||||||
// 再删除智能体
|
// 再删除智能体
|
||||||
agentService.deleteById(id);
|
agentService.deleteById(id);
|
||||||
return new Result<>();
|
return new Result<>();
|
||||||
@@ -192,4 +242,71 @@ public class AgentController {
|
|||||||
return new Result<List<AgentTemplateEntity>>().ok(list);
|
return new Result<List<AgentTemplateEntity>>().ok(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/sessions")
|
||||||
|
@Operation(summary = "获取智能体会话列表")
|
||||||
|
@RequiresPermissions("sys:role:normal")
|
||||||
|
@Parameters({
|
||||||
|
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
|
||||||
|
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true),
|
||||||
|
})
|
||||||
|
public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
|
||||||
|
@PathVariable("id") String id,
|
||||||
|
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||||
|
params.put("agentId", id);
|
||||||
|
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
|
||||||
|
return new Result<PageData<AgentChatSessionDTO>>().ok(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/chat-history/{sessionId}")
|
||||||
|
@Operation(summary = "获取智能体聊天记录")
|
||||||
|
@RequiresPermissions("sys:role:normal")
|
||||||
|
public Result<List<AgentChatHistoryDTO>> getAgentChatHistory(
|
||||||
|
@PathVariable("id") String id,
|
||||||
|
@PathVariable("sessionId") String sessionId) {
|
||||||
|
// 获取当前用户
|
||||||
|
UserDetail user = SecurityUser.getUser();
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!agentService.checkAgentPermission(id, user.getId())) {
|
||||||
|
return new Result<List<AgentChatHistoryDTO>>().error("没有权限查看该智能体的聊天记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询聊天记录
|
||||||
|
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
|
||||||
|
return new Result<List<AgentChatHistoryDTO>>().ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/audio/{audioId}")
|
||||||
|
@Operation(summary = "获取音频下载ID")
|
||||||
|
@RequiresPermissions("sys:role:normal")
|
||||||
|
public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
|
||||||
|
byte[] audioData = agentChatAudioService.getAudio(audioId);
|
||||||
|
if (audioData == null) {
|
||||||
|
return new Result<String>().error("音频不存在");
|
||||||
|
}
|
||||||
|
String uuid = UUID.randomUUID().toString();
|
||||||
|
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
|
||||||
|
return new Result<String>().ok(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/play/{uuid}")
|
||||||
|
@Operation(summary = "播放音频")
|
||||||
|
public ResponseEntity<byte[]> playAudio(@PathVariable("uuid") String uuid) {
|
||||||
|
|
||||||
|
String audioId = (String) redisUtils.get(RedisKeys.getAgentAudioIdKey(uuid));
|
||||||
|
if (StringUtils.isBlank(audioId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] audioData = agentChatAudioService.getAudio(audioId);
|
||||||
|
if (audioData == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
redisUtils.delete(RedisKeys.getAgentAudioIdKey(uuid));
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"play.wav\"")
|
||||||
|
.body(audioData);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package xiaozhi.modules.agent.dao;
|
package xiaozhi.modules.agent.dao;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,4 +15,24 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
|||||||
*/
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
|
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
|
||||||
|
/**
|
||||||
|
* 根据智能体ID删除音频
|
||||||
|
*
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
*/
|
||||||
|
void deleteAudioByAgentId(String agentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据智能体ID删除聊天历史记录
|
||||||
|
*
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
*/
|
||||||
|
void deleteHistoryByAgentId(String agentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据智能体ID删除音频ID
|
||||||
|
*
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
*/
|
||||||
|
void deleteAudioIdByAgentId(String agentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package xiaozhi.modules.agent.dto;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体聊天记录DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "智能体聊天记录")
|
||||||
|
public class AgentChatHistoryDTO {
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "消息类型: 1-用户, 2-智能体")
|
||||||
|
private Byte chatType;
|
||||||
|
|
||||||
|
@Schema(description = "聊天内容")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Schema(description = "音频ID")
|
||||||
|
private String audioId;
|
||||||
|
|
||||||
|
@Schema(description = "MAC地址")
|
||||||
|
private String macAddress;
|
||||||
|
}
|
||||||
@@ -26,6 +26,6 @@ public class AgentChatHistoryReportDTO {
|
|||||||
@Schema(description = "聊天内容", example = "你好呀")
|
@Schema(description = "聊天内容", example = "你好呀")
|
||||||
@NotBlank
|
@NotBlank
|
||||||
private String content;
|
private String content;
|
||||||
@Schema(description = "文件数据(opus编码)", example = "")
|
@Schema(description = "base64编码的opus音频数据", example = "")
|
||||||
private String opusDataBase64;
|
private String audioBase64;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package xiaozhi.modules.agent.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体会话列表DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AgentChatSessionDTO {
|
||||||
|
/**
|
||||||
|
* 会话ID
|
||||||
|
*/
|
||||||
|
private String sessionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天条数
|
||||||
|
*/
|
||||||
|
private Integer chatCount;
|
||||||
|
}
|
||||||
@@ -27,9 +27,16 @@ public class AgentDTO {
|
|||||||
@Schema(description = "大语言模型名称", example = "llm_model_01")
|
@Schema(description = "大语言模型名称", example = "llm_model_01")
|
||||||
private String llmModelName;
|
private String llmModelName;
|
||||||
|
|
||||||
|
@Schema(description = "记忆模型ID", example = "mem_model_01")
|
||||||
|
private String memModelId;
|
||||||
|
|
||||||
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助")
|
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助")
|
||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|
||||||
|
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||||
|
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
||||||
|
private String summaryMemory;
|
||||||
|
|
||||||
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
|
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
|
||||||
private Date lastConnectedAt;
|
private Date lastConnectedAt;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package xiaozhi.modules.agent.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体记忆更新DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "智能体记忆更新对象")
|
||||||
|
public class AgentMemoryDTO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||||
|
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
||||||
|
private String summaryMemory;
|
||||||
|
}
|
||||||
@@ -45,6 +45,13 @@ public class AgentUpdateDTO implements Serializable {
|
|||||||
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false)
|
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false)
|
||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|
||||||
|
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||||
|
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
||||||
|
private String summaryMemory;
|
||||||
|
|
||||||
|
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", required = false)
|
||||||
|
private Integer chatHistoryConf;
|
||||||
|
|
||||||
@Schema(description = "语言编码", example = "zh_CN", required = false)
|
@Schema(description = "语言编码", example = "zh_CN", required = false)
|
||||||
private String langCode;
|
private String langCode;
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,6 @@ public class AgentChatHistoryEntity {
|
|||||||
@TableField(value = "audio_id")
|
@TableField(value = "audio_id")
|
||||||
private String audioId;
|
private String audioId;
|
||||||
|
|
||||||
/**
|
|
||||||
* 音频URL
|
|
||||||
*/
|
|
||||||
@TableField(value = "audio_url")
|
|
||||||
private String audioUrl;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间
|
* 创建时间
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,9 +48,16 @@ public class AgentEntity {
|
|||||||
@Schema(description = "意图模型标识")
|
@Schema(description = "意图模型标识")
|
||||||
private String intentModelId;
|
private String intentModelId;
|
||||||
|
|
||||||
|
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)")
|
||||||
|
private Integer chatHistoryConf;
|
||||||
|
|
||||||
@Schema(description = "角色设定参数")
|
@Schema(description = "角色设定参数")
|
||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|
||||||
|
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||||
|
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
||||||
|
private String summaryMemory;
|
||||||
|
|
||||||
@Schema(description = "语言编码")
|
@Schema(description = "语言编码")
|
||||||
private String langCode;
|
private String langCode;
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,20 @@ public class AgentTemplateEntity implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String intentModelId;
|
private String intentModelId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)
|
||||||
|
*/
|
||||||
|
private Integer chatHistoryConf;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 角色设定参数
|
* 角色设定参数
|
||||||
*/
|
*/
|
||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总结记忆
|
||||||
|
*/
|
||||||
|
private String summaryMemory;
|
||||||
/**
|
/**
|
||||||
* 语言编码
|
* 语言编码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ public interface AgentChatAudioService extends IService<AgentChatAudioEntity> {
|
|||||||
* @return 音频ID
|
* @return 音频ID
|
||||||
*/
|
*/
|
||||||
String saveAudio(byte[] audioData);
|
String saveAudio(byte[] audioData);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取音频数据
|
||||||
|
*
|
||||||
|
* @param audioId 音频ID
|
||||||
|
* @return 音频数据
|
||||||
|
*/
|
||||||
|
byte[] getAudio(String audioId);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
package xiaozhi.modules.agent.service;
|
package xiaozhi.modules.agent.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import xiaozhi.common.page.PageData;
|
||||||
|
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||||
|
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
|
||||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,4 +18,30 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
|||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
|
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据智能体ID获取会话列表
|
||||||
|
*
|
||||||
|
* @param params 查询参数,包含agentId、page、limit
|
||||||
|
* @return 分页的会话列表
|
||||||
|
*/
|
||||||
|
PageData<AgentChatSessionDTO> getSessionListByAgentId(Map<String, Object> params);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会话ID获取聊天记录列表
|
||||||
|
*
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
* @param sessionId 会话ID
|
||||||
|
* @return 聊天记录列表
|
||||||
|
*/
|
||||||
|
List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据智能体ID删除聊天记录
|
||||||
|
*
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
* @param deleteAudio 是否删除音频
|
||||||
|
* @param deleteText 是否删除文本
|
||||||
|
*/
|
||||||
|
void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,35 +8,55 @@ import xiaozhi.common.service.BaseService;
|
|||||||
import xiaozhi.modules.agent.dto.AgentDTO;
|
import xiaozhi.modules.agent.dto.AgentDTO;
|
||||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体表处理service
|
||||||
|
*
|
||||||
|
* @author Goody
|
||||||
|
* @version 1.0, 2025/4/30
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
public interface AgentService extends BaseService<AgentEntity> {
|
public interface AgentService extends BaseService<AgentEntity> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理员获取所有智能体列表(分页)
|
* 获取管理员智能体列表
|
||||||
|
*
|
||||||
|
* @param params 查询参数
|
||||||
|
* @return 分页数据
|
||||||
*/
|
*/
|
||||||
PageData<AgentEntity> adminAgentList(Map<String, Object> params);
|
PageData<AgentEntity> adminAgentList(Map<String, Object> params);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取智能体详情
|
* 根据ID获取智能体
|
||||||
|
*
|
||||||
|
* @param id 智能体ID
|
||||||
|
* @return 智能体实体
|
||||||
*/
|
*/
|
||||||
AgentEntity getAgentById(String id);
|
AgentEntity getAgentById(String id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除这个用户的所有
|
* 插入智能体
|
||||||
*
|
*
|
||||||
* @param userId
|
* @param entity 智能体实体
|
||||||
|
* @return 是否成功
|
||||||
|
*/
|
||||||
|
boolean insert(AgentEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户ID删除智能体
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
*/
|
*/
|
||||||
void deleteAgentByUserId(Long userId);
|
void deleteAgentByUserId(Long userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户智能体列表
|
* 获取用户智能体列表
|
||||||
*
|
*
|
||||||
* @param userId
|
* @param userId 用户ID
|
||||||
* @return
|
* @return 智能体列表
|
||||||
*/
|
*/
|
||||||
List<AgentDTO> getUserAgents(Long userId);
|
List<AgentDTO> getUserAgents(Long userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取智能体的设备数量
|
* 根据智能体ID获取设备数量
|
||||||
*
|
*
|
||||||
* @param agentId 智能体ID
|
* @param agentId 智能体ID
|
||||||
* @return 设备数量
|
* @return 设备数量
|
||||||
@@ -50,4 +70,13 @@ public interface AgentService extends BaseService<AgentEntity> {
|
|||||||
* @return 默认智能体信息,不存在时返回null
|
* @return 默认智能体信息,不存在时返回null
|
||||||
*/
|
*/
|
||||||
AgentEntity getDefaultAgentByMacAddress(String macAddress);
|
AgentEntity getDefaultAgentByMacAddress(String macAddress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有权限访问智能体
|
||||||
|
*
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 是否有权限
|
||||||
|
*/
|
||||||
|
boolean checkAgentPermission(String agentId, Long userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package xiaozhi.modules.agent.service.biz.impl;
|
package xiaozhi.modules.agent.service.biz.impl;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import xiaozhi.common.constant.Constant;
|
||||||
|
import xiaozhi.common.redis.RedisKeys;
|
||||||
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
|
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
|
||||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||||
@@ -27,6 +34,7 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
|||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final AgentChatHistoryService agentChatHistoryService;
|
private final AgentChatHistoryService agentChatHistoryService;
|
||||||
private final AgentChatAudioService agentChatAudioService;
|
private final AgentChatAudioService agentChatAudioService;
|
||||||
|
private final RedisUtils redisUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理聊天记录上报,包括文件上传和相关信息记录
|
* 处理聊天记录上报,包括文件上传和相关信息记录
|
||||||
@@ -41,30 +49,52 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
|||||||
Byte chatType = report.getChatType();
|
Byte chatType = report.getChatType();
|
||||||
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
|
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
|
||||||
|
|
||||||
// 1. base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
|
// 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
|
||||||
String audioId = null;
|
|
||||||
if (report.getOpusDataBase64() != null && !report.getOpusDataBase64().isEmpty()) {
|
|
||||||
try {
|
|
||||||
// TODO: 需要考虑保留什么格式的音频数据,比如是opus还是wave
|
|
||||||
// byte[] audioData = Base64.getDecoder().decode(report.getOpusDataBase64());
|
|
||||||
// audioId = agentChatAudioService.saveAudio(audioData);
|
|
||||||
// log.info("音频数据保存成功,audioId={}", audioId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("音频数据保存失败", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 组装上报数据
|
|
||||||
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
|
|
||||||
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
|
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
|
||||||
if (agentEntity == null) {
|
if (agentEntity == null) {
|
||||||
return false;
|
return Boolean.FALSE;
|
||||||
}
|
}
|
||||||
String agentId = agentEntity.getId();
|
|
||||||
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
|
|
||||||
|
|
||||||
// 2.2 构建聊天记录实体
|
Integer chatHistoryConf = agentEntity.getChatHistoryConf();
|
||||||
|
String agentId = agentEntity.getId();
|
||||||
|
|
||||||
|
if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT.getCode())) {
|
||||||
|
saveChatText(report, agentId, macAddress, null);
|
||||||
|
} else if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode())) {
|
||||||
|
String audioId = saveChatAudio(report);
|
||||||
|
saveChatText(report, agentId, macAddress, audioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新设备最后对话时间
|
||||||
|
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
|
||||||
|
return Boolean.TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
|
||||||
|
*/
|
||||||
|
private String saveChatAudio(AgentChatHistoryReportDTO report) {
|
||||||
|
String audioId = null;
|
||||||
|
|
||||||
|
if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
|
||||||
|
try {
|
||||||
|
byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
|
||||||
|
audioId = agentChatAudioService.saveAudio(audioData);
|
||||||
|
log.info("音频数据保存成功,audioId={}", audioId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("音频数据保存失败", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return audioId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装上报数据
|
||||||
|
*/
|
||||||
|
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId) {
|
||||||
|
|
||||||
|
// 构建聊天记录实体
|
||||||
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
|
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
|
||||||
.macAddress(macAddress)
|
.macAddress(macAddress)
|
||||||
.agentId(agentId)
|
.agentId(agentId)
|
||||||
@@ -74,8 +104,9 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
|||||||
.audioId(audioId)
|
.audioId(audioId)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// 3. 保存数据
|
// 保存数据
|
||||||
agentChatHistoryService.save(entity);
|
agentChatHistoryService.save(entity);
|
||||||
return Boolean.TRUE;
|
|
||||||
|
log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,4 +25,10 @@ public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao,
|
|||||||
save(entity);
|
save(entity);
|
||||||
return entity.getId();
|
return entity.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getAudio(String audioId) {
|
||||||
|
AgentChatAudioEntity entity = getById(audioId);
|
||||||
|
return entity != null ? entity.getAudio() : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,24 @@
|
|||||||
package xiaozhi.modules.agent.service.impl;
|
package xiaozhi.modules.agent.service.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
|
||||||
|
import xiaozhi.common.constant.Constant;
|
||||||
|
import xiaozhi.common.page.PageData;
|
||||||
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
|
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
|
||||||
|
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||||
|
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
|
||||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||||
|
|
||||||
@@ -14,6 +30,64 @@ import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
|||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity> implements AgentChatHistoryService {
|
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity>
|
||||||
|
implements AgentChatHistoryService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageData<AgentChatSessionDTO> getSessionListByAgentId(Map<String, Object> params) {
|
||||||
|
String agentId = (String) params.get("agentId");
|
||||||
|
int page = Integer.parseInt(params.get(Constant.PAGE).toString());
|
||||||
|
int limit = Integer.parseInt(params.get(Constant.LIMIT).toString());
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
|
||||||
|
wrapper.select("session_id", "MAX(created_at) as created_at", "COUNT(*) as chat_count")
|
||||||
|
.eq("agent_id", agentId)
|
||||||
|
.groupBy("session_id")
|
||||||
|
.orderByDesc("created_at");
|
||||||
|
|
||||||
|
// 执行分页查询
|
||||||
|
Page<Map<String, Object>> pageParam = new Page<>(page, limit);
|
||||||
|
IPage<Map<String, Object>> result = this.baseMapper.selectMapsPage(pageParam, wrapper);
|
||||||
|
|
||||||
|
List<AgentChatSessionDTO> records = result.getRecords().stream().map(map -> {
|
||||||
|
AgentChatSessionDTO dto = new AgentChatSessionDTO();
|
||||||
|
dto.setSessionId((String) map.get("session_id"));
|
||||||
|
dto.setCreatedAt((LocalDateTime) map.get("created_at"));
|
||||||
|
dto.setChatCount(((Number) map.get("chat_count")).intValue());
|
||||||
|
return dto;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new PageData<>(records, result.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId) {
|
||||||
|
// 构建查询条件
|
||||||
|
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
|
||||||
|
wrapper.eq("agent_id", agentId)
|
||||||
|
.eq("session_id", sessionId)
|
||||||
|
.orderByAsc("created_at");
|
||||||
|
|
||||||
|
// 查询聊天记录
|
||||||
|
List<AgentChatHistoryEntity> historyList = list(wrapper);
|
||||||
|
|
||||||
|
// 转换为DTO
|
||||||
|
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
|
||||||
|
if (deleteAudio) {
|
||||||
|
baseMapper.deleteAudioByAgentId(agentId);
|
||||||
|
}
|
||||||
|
if (deleteAudio && !deleteText) {
|
||||||
|
baseMapper.deleteAudioIdByAgentId(agentId);
|
||||||
|
}
|
||||||
|
if (deleteText) {
|
||||||
|
baseMapper.deleteHistoryByAgentId(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import java.util.UUID;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import xiaozhi.common.constant.Constant;
|
||||||
import xiaozhi.common.page.PageData;
|
import xiaozhi.common.page.PageData;
|
||||||
import xiaozhi.common.redis.RedisKeys;
|
import xiaozhi.common.redis.RedisKeys;
|
||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
@@ -21,25 +22,20 @@ import xiaozhi.modules.agent.dao.AgentDao;
|
|||||||
import xiaozhi.modules.agent.dto.AgentDTO;
|
import xiaozhi.modules.agent.dto.AgentDTO;
|
||||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||||
import xiaozhi.modules.agent.service.AgentService;
|
import xiaozhi.modules.agent.service.AgentService;
|
||||||
|
import xiaozhi.modules.device.service.DeviceService;
|
||||||
import xiaozhi.modules.model.service.ModelConfigService;
|
import xiaozhi.modules.model.service.ModelConfigService;
|
||||||
|
import xiaozhi.modules.security.user.SecurityUser;
|
||||||
|
import xiaozhi.modules.sys.enums.SuperAdminEnum;
|
||||||
import xiaozhi.modules.timbre.service.TimbreService;
|
import xiaozhi.modules.timbre.service.TimbreService;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@AllArgsConstructor
|
||||||
public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> implements AgentService {
|
public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> implements AgentService {
|
||||||
private final AgentDao agentDao;
|
private final AgentDao agentDao;
|
||||||
|
private final TimbreService timbreModelService;
|
||||||
@Autowired
|
private final ModelConfigService modelConfigService;
|
||||||
private TimbreService timbreModelService;
|
private final RedisUtils redisUtils;
|
||||||
|
private final DeviceService deviceService;
|
||||||
@Autowired
|
|
||||||
private ModelConfigService modelConfigService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisUtils redisUtils;
|
|
||||||
|
|
||||||
public AgentServiceImpl(AgentDao agentDao) {
|
|
||||||
this.agentDao = agentDao;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
|
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
|
||||||
@@ -51,7 +47,15 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentEntity getAgentById(String id) {
|
public AgentEntity getAgentById(String id) {
|
||||||
return agentDao.selectById(id);
|
AgentEntity agent = agentDao.selectById(id);
|
||||||
|
if (agent != null && agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
|
||||||
|
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
|
||||||
|
} else if (agent != null && agent.getMemModelId() != null
|
||||||
|
&& !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
|
||||||
|
&& agent.getChatHistoryConf() == null) {
|
||||||
|
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode());
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -98,12 +102,17 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
|||||||
// 获取 LLM 模型名称
|
// 获取 LLM 模型名称
|
||||||
dto.setLlmModelName(modelConfigService.getModelNameById(agent.getLlmModelId()));
|
dto.setLlmModelName(modelConfigService.getModelNameById(agent.getLlmModelId()));
|
||||||
|
|
||||||
|
// 获取记忆模型名称
|
||||||
|
dto.setMemModelId(agent.getMemModelId());
|
||||||
|
|
||||||
// 获取 TTS 音色名称
|
// 获取 TTS 音色名称
|
||||||
dto.setTtsVoiceName(timbreModelService.getTimbreNameById(agent.getTtsVoiceId()));
|
dto.setTtsVoiceName(timbreModelService.getTimbreNameById(agent.getTtsVoiceId()));
|
||||||
|
|
||||||
|
// 获取智能体最近的最后连接时长
|
||||||
|
dto.setLastConnectedAt(deviceService.getLatestLastConnectionTime(agent.getId()));
|
||||||
|
|
||||||
// 获取设备数量
|
// 获取设备数量
|
||||||
dto.setDeviceCount(getDeviceCountByAgentId(agent.getId()));
|
dto.setDeviceCount(getDeviceCountByAgentId(agent.getId()));
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
@@ -138,4 +147,21 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
|||||||
}
|
}
|
||||||
return agentDao.getDefaultAgentByMacAddress(macAddress);
|
return agentDao.getDefaultAgentByMacAddress(macAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean checkAgentPermission(String agentId, Long userId) {
|
||||||
|
// 获取智能体信息
|
||||||
|
AgentEntity agent = getAgentById(agentId);
|
||||||
|
if (agent == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是超级管理员,直接返回true
|
||||||
|
if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是智能体的所有者
|
||||||
|
return userId.equals(agent.getUserId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
import xiaozhi.common.constant.Constant;
|
||||||
import xiaozhi.common.exception.ErrorCode;
|
import xiaozhi.common.exception.ErrorCode;
|
||||||
import xiaozhi.common.exception.RenException;
|
import xiaozhi.common.exception.RenException;
|
||||||
import xiaozhi.common.redis.RedisKeys;
|
import xiaozhi.common.redis.RedisKeys;
|
||||||
@@ -61,15 +62,16 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
|
|
||||||
// 构建模块配置
|
// 构建模块配置
|
||||||
buildModuleConfig(
|
buildModuleConfig(
|
||||||
agent.getAgentName(),
|
null,
|
||||||
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
agent.getVadModelId(),
|
agent.getVadModelId(),
|
||||||
agent.getAsrModelId(),
|
agent.getAsrModelId(),
|
||||||
agent.getLlmModelId(),
|
null,
|
||||||
agent.getTtsModelId(),
|
null,
|
||||||
agent.getMemModelId(),
|
null,
|
||||||
agent.getIntentModelId(),
|
null,
|
||||||
result,
|
result,
|
||||||
isCache);
|
isCache);
|
||||||
|
|
||||||
@@ -108,6 +110,17 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
// 获取单台设备每天最多输出字数
|
// 获取单台设备每天最多输出字数
|
||||||
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
|
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
|
||||||
result.put("device_max_output_size", deviceMaxOutputSize);
|
result.put("device_max_output_size", deviceMaxOutputSize);
|
||||||
|
|
||||||
|
// 获取聊天记录配置
|
||||||
|
Integer chatHistoryConf = agent.getChatHistoryConf();
|
||||||
|
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
|
||||||
|
chatHistoryConf = Constant.ChatHistoryConfEnum.IGNORE.getCode();
|
||||||
|
} else if (agent.getMemModelId() != null
|
||||||
|
&& !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
|
||||||
|
&& agent.getChatHistoryConf() == null) {
|
||||||
|
chatHistoryConf = Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode();
|
||||||
|
}
|
||||||
|
result.put("chat_history_conf", chatHistoryConf);
|
||||||
// 如果客户端已实例化模型,则不返回
|
// 如果客户端已实例化模型,则不返回
|
||||||
String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
|
String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
|
||||||
if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
|
if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
|
||||||
@@ -117,23 +130,12 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
if (alreadySelectedAsrModelId != null && alreadySelectedAsrModelId.equals(agent.getAsrModelId())) {
|
if (alreadySelectedAsrModelId != null && alreadySelectedAsrModelId.equals(agent.getAsrModelId())) {
|
||||||
agent.setAsrModelId(null);
|
agent.setAsrModelId(null);
|
||||||
}
|
}
|
||||||
String alreadySelectedLlmModelId = (String) selectedModule.get("LLM");
|
|
||||||
if (alreadySelectedLlmModelId != null && alreadySelectedLlmModelId.equals(agent.getLlmModelId())) {
|
|
||||||
agent.setLlmModelId(null);
|
|
||||||
}
|
|
||||||
String alreadySelectedMemModelId = (String) selectedModule.get("Memory");
|
|
||||||
if (alreadySelectedMemModelId != null && alreadySelectedMemModelId.equals(agent.getMemModelId())) {
|
|
||||||
agent.setMemModelId(null);
|
|
||||||
}
|
|
||||||
String alreadySelectedIntentModelId = (String) selectedModule.get("Intent");
|
|
||||||
if (alreadySelectedIntentModelId != null && alreadySelectedIntentModelId.equals(agent.getIntentModelId())) {
|
|
||||||
agent.setIntentModelId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建模块配置
|
// 构建模块配置
|
||||||
buildModuleConfig(
|
buildModuleConfig(
|
||||||
agent.getAgentName(),
|
agent.getAgentName(),
|
||||||
agent.getSystemPrompt(),
|
agent.getSystemPrompt(),
|
||||||
|
agent.getSummaryMemory(),
|
||||||
voice,
|
voice,
|
||||||
agent.getVadModelId(),
|
agent.getVadModelId(),
|
||||||
agent.getAsrModelId(),
|
agent.getAsrModelId(),
|
||||||
@@ -234,6 +236,7 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
private void buildModuleConfig(
|
private void buildModuleConfig(
|
||||||
String assistantName,
|
String assistantName,
|
||||||
String prompt,
|
String prompt,
|
||||||
|
String summaryMemory,
|
||||||
String voice,
|
String voice,
|
||||||
String vadModelId,
|
String vadModelId,
|
||||||
String asrModelId,
|
String asrModelId,
|
||||||
@@ -269,7 +272,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("\\;");
|
||||||
@@ -293,5 +297,6 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
|
prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
|
||||||
}
|
}
|
||||||
result.put("prompt", prompt);
|
result.put("prompt", prompt);
|
||||||
|
result.put("summaryMemory", summaryMemory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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;
|
||||||
@@ -8,4 +10,12 @@ import xiaozhi.modules.device.entity.DeviceEntity;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface DeviceDao extends BaseMapper<DeviceEntity> {
|
public interface DeviceDao extends BaseMapper<DeviceEntity> {
|
||||||
|
/**
|
||||||
|
* 获取此智能体全部设备的最后连接时间
|
||||||
|
*
|
||||||
|
* @param agentId 智能体id
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
Date getAllLastConnectedAtByAgentId(String agentId);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package xiaozhi.modules.device.service;
|
package xiaozhi.modules.device.service;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import xiaozhi.common.page.PageData;
|
import xiaozhi.common.page.PageData;
|
||||||
@@ -78,4 +79,13 @@ public interface DeviceService extends BaseService<DeviceEntity> {
|
|||||||
* @return 激活码
|
* @return 激活码
|
||||||
*/
|
*/
|
||||||
String geCodeByDeviceId(String deviceId);
|
String geCodeByDeviceId(String deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取这个智能体设备理的最近的最后连接时间
|
||||||
|
* @param agentId 智能体id
|
||||||
|
* @return 返回设备最近的最后连接时间
|
||||||
|
*/
|
||||||
|
Date getLatestLastConnectionTime(String agentId);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ import java.util.TimeZone;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.aop.framework.AopContext;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
@@ -54,6 +56,24 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
private final RedisUtils redisUtils;
|
private final RedisUtils redisUtils;
|
||||||
private final OtaService otaService;
|
private final OtaService otaService;
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion) {
|
||||||
|
try {
|
||||||
|
DeviceEntity device = new DeviceEntity();
|
||||||
|
device.setId(deviceId);
|
||||||
|
device.setLastConnectedAt(new Date());
|
||||||
|
if (StringUtils.isNotBlank(appVersion)) {
|
||||||
|
device.setAppVersion(appVersion);
|
||||||
|
}
|
||||||
|
deviceDao.updateById(device);
|
||||||
|
if (StringUtils.isNotBlank(agentId)) {
|
||||||
|
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("异步更新设备连接信息失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Boolean deviceActivation(String agentId, String activationCode) {
|
public Boolean deviceActivation(String agentId, String activationCode) {
|
||||||
if (StringUtils.isBlank(activationCode)) {
|
if (StringUtils.isBlank(activationCode)) {
|
||||||
@@ -118,13 +138,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
|
|
||||||
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
|
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
|
||||||
|
|
||||||
if (deviceById == null || deviceById.getAutoUpdate() != 0) {
|
// 设备未绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
|
||||||
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
|
if (deviceById == null) {
|
||||||
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
|
||||||
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
firmware.setVersion(deviceReport.getApplication().getVersion());
|
||||||
|
firmware.setUrl("http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL");
|
||||||
response.setFirmware(firmware);
|
response.setFirmware(firmware);
|
||||||
} else {
|
} else {
|
||||||
response.setFirmware(null);
|
// 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
|
||||||
|
if (deviceById.getAutoUpdate() != 0) {
|
||||||
|
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
|
||||||
|
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
||||||
|
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
||||||
|
response.setFirmware(firmware);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加WebSocket配置
|
// 添加WebSocket配置
|
||||||
@@ -149,13 +176,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
response.setWebsocket(websocket);
|
response.setWebsocket(websocket);
|
||||||
|
|
||||||
if (deviceById != null) {
|
if (deviceById != null) {
|
||||||
// 如果设备存在,则更新上次连接时间
|
// 如果设备存在,则异步更新上次连接时间和版本信息
|
||||||
deviceById.setLastConnectedAt(new Date());
|
String appVersion = deviceReport.getApplication() != null ? deviceReport.getApplication().getVersion()
|
||||||
if (deviceReport.getApplication() != null
|
: null;
|
||||||
&& StringUtils.isNotBlank(deviceReport.getApplication().getVersion())) {
|
// 通过Spring代理调用异步方法
|
||||||
deviceById.setAppVersion(deviceReport.getApplication().getVersion());
|
((DeviceServiceImpl) AopContext.currentProxy()).updateDeviceConnectionInfo(deviceById.getAgentId(),
|
||||||
}
|
deviceById.getId(), appVersion);
|
||||||
deviceDao.updateById(deviceById);
|
|
||||||
} else {
|
} else {
|
||||||
// 如果设备不存在,则生成激活码
|
// 如果设备不存在,则生成激活码
|
||||||
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
|
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
|
||||||
@@ -258,6 +284,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Date getLatestLastConnectionTime(String agentId) {
|
||||||
|
// 查询是否有缓存时间,有则返回
|
||||||
|
Date cachedDate = (Date) redisUtils.get(RedisKeys.getAgentDeviceLastConnectedAtById(agentId));
|
||||||
|
if (cachedDate != null) {
|
||||||
|
return cachedDate;
|
||||||
|
}
|
||||||
|
Date maxDate = deviceDao.getAllLastConnectedAtByAgentId(agentId);
|
||||||
|
if (maxDate != null) {
|
||||||
|
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), maxDate);
|
||||||
|
}
|
||||||
|
return maxDate;
|
||||||
|
}
|
||||||
|
|
||||||
private String getDeviceCacheKey(String deviceId) {
|
private String getDeviceCacheKey(String deviceId) {
|
||||||
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
||||||
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
|
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package xiaozhi.modules.security.config;
|
package xiaozhi.modules.security.config;
|
||||||
|
|
||||||
import jakarta.servlet.Filter;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.apache.shiro.mgt.SecurityManager;
|
import org.apache.shiro.mgt.SecurityManager;
|
||||||
import org.apache.shiro.session.mgt.SessionManager;
|
import org.apache.shiro.session.mgt.SessionManager;
|
||||||
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
|
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
|
||||||
@@ -11,15 +14,13 @@ import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
|
|||||||
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
|
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import jakarta.servlet.Filter;
|
||||||
import xiaozhi.modules.security.oauth2.Oauth2Filter;
|
import xiaozhi.modules.security.oauth2.Oauth2Filter;
|
||||||
import xiaozhi.modules.security.oauth2.Oauth2Realm;
|
import xiaozhi.modules.security.oauth2.Oauth2Realm;
|
||||||
import xiaozhi.modules.security.secret.ServerSecretFilter;
|
import xiaozhi.modules.security.secret.ServerSecretFilter;
|
||||||
import xiaozhi.modules.sys.service.SysParamsService;
|
import xiaozhi.modules.sys.service.SysParamsService;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shiro的配置文件
|
* Shiro的配置文件
|
||||||
* Copyright (c) 人人开源 All rights reserved.
|
* Copyright (c) 人人开源 All rights reserved.
|
||||||
@@ -85,6 +86,8 @@ public class ShiroConfig {
|
|||||||
// 将config路径使用server服务过滤器
|
// 将config路径使用server服务过滤器
|
||||||
filterMap.put("/config/**", "server");
|
filterMap.put("/config/**", "server");
|
||||||
filterMap.put("/agent/chat-history/report", "server");
|
filterMap.put("/agent/chat-history/report", "server");
|
||||||
|
filterMap.put("/agent/saveMemory/**", "server");
|
||||||
|
filterMap.put("/agent/play/**", "anon");
|
||||||
filterMap.put("/**", "oauth2");
|
filterMap.put("/**", "oauth2");
|
||||||
shiroFilter.setFilterChainDefinitionMap(filterMap);
|
shiroFilter.setFilterChainDefinitionMap(filterMap);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package xiaozhi.modules.security.config;
|
package xiaozhi.modules.security.config;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
|
|
||||||
@@ -18,6 +19,15 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
|
||||||
|
|
||||||
|
import xiaozhi.common.utils.DateUtils;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebMvcConfig implements WebMvcConfigurer {
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
@@ -53,10 +63,29 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
// 忽略未知属性
|
// 忽略未知属性
|
||||||
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
// 日期格式转换
|
// 设置时区
|
||||||
// mapper.setDateFormat(new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN));
|
|
||||||
mapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));
|
mapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));
|
||||||
|
|
||||||
|
// 配置Java8日期时间序列化
|
||||||
|
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||||
|
javaTimeModule.addSerializer(java.time.LocalDateTime.class, new LocalDateTimeSerializer(
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
|
||||||
|
javaTimeModule.addSerializer(java.time.LocalDate.class, new LocalDateSerializer(
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
|
||||||
|
javaTimeModule.addSerializer(java.time.LocalTime.class,
|
||||||
|
new LocalTimeSerializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
|
||||||
|
javaTimeModule.addDeserializer(java.time.LocalDateTime.class, new LocalDateTimeDeserializer(
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
|
||||||
|
javaTimeModule.addDeserializer(java.time.LocalDate.class, new LocalDateDeserializer(
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
|
||||||
|
javaTimeModule.addDeserializer(java.time.LocalTime.class,
|
||||||
|
new LocalTimeDeserializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
|
||||||
|
mapper.registerModule(javaTimeModule);
|
||||||
|
|
||||||
|
// 配置java.util.Date的序列化和反序列化
|
||||||
|
SimpleDateFormat dateFormat = new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN);
|
||||||
|
mapper.setDateFormat(dateFormat);
|
||||||
|
|
||||||
// Long类型转String类型
|
// Long类型转String类型
|
||||||
SimpleModule simpleModule = new SimpleModule();
|
SimpleModule simpleModule = new SimpleModule();
|
||||||
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
|
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ public class SysDictDataController {
|
|||||||
|
|
||||||
@GetMapping("/type/{dictType}")
|
@GetMapping("/type/{dictType}")
|
||||||
@Operation(summary = "获取字典数据列表")
|
@Operation(summary = "获取字典数据列表")
|
||||||
@RequiresPermissions("sys:role:superAdmin")
|
@RequiresPermissions("sys:role:normal")
|
||||||
public Result<List<SysDictDataItem>> getDictDataByType(@PathVariable("dictType") String dictType) {
|
public Result<List<SysDictDataItem>> getDictDataByType(@PathVariable("dictType") String dictType) {
|
||||||
List<SysDictDataItem> list = sysDictDataService.getDictDataByType(dictType);
|
List<SysDictDataItem> list = sysDictDataService.getDictDataByType(dictType);
|
||||||
return new Result<List<SysDictDataItem>>().ok(list);
|
return new Result<List<SysDictDataItem>>().ok(list);
|
||||||
|
|||||||
@@ -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 = "备注")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.util.regex.Pattern;
|
|||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.socket.WebSocketHttpHeaders;
|
||||||
import org.springframework.web.socket.client.WebSocketClient;
|
import org.springframework.web.socket.client.WebSocketClient;
|
||||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||||
|
|
||||||
@@ -45,8 +46,9 @@ public class WebSocketValidator {
|
|||||||
try {
|
try {
|
||||||
WebSocketClient client = new StandardWebSocketClient();
|
WebSocketClient client = new StandardWebSocketClient();
|
||||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||||
|
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
|
||||||
|
|
||||||
client.doHandshake(new WebSocketTestHandler(future), null, URI.create(url));
|
client.execute(new WebSocketTestHandler(future), headers, URI.create(url));
|
||||||
|
|
||||||
// 等待最多5秒获取连接结果
|
// 等待最多5秒获取连接结果
|
||||||
return future.get(5, TimeUnit.SECONDS);
|
return future.get(5, TimeUnit.SECONDS);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
-- 初始化智能体聊天记录
|
-- 初始化智能体聊天记录
|
||||||
DROP TABLE IF EXISTS ai_chat_history;
|
DROP TABLE IF EXISTS ai_chat_history;
|
||||||
|
DROP TABLE IF EXISTS ai_chat_message;
|
||||||
DROP TABLE IF EXISTS ai_agent_chat_history;
|
DROP TABLE IF EXISTS ai_agent_chat_history;
|
||||||
CREATE TABLE ai_agent_chat_history
|
CREATE TABLE ai_agent_chat_history
|
||||||
(
|
(
|
||||||
@@ -13,7 +14,9 @@ CREATE TABLE ai_agent_chat_history
|
|||||||
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL COMMENT '创建时间',
|
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL COMMENT '创建时间',
|
||||||
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
INDEX idx_ai_agent_chat_history_mac (mac_address),
|
INDEX idx_ai_agent_chat_history_mac (mac_address),
|
||||||
INDEX idx_ai_agent_chat_history_agent_id (agent_id)
|
INDEX idx_ai_agent_chat_history_session_id (session_id),
|
||||||
|
INDEX idx_ai_agent_chat_history_agent_id (agent_id),
|
||||||
|
INDEX idx_ai_agent_chat_history_agent_session_created (agent_id, session_id, created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天记录表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天记录表';
|
||||||
|
|
||||||
DROP TABLE IF EXISTS ai_agent_chat_audio;
|
DROP TABLE IF EXISTS ai_agent_chat_audio;
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- 更新模型供应器表
|
||||||
|
UPDATE `ai_model_provider` SET fields = '[{"key": "host", "type": "string", "label": "服务地址"}, {"key": "port", "type": "number", "label": "端口号"}, {"key": "type", "type": "string", "label": "服务类型"}, {"key": "is_ssl", "type": "boolean", "label": "是否使用SSL"}, {"key": "api_key", "type": "string", "label": "API密钥"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]' WHERE id = 'SYSTEM_ASR_FunASRServer';
|
||||||
|
|
||||||
|
-- 更新模型配置表
|
||||||
|
UPDATE `ai_model_config` SET
|
||||||
|
config_json = '{"host": "127.0.0.1", "port": 10096, "type": "fun_server", "is_ssl": true, "api_key": "none", "output_dir": "tmp/"}',
|
||||||
|
`doc_link` = 'https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md',
|
||||||
|
`remark` = '独立部署FunASR,使用FunASR的API服务,只需要五句话
|
||||||
|
第一句:mkdir -p ./funasr-runtime-resources/models
|
||||||
|
第二句:sudo docker run -p 10096:10095 -it --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
|
||||||
|
上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
|
||||||
|
不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
|
||||||
|
上一句话执行后会进入到容器,继续第五句:tail -f log.txt
|
||||||
|
第五句话执行完后,会看到模型下载日志,下载完后就可以连接使用了
|
||||||
|
以上是使用CPU推理,如果有GPU,详细参考:https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md' WHERE `id` = 'ASR_FunASRServer';
|
||||||
|
|
||||||
|
-- FishSpeech配置说明
|
||||||
|
UPDATE `ai_model_config` SET
|
||||||
|
`doc_link` = 'https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md',
|
||||||
|
`remark` = 'FishSpeech配置说明:
|
||||||
|
1. 需要本地部署FishSpeech服务
|
||||||
|
2. 支持自定义音色
|
||||||
|
3. 本地推理,无需网络连接
|
||||||
|
4. 输出文件保存在tmp/目录
|
||||||
|
5. 可参照教程https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md' WHERE `id` = 'TTS_FishSpeech';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 添加聊天记录配置字段
|
||||||
|
ALTER TABLE `ai_agent`
|
||||||
|
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 0 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
|
||||||
|
|
||||||
|
ALTER TABLE `ai_agent_template`
|
||||||
|
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 0 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 添加总结记忆字段
|
||||||
|
ALTER TABLE `ai_agent`
|
||||||
|
ADD COLUMN `summary_memory` text COMMENT '总结记忆' AFTER `system_prompt`;
|
||||||
|
|
||||||
|
ALTER TABLE `ai_agent_template`
|
||||||
|
ADD COLUMN `summary_memory` text COMMENT '总结记忆' AFTER `system_prompt`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
update ai_agent_template set system_prompt = replace(system_prompt, '我是', '你是');
|
||||||
|
|
||||||
|
delete from sys_params where id in (500,501,402);
|
||||||
|
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (500, 'end_prompt.enable', 'true', 'boolean', 1, '是否开启结束语');
|
||||||
|
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (501, 'end_prompt.prompt', '请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!', 'string', 1, '结束提示词');
|
||||||
|
|
||||||
|
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (402, 'plugins.get_weather.api_host', 'mj7p3y7naa.re.qweatherapi.com', 'string', 1, '开发者apihost');
|
||||||
@@ -94,9 +94,51 @@ databaseChangeLog:
|
|||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202504301341.sql
|
path: classpath:db/changelog/202504301341.sql
|
||||||
- changeSet:
|
- changeSet:
|
||||||
id: 202505012207
|
id: 202505022134
|
||||||
author: Goody
|
author: Goody
|
||||||
changes:
|
changes:
|
||||||
- sqlFile:
|
- sqlFile:
|
||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202505012207.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
|
||||||
|
- changeSet:
|
||||||
|
id: 202505091555
|
||||||
|
author: whosmyqueen
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202505091555.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202505111914
|
||||||
|
author: hrz
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202505111914.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202505122348
|
||||||
|
author: ljwwd2
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202505122348.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202505142037
|
||||||
|
author: hrz
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202505142037.sql
|
||||||
|
|||||||
@@ -21,4 +21,24 @@
|
|||||||
id, mac_address, agent_id, session_id, sort, chat_type, content, audio, audio_url,
|
id, mac_address, agent_id, session_id, sort, chat_type, content, audio, audio_url,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
|
<delete id="deleteAudioByAgentId">
|
||||||
|
DELETE FROM ai_agent_chat_audio
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT audio_id
|
||||||
|
FROM ai_agent_chat_history
|
||||||
|
WHERE agent_id = #{agentId}
|
||||||
|
)
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<update id="deleteAudioIdByAgentId">
|
||||||
|
UPDATE ai_agent_chat_history
|
||||||
|
SET audio_id = NULL
|
||||||
|
WHERE agent_id = #{agentId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="deleteHistoryByAgentId">
|
||||||
|
DELETE FROM ai_agent_chat_history
|
||||||
|
WHERE agent_id = #{agentId}
|
||||||
|
</delete>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="xiaozhi.modules.device.dao.DeviceDao">
|
||||||
|
<!-- 获取此智能体全部设备的最后连接时间 -->
|
||||||
|
<select id="getAllLastConnectedAtByAgentId" resultType="java.util.Date">
|
||||||
|
SELECT last_connected_at FROM ai_device
|
||||||
|
WHERE
|
||||||
|
agent_id = #{agentId}
|
||||||
|
order by
|
||||||
|
last_connected_at desc limit 0,1
|
||||||
|
</select>
|
||||||
|
</mapper>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package xiaozhi.modules.device;
|
package xiaozhi.modules.device;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions;
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -8,8 +11,9 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import xiaozhi.common.redis.RedisKeys;
|
|
||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
|
import xiaozhi.modules.sys.dto.SysUserDTO;
|
||||||
|
import xiaozhi.modules.sys.service.SysUserService;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@@ -19,18 +23,37 @@ public class DeviceTest {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private RedisUtils redisUtils;
|
private RedisUtils redisUtils;
|
||||||
|
@Autowired
|
||||||
|
private SysUserService sysUserService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveUser() {
|
||||||
|
SysUserDTO userDTO = new SysUserDTO();
|
||||||
|
userDTO.setUsername("test");
|
||||||
|
userDTO.setPassword(UUID.randomUUID().toString());
|
||||||
|
sysUserService.save(userDTO);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("测试写入设备信息")
|
@DisplayName("测试写入设备信息")
|
||||||
public void testWriteDeviceInfo() {
|
public void testWriteDeviceInfo() {
|
||||||
log.info("开始测试写入设备信息...");
|
log.info("开始测试写入设备信息...");
|
||||||
|
|
||||||
// 模拟设备MAC地址
|
// 模拟设备MAC地址
|
||||||
String macAddress = "00:11:22:33:44:55";
|
String macAddress = "00:11:22:33:44:66";
|
||||||
// 模拟设备验证码
|
// 模拟设备验证码
|
||||||
String deviceCode = "123456";
|
String deviceCode = "123456";
|
||||||
|
|
||||||
String redisKey = RedisKeys.getDeviceCaptchaKey(deviceCode);
|
HashMap<String, Object> map = new HashMap<>();
|
||||||
|
map.put("mac_address", macAddress);
|
||||||
|
map.put("activation_code", deviceCode);
|
||||||
|
map.put("board", "硬件型号");
|
||||||
|
map.put("app_version", "0.3.13");
|
||||||
|
|
||||||
|
String safeDeviceId = macAddress.replace(":", "_").toLowerCase();
|
||||||
|
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
|
||||||
|
redisUtils.set(cacheDeviceKey, map, 300);
|
||||||
|
|
||||||
|
String redisKey = "ota:activation:code:" + deviceCode;
|
||||||
log.info("Redis Key: {}", redisKey);
|
log.info("Redis Key: {}", redisKey);
|
||||||
|
|
||||||
// 将设备信息写入Redis
|
// 将设备信息写入Redis
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ export default {
|
|||||||
}).send();
|
}).send();
|
||||||
},
|
},
|
||||||
// 获取智能体配置
|
// 获取智能体配置
|
||||||
getDeviceConfig(deviceId, callback) {
|
getDeviceConfig(agentId, callback) {
|
||||||
RequestService.sendRequest()
|
RequestService.sendRequest()
|
||||||
.url(`${getServiceUrl()}/agent/${deviceId}`)
|
.url(`${getServiceUrl()}/agent/${agentId}`)
|
||||||
.method('GET')
|
.method('GET')
|
||||||
.success((res) => {
|
.success((res) => {
|
||||||
RequestService.clearRequestTime();
|
RequestService.clearRequestTime();
|
||||||
@@ -61,7 +61,7 @@ export default {
|
|||||||
.fail((err) => {
|
.fail((err) => {
|
||||||
console.error('获取配置失败:', err);
|
console.error('获取配置失败:', err);
|
||||||
RequestService.reAjaxFun(() => {
|
RequestService.reAjaxFun(() => {
|
||||||
this.getDeviceConfig(deviceId, callback);
|
this.getDeviceConfig(agentId, callback);
|
||||||
});
|
});
|
||||||
}).send();
|
}).send();
|
||||||
},
|
},
|
||||||
@@ -97,4 +97,50 @@ export default {
|
|||||||
});
|
});
|
||||||
}).send();
|
}).send();
|
||||||
},
|
},
|
||||||
|
// 获取智能体会话列表
|
||||||
|
getAgentSessions(agentId, params, callback) {
|
||||||
|
RequestService.sendRequest()
|
||||||
|
.url(`${getServiceUrl()}/agent/${agentId}/sessions`)
|
||||||
|
.method('GET')
|
||||||
|
.data(params)
|
||||||
|
.success((res) => {
|
||||||
|
RequestService.clearRequestTime();
|
||||||
|
callback(res);
|
||||||
|
})
|
||||||
|
.fail(() => {
|
||||||
|
RequestService.reAjaxFun(() => {
|
||||||
|
this.getAgentSessions(agentId, params, callback);
|
||||||
|
});
|
||||||
|
}).send();
|
||||||
|
},
|
||||||
|
// 获取智能体聊天记录
|
||||||
|
getAgentChatHistory(agentId, sessionId, callback) {
|
||||||
|
RequestService.sendRequest()
|
||||||
|
.url(`${getServiceUrl()}/agent/${agentId}/chat-history/${sessionId}`)
|
||||||
|
.method('GET')
|
||||||
|
.success((res) => {
|
||||||
|
RequestService.clearRequestTime();
|
||||||
|
callback(res);
|
||||||
|
})
|
||||||
|
.fail(() => {
|
||||||
|
RequestService.reAjaxFun(() => {
|
||||||
|
this.getAgentChatHistory(agentId, sessionId, callback);
|
||||||
|
});
|
||||||
|
}).send();
|
||||||
|
},
|
||||||
|
// 获取音频下载ID
|
||||||
|
getAudioId(audioId, callback) {
|
||||||
|
RequestService.sendRequest()
|
||||||
|
.url(`${getServiceUrl()}/agent/audio/${audioId}`)
|
||||||
|
.method('POST')
|
||||||
|
.success((res) => {
|
||||||
|
RequestService.clearRequestTime();
|
||||||
|
callback(res);
|
||||||
|
})
|
||||||
|
.fail(() => {
|
||||||
|
RequestService.reAjaxFun(() => {
|
||||||
|
this.getAudioId(audioId, callback);
|
||||||
|
});
|
||||||
|
}).send();
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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() {
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :title="'与' + agentName + '的聊天记录' + (currentMacAddress ? '[' + currentMacAddress + ']' : '')"
|
||||||
|
:visible.sync="dialogVisible" width="80%" :before-close="handleClose" custom-class="chat-history-dialog">
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="session-list" @scroll="handleScroll">
|
||||||
|
<div v-for="session in sessions" :key="session.sessionId" class="session-item"
|
||||||
|
:class="{ active: currentSessionId === session.sessionId }" @click="selectSession(session)">
|
||||||
|
<img :src="getUserAvatar(session.sessionId)" class="avatar" />
|
||||||
|
<div class="session-info">
|
||||||
|
<div class="session-time">{{ formatTime(session.createdAt) }}</div>
|
||||||
|
<div class="message-count">{{ session.chatCount > 99 ? '99' : session.chatCount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
<div v-if="!hasMore" class="no-more">没有更多记录了</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-content">
|
||||||
|
<div v-if="currentSessionId" class="messages">
|
||||||
|
<div v-for="(message, index) in messagesWithTime" :key="message.id">
|
||||||
|
<div v-if="message.type === 'time'" class="time-divider">
|
||||||
|
{{ message.content }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="message-item" :class="{ 'user-message': message.chatType === 1 }">
|
||||||
|
<img :src="message.chatType === 1 ? getUserAvatar(currentSessionId) : require('@/assets/xiaozhi-logo.png')"
|
||||||
|
class="avatar" />
|
||||||
|
<div class="message-content">
|
||||||
|
{{ message.content }}
|
||||||
|
<i v-if="message.audioId" :class="getAudioIconClass(message)"
|
||||||
|
@click="playAudio(message)" class="audio-icon"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-session-selected">
|
||||||
|
请选择会话查看聊天记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Api from '@/apis/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ChatHistoryDialog',
|
||||||
|
props: {
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
agentId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
agentName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dialogVisible: false,
|
||||||
|
sessions: [],
|
||||||
|
messages: [],
|
||||||
|
currentSessionId: '',
|
||||||
|
currentMacAddress: '',
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
loading: false,
|
||||||
|
hasMore: true,
|
||||||
|
scrollTimer: null,
|
||||||
|
isFirstLoad: true,
|
||||||
|
playingAudioId: null,
|
||||||
|
audioElement: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visible(val) {
|
||||||
|
this.dialogVisible = val;
|
||||||
|
if (val) {
|
||||||
|
this.resetData();
|
||||||
|
this.loadSessions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dialogVisible(val) {
|
||||||
|
if (!val) {
|
||||||
|
this.$emit('update:visible', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
messagesWithTime() {
|
||||||
|
if (!this.messages || this.messages.length === 0) return [];
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const TIME_INTERVAL = 60 * 1000; // 1分钟的时间间隔(毫秒)
|
||||||
|
|
||||||
|
// 添加第一条消息的时间标记
|
||||||
|
if (this.messages[0]) {
|
||||||
|
result.push({
|
||||||
|
type: 'time',
|
||||||
|
content: this.formatTime(this.messages[0].createdAt),
|
||||||
|
id: `time-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理消息列表
|
||||||
|
for (let i = 0; i < this.messages.length; i++) {
|
||||||
|
const currentMessage = this.messages[i];
|
||||||
|
result.push(currentMessage);
|
||||||
|
|
||||||
|
// 检查是否需要添加时间标记
|
||||||
|
if (i < this.messages.length - 1) {
|
||||||
|
const currentTime = new Date(currentMessage.createdAt).getTime();
|
||||||
|
const nextTime = new Date(this.messages[i + 1].createdAt).getTime();
|
||||||
|
|
||||||
|
if (nextTime - currentTime > TIME_INTERVAL) {
|
||||||
|
result.push({
|
||||||
|
type: 'time',
|
||||||
|
content: this.formatTime(this.messages[i + 1].createdAt),
|
||||||
|
id: `time-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resetData() {
|
||||||
|
this.sessions = [];
|
||||||
|
this.messages = [];
|
||||||
|
this.currentSessionId = '';
|
||||||
|
this.currentMacAddress = '';
|
||||||
|
this.page = 1;
|
||||||
|
this.loading = false;
|
||||||
|
this.hasMore = true;
|
||||||
|
this.isFirstLoad = true;
|
||||||
|
},
|
||||||
|
handleClose() {
|
||||||
|
this.dialogVisible = false;
|
||||||
|
},
|
||||||
|
loadSessions() {
|
||||||
|
if (this.loading || (!this.isFirstLoad && !this.hasMore)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
const params = {
|
||||||
|
page: this.page,
|
||||||
|
limit: this.limit
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.agent.getAgentSessions(this.agentId, params, (res) => {
|
||||||
|
if (res.data && res.data.data && Array.isArray(res.data.data.list)) {
|
||||||
|
const list = res.data.data.list;
|
||||||
|
this.hasMore = list.length === this.limit;
|
||||||
|
|
||||||
|
this.sessions = [...this.sessions, ...list];
|
||||||
|
this.page++;
|
||||||
|
|
||||||
|
if (this.sessions.length > 0 && !this.currentSessionId) {
|
||||||
|
this.selectSession(this.sessions[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
this.isFirstLoad = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectSession(session) {
|
||||||
|
this.currentSessionId = session.sessionId;
|
||||||
|
Api.agent.getAgentChatHistory(this.agentId, session.sessionId, (res) => {
|
||||||
|
if (res.data && res.data.data) {
|
||||||
|
this.messages = res.data.data;
|
||||||
|
if (this.messages.length > 0 && this.messages[0].macAddress) {
|
||||||
|
this.currentMacAddress = this.messages[0].macAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleScroll(e) {
|
||||||
|
if (this.scrollTimer) {
|
||||||
|
clearTimeout(this.scrollTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollTimer = setTimeout(() => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
|
// 当滚动到底部时加载更多
|
||||||
|
if (scrollHeight - scrollTop <= clientHeight + 50) {
|
||||||
|
this.loadSessions();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
formatTime(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
|
if (date >= today) {
|
||||||
|
return `今天 ${hours}:${minutes}`;
|
||||||
|
} else if (date >= yesterday) {
|
||||||
|
return `昨天 ${hours}:${minutes}`;
|
||||||
|
} else {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getAudioIconClass(message) {
|
||||||
|
if (this.playingAudioId === message.audioId) {
|
||||||
|
return 'el-icon-loading';
|
||||||
|
}
|
||||||
|
return 'el-icon-video-play';
|
||||||
|
},
|
||||||
|
playAudio(message) {
|
||||||
|
if (this.playingAudioId === message.audioId) {
|
||||||
|
// 如果正在播放当前音频,则停止播放
|
||||||
|
if (this.audioElement) {
|
||||||
|
this.audioElement.pause();
|
||||||
|
this.audioElement = null;
|
||||||
|
}
|
||||||
|
this.playingAudioId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止当前正在播放的音频
|
||||||
|
if (this.audioElement) {
|
||||||
|
this.audioElement.pause();
|
||||||
|
this.audioElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先获取音频下载ID
|
||||||
|
this.playingAudioId = message.audioId;
|
||||||
|
Api.agent.getAudioId(message.audioId, (res) => {
|
||||||
|
if (res.data && res.data.data) {
|
||||||
|
// 使用获取到的下载ID播放音频
|
||||||
|
this.audioElement = new Audio(Api.getServiceUrl() + `/agent/play/${res.data.data}`);
|
||||||
|
|
||||||
|
this.audioElement.onended = () => {
|
||||||
|
this.playingAudioId = null;
|
||||||
|
this.audioElement = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.audioElement.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getUserAvatar(sessionId) {
|
||||||
|
// 从 sessionId 中提取所有数字
|
||||||
|
const numbers = sessionId.match(/\d+/g);
|
||||||
|
if (!numbers) return require('@/assets/user-avatar1.png');
|
||||||
|
|
||||||
|
// 将所有数字相加
|
||||||
|
const sum = numbers.reduce((acc, num) => acc + parseInt(num), 0);
|
||||||
|
|
||||||
|
// 计算模5并加1,得到1-5之间的数字
|
||||||
|
const avatarIndex = (sum % 5) + 1;
|
||||||
|
|
||||||
|
// 返回对应的头像图片
|
||||||
|
return require(`@/assets/user-avatar${avatarIndex}.png`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
width: 250px;
|
||||||
|
border-right: 1px solid #eee;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item.active {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-time {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #272727;
|
||||||
|
float: left;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
/* 为消息数量留出空间 */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-count {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #b4b4b4;
|
||||||
|
border-radius: 20px;
|
||||||
|
float: left;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.user-message {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
max-width: 60%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin: 0 10px;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 20px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 5px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message .message-content {
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: white;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message .audio-icon {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 10px 30px 10px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-session-selected {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-divider {
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-divider::before,
|
||||||
|
.time-divider::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 30%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: #eee;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-history-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 700px;
|
||||||
|
margin: 0 !important;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
height: 90vh;
|
||||||
|
max-width: 85vw;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-dialog .el-dialog__header {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
padding: 15px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-dialog .el-dialog__body {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: calc(90vh - 54px);
|
||||||
|
/* 减去标题栏的高度 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,9 +26,16 @@
|
|||||||
<div class="settings-btn" @click="handleDeviceManage">
|
<div class="settings-btn" @click="handleDeviceManage">
|
||||||
设备管理({{ device.deviceCount }})
|
设备管理({{ device.deviceCount }})
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-btn" @click="handleChatHistory"
|
||||||
|
:class="{ 'disabled-btn': device.memModelId === 'Memory_nomem' }">
|
||||||
|
<el-tooltip v-if="device.memModelId === 'Memory_nomem'" content="未开启记忆" placement="top">
|
||||||
|
<span>聊天记录</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else>聊天记录</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-info">
|
<div class="version-info">
|
||||||
<div>最近对话:{{ device.lastConnectedAt }}</div>
|
<div>最近对话:{{ formattedLastConnectedTime }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -42,6 +49,27 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return { switchValue: false }
|
return { switchValue: false }
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
formattedLastConnectedTime() {
|
||||||
|
if (!this.device.lastConnectedAt) return '暂未对话';
|
||||||
|
|
||||||
|
const lastTime = new Date(this.device.lastConnectedAt);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMinutes = Math.floor((now - lastTime) / (1000 * 60));
|
||||||
|
|
||||||
|
if (diffMinutes <= 1) {
|
||||||
|
return '刚刚';
|
||||||
|
} else if (diffMinutes < 60) {
|
||||||
|
return `${diffMinutes}分钟前`;
|
||||||
|
} else if (diffMinutes < 24 * 60) {
|
||||||
|
const hours = Math.floor(diffMinutes / 60);
|
||||||
|
const minutes = diffMinutes % 60;
|
||||||
|
return `${hours}小时${minutes > 0 ? minutes + '分钟' : ''}前`;
|
||||||
|
} else {
|
||||||
|
return this.device.lastConnectedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleDelete() {
|
handleDelete() {
|
||||||
this.$emit('delete', this.device.agentId)
|
this.$emit('delete', this.device.agentId)
|
||||||
@@ -51,6 +79,12 @@ export default {
|
|||||||
},
|
},
|
||||||
handleDeviceManage() {
|
handleDeviceManage() {
|
||||||
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
|
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
|
||||||
|
},
|
||||||
|
handleChatHistory() {
|
||||||
|
if (this.device.memModelId === 'Memory_nomem') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$emit('chat-history', { agentId: this.device.agentId, agentName: this.device.agentName })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +127,12 @@ export default {
|
|||||||
color: #979db1;
|
color: #979db1;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disabled-btn {
|
||||||
|
background: #e6e6e6;
|
||||||
|
color: #999;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<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="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" @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>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
|
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
|
||||||
:status="uploadStatus"></el-progress>
|
:status="uploadStatus"></el-progress>
|
||||||
<div class="hint-text">
|
<div class="hint-text">
|
||||||
<span>温馨提示:请上传xiaozhi.bin文件,而不是merged-binary.bin文件</span>
|
<span>温馨提示:请上传合并前的xiaozhi.bin文件,而不是合并后的merged-binary.bin文件</span>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
@@ -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() {
|
||||||
@@ -188,26 +198,30 @@ export default {
|
|||||||
if (!this.form.id) { // 只在新增时重置
|
if (!this.form.id) { // 只在新增时重置
|
||||||
this.form.firmwarePath = ''
|
this.form.firmwarePath = ''
|
||||||
this.form.size = 0
|
this.form.size = 0
|
||||||
// 重置上传组件
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.upload) {
|
|
||||||
this.$refs.upload.clearFiles()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
// 无论是否编辑模式,都重置上传组件
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.upload) {
|
||||||
|
this.$refs.upload.clearFiles()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +229,6 @@ export default {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: #979db1;
|
font-size: 14px;
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer :visible.sync="dialogVisible" direction="rtl" size="50%" :wrapperClosable="false" :withHeader="false">
|
||||||
|
<!-- 自定义标题区域 -->
|
||||||
|
<div class="custom-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h3 class="bold-title">功能管理</h3>
|
||||||
|
</div>
|
||||||
|
<button class="custom-close-btn" @click="closeDialog">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="function-manager">
|
||||||
|
<!-- 左侧:未选功能 -->
|
||||||
|
<div class="function-column">
|
||||||
|
<div class="column-header">
|
||||||
|
<h4 class="column-title">未选功能</h4>
|
||||||
|
<el-button type="text" @click="selectAll" class="select-all-btn">全选</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="function-list">
|
||||||
|
<div v-for="func in unselected" :key="func.name" class="function-item">
|
||||||
|
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)" @click.native.stop></el-checkbox>
|
||||||
|
<div class="func-tag" @click="handleFunctionClick(func)">
|
||||||
|
<div class="color-dot" :style="{backgroundColor: getFunctionColor(func.name)}"></div>
|
||||||
|
<span>{{ func.name }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tooltip class="item" effect="dark" :content="func.description || '暂无功能描述'" placement="top">
|
||||||
|
<img src="@/assets/home/info.png" alt="" class="info-icon">
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中间:已选功能 -->
|
||||||
|
<div class="function-column">
|
||||||
|
<div class="column-header">
|
||||||
|
<h4 class="column-title">已选功能</h4>
|
||||||
|
<el-button type="text" @click="deselectAll" class="select-all-btn">全选</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="function-list">
|
||||||
|
<div v-for="func in selectedList" :key="func.name" class="function-item">
|
||||||
|
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)" @click.native.stop></el-checkbox>
|
||||||
|
<div class="func-tag" @click="handleFunctionClick(func)">
|
||||||
|
<div class="color-dot" :style="{backgroundColor: getFunctionColor(func.name)}"></div>
|
||||||
|
<span>{{ func.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:参数配置 -->
|
||||||
|
<div class="params-column">
|
||||||
|
<h4 v-if="currentFunction" class="column-title">参数配置 - {{ currentFunction.name }}</h4>
|
||||||
|
<div v-if="currentFunction" class="params-container">
|
||||||
|
<el-form :model="currentFunction" size="mini" class="param-form" v-loading="loading" element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||||
|
<el-form-item v-for="(value, key) in currentFunction.params" :key="key" :label="key" class="param-item">
|
||||||
|
<el-input v-model="currentFunction.params[key]" size="mini" class="param-input" @change="(val) => handleParamChange(currentFunction, key, val)"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-tip">请选择已配置的功能进行参数设置</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="closeDialog">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSelection">保存配置</el-button>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
functions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dialogVisible: this.value,
|
||||||
|
selectedNames: [],
|
||||||
|
currentFunction: null,
|
||||||
|
modifiedFunctions: {},
|
||||||
|
allFunctions: [
|
||||||
|
{name: '天气', params: {city: '北京'}, description: '查看指定城市的天气情况'},
|
||||||
|
{name: '新闻', params: {type: '科技'}, description: '获取最新科技类新闻资讯'},
|
||||||
|
{name: '工具', params: {category: '常用'}, description: '提供常用工具集合'},
|
||||||
|
{name: '退出', params: {}, description: '退出当前系统'},
|
||||||
|
{name: '音乐', params: {genre: '流行'}, description: '播放流行音乐'},
|
||||||
|
{name: '翻译', params: {from: '中文', to: '英文'}, description: '提供中英文互译功能'},
|
||||||
|
{name: '计算', params: {precision: '2'}, description: '提供精确计算功能'},
|
||||||
|
{name: '日历', params: {view: '月'}, description: '查看月历视图'}
|
||||||
|
],
|
||||||
|
functionColorMap: [
|
||||||
|
'#FF6B6B', '#4ECDC4', '#45B7D1',
|
||||||
|
'#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E'
|
||||||
|
],
|
||||||
|
tempFunctions: {},
|
||||||
|
// 添加一个标志位来跟踪是否已经保存
|
||||||
|
hasSaved: false,
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selectedList() {
|
||||||
|
return this.allFunctions.filter(f => this.selectedNames.includes(f.name));
|
||||||
|
},
|
||||||
|
unselected() {
|
||||||
|
return this.allFunctions.filter(f => !this.selectedNames.includes(f.name));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value(newVal) {
|
||||||
|
this.dialogVisible = newVal;
|
||||||
|
if (newVal) {
|
||||||
|
this.selectedNames = this.functions.map(f => f.name);
|
||||||
|
this.currentFunction = this.selectedList[0] || null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dialogVisible(newVal) {
|
||||||
|
this.$emit('input', newVal);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleFunctionClick(func) {
|
||||||
|
if (this.selectedNames.includes(func.name)) {
|
||||||
|
this.loading = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
const tempFunc = this.tempFunctions[func.name];
|
||||||
|
this.currentFunction = tempFunc ? tempFunc : JSON.parse(JSON.stringify(func));
|
||||||
|
this.loading = false;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleParamChange(func, key, value) {
|
||||||
|
if (!this.tempFunctions[func.name]) {
|
||||||
|
this.tempFunctions[func.name] = JSON.parse(JSON.stringify(func));
|
||||||
|
}
|
||||||
|
this.tempFunctions[func.name].params[key] = value;
|
||||||
|
},
|
||||||
|
handleCheckboxChange(func, checked) {
|
||||||
|
if (checked) {
|
||||||
|
if (!this.selectedNames.includes(func.name)) {
|
||||||
|
this.selectedNames = [...this.selectedNames, func.name];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectedNames = this.selectedNames.filter(name => name !== func.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedList.length > 0) {
|
||||||
|
this.currentFunction = this.selectedList[0];
|
||||||
|
} else {
|
||||||
|
this.currentFunction = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll() {
|
||||||
|
this.selectedNames = [...this.allFunctions.map(f => f.name)];
|
||||||
|
if (this.selectedList.length > 0) {
|
||||||
|
this.currentFunction = JSON.parse(JSON.stringify(this.selectedList[0]));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deselectAll() {
|
||||||
|
this.selectedNames = [];
|
||||||
|
this.currentFunction = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
this.tempFunctions = {};
|
||||||
|
this.selectedNames = this.functions.map(f => f.name);
|
||||||
|
this.currentFunction = null;
|
||||||
|
this.dialogVisible = false;
|
||||||
|
this.$emit('input', false);
|
||||||
|
this.$emit('dialog-closed', false);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSelection() {
|
||||||
|
Object.keys(this.tempFunctions).forEach(name => {
|
||||||
|
this.modifiedFunctions[name] = JSON.parse(JSON.stringify(this.tempFunctions[name]));
|
||||||
|
});
|
||||||
|
this.tempFunctions = {};
|
||||||
|
this.hasSaved = true;
|
||||||
|
|
||||||
|
const selected = this.selectedList.map(f => {
|
||||||
|
const modified = this.modifiedFunctions[f.name];
|
||||||
|
return modified || f;
|
||||||
|
}).map(f => ({
|
||||||
|
...f,
|
||||||
|
params: JSON.parse(JSON.stringify(f.params))
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.$emit('update-functions', selected);
|
||||||
|
this.dialogVisible = false;
|
||||||
|
this.$message.success('配置保存成功');
|
||||||
|
// 通知父组件对话框已关闭且已保存
|
||||||
|
this.$emit('dialog-closed', true);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFunctionColor(name) {
|
||||||
|
const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
return this.functionColorMap[hash % 7];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.function-manager {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 0.5fr) minmax(120px, 0.5fr) minmax(200px, 2fr);
|
||||||
|
gap: 12px;
|
||||||
|
height: calc(70vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #EBEEF5;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bold-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all-btn {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-column {
|
||||||
|
position: relative;
|
||||||
|
width: auto;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #EBEEF5;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-column::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 4px 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-column {
|
||||||
|
min-width: 280px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-column::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-title {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.func-tag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-form {
|
||||||
|
::v-deep .el-form-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.el-form-item__label {
|
||||||
|
font-size: 14px !important;
|
||||||
|
color: #606266;
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__content {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
.el-input__inner {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-container {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 20px;
|
||||||
|
color: #909399;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 1vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 10px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #cfcfcf;
|
||||||
|
background: none;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: lighter;
|
||||||
|
color: #cfcfcf;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-close-btn:hover {
|
||||||
|
color: #409EFF;
|
||||||
|
border-color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .el-checkbox__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,12 +35,12 @@
|
|||||||
OTA管理
|
OTA管理
|
||||||
</div>
|
</div>
|
||||||
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
|
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
|
||||||
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' }">
|
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' }" @visible-change="handleParamDropdownVisibleChange">
|
||||||
<span class="el-dropdown-link">
|
<span class="el-dropdown-link">
|
||||||
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
|
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
|
||||||
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||||
参数字典
|
参数字典
|
||||||
<i class="el-icon-arrow-down el-icon--right"></i>
|
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': paramDropdownVisible }"></i>
|
||||||
</span>
|
</span>
|
||||||
<el-dropdown-menu slot="dropdown">
|
<el-dropdown-menu slot="dropdown">
|
||||||
<el-dropdown-item @click.native="goParamManagement">
|
<el-dropdown-item @click.native="goParamManagement">
|
||||||
@@ -49,22 +49,26 @@
|
|||||||
<el-dropdown-item @click.native="goDictManagement">
|
<el-dropdown-item @click.native="goDictManagement">
|
||||||
字典管理
|
字典管理
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click.native="goProviderManagement">
|
||||||
|
供应器管理
|
||||||
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧元素 -->
|
<!-- 右侧元素 -->
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="search-container" v-if="$route.path === '/home'">
|
<div class="search-container" v-if="$route.path === '/home' && !(isSuperAdmin && isSmallScreen)">
|
||||||
<el-input v-model="search" placeholder="输入名称搜索.." class="custom-search-input"
|
<el-input v-model="search" placeholder="输入名称搜索.." class="custom-search-input"
|
||||||
@keyup.enter.native="handleSearch">
|
@keyup.enter.native="handleSearch">
|
||||||
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
|
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" />
|
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" />
|
||||||
<el-dropdown trigger="click" class="user-dropdown">
|
<el-dropdown trigger="click" class="user-dropdown" @visible-change="handleUserDropdownVisibleChange">
|
||||||
<span class="el-dropdown-link">
|
<span class="el-dropdown-link">
|
||||||
{{ userInfo.username || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
|
{{ userInfo.username || '加载中...' }}
|
||||||
|
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': userDropdownVisible }"></i>
|
||||||
</span>
|
</span>
|
||||||
<el-dropdown-menu slot="dropdown">
|
<el-dropdown-menu slot="dropdown">
|
||||||
<el-dropdown-item @click.native="showChangePasswordDialog">修改密码</el-dropdown-item>
|
<el-dropdown-item @click.native="showChangePasswordDialog">修改密码</el-dropdown-item>
|
||||||
@@ -84,7 +88,6 @@ import userApi from '@/apis/module/user';
|
|||||||
import { mapActions, mapGetters } from 'vuex';
|
import { mapActions, mapGetters } from 'vuex';
|
||||||
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
|
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HeaderBar',
|
name: 'HeaderBar',
|
||||||
components: {
|
components: {
|
||||||
@@ -98,7 +101,10 @@ export default {
|
|||||||
username: '',
|
username: '',
|
||||||
mobile: ''
|
mobile: ''
|
||||||
},
|
},
|
||||||
isChangePasswordDialogVisible: false // 控制修改密码弹窗的显示
|
isChangePasswordDialogVisible: false, // 控制修改密码弹窗的显示
|
||||||
|
userDropdownVisible: false,
|
||||||
|
paramDropdownVisible: false,
|
||||||
|
isSmallScreen: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -108,7 +114,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.fetchUserInfo()
|
this.fetchUserInfo();
|
||||||
|
this.checkScreenSize();
|
||||||
|
window.addEventListener('resize', this.checkScreenSize);
|
||||||
|
},
|
||||||
|
//移除事件监听器
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('resize', this.checkScreenSize);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goHome() {
|
goHome() {
|
||||||
@@ -130,6 +142,9 @@ export default {
|
|||||||
goDictManagement() {
|
goDictManagement() {
|
||||||
this.$router.push('/dict-management')
|
this.$router.push('/dict-management')
|
||||||
},
|
},
|
||||||
|
goProviderManagement() {
|
||||||
|
this.$router.push('/provider-management')
|
||||||
|
},
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
fetchUserInfo() {
|
fetchUserInfo() {
|
||||||
userApi.getUserInfo(({ data }) => {
|
userApi.getUserInfo(({ data }) => {
|
||||||
@@ -139,7 +154,9 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
checkScreenSize() {
|
||||||
|
this.isSmallScreen = window.innerWidth <= 1386;
|
||||||
|
},
|
||||||
// 处理搜索
|
// 处理搜索
|
||||||
handleSearch() {
|
handleSearch() {
|
||||||
const searchValue = this.search.trim();
|
const searchValue = this.search.trim();
|
||||||
@@ -184,6 +201,13 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
handleUserDropdownVisibleChange(visible) {
|
||||||
|
this.userDropdownVisible = visible;
|
||||||
|
},
|
||||||
|
// 监听第二个下拉菜单的可见状态变化
|
||||||
|
handleParamDropdownVisibleChange(visible) {
|
||||||
|
this.paramDropdownVisible = visible;
|
||||||
|
},
|
||||||
|
|
||||||
// 使用 mapActions 引入 Vuex 的 logout action
|
// 使用 mapActions 引入 Vuex 的 logout action
|
||||||
...mapActions(['logout'])
|
...mapActions(['logout'])
|
||||||
@@ -191,7 +215,7 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
.header {
|
.header {
|
||||||
background: #f6fcfe66;
|
background: #f6fcfe66;
|
||||||
border: 1px solid #fff;
|
border: 1px solid #fff;
|
||||||
@@ -243,8 +267,6 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.equipment-management {
|
.equipment-management {
|
||||||
padding: 0 9px;
|
|
||||||
width: px;
|
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
background: #deeafe;
|
background: #deeafe;
|
||||||
@@ -260,7 +282,8 @@ export default {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* 防止导航按钮被压缩 */
|
/* 防止导航按钮被压缩 */
|
||||||
padding: 0px 15px;
|
padding: 0 15px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.equipment-management.active-tab {
|
.equipment-management.active-tab {
|
||||||
@@ -309,6 +332,25 @@ export default {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.more-dropdown {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-dropdown .el-dropdown-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate-down {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon-arrow-down {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* 响应式调整 */
|
/* 响应式调整 */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.header-center {
|
.header-center {
|
||||||
@@ -316,50 +358,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.equipment-management {
|
.equipment-management {
|
||||||
width: 70px;
|
width: 79px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.search-container {
|
|
||||||
margin-right: 10px;
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.header-left {
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container {
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.search-container {
|
|
||||||
max-width: 145px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-search-input>>>.el-input__inner {
|
|
||||||
padding-left: 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.search-container {
|
|
||||||
max-width: 120px;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.equipment-management.more-dropdown {
|
.equipment-management.more-dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -378,13 +381,4 @@ export default {
|
|||||||
color: #606266;
|
color: #606266;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.equipment-management.more-dropdown .el-dropdown-menu {
|
|
||||||
position: fixed;
|
|
||||||
right: 10px;
|
|
||||||
top: 60px;
|
|
||||||
z-index: 2000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :visible="visible" @update:visible="handleVisibleChange" width="57%" center custom-class="custom-dialog" :show-close="false" class="center-dialog">
|
||||||
|
|
||||||
|
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
||||||
|
<div style="font-size: 30px; color: #3d4566; margin-top: -15px; margin-bottom: 20px; text-align: center;">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="custom-close-btn" @click="handleClose">×</button>
|
||||||
|
|
||||||
|
<el-form :model="form" label-width="100px" :rules="rules" ref="form" class="custom-form">
|
||||||
|
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
|
||||||
|
<el-form-item label="类别" prop="model_type" style="flex: 1;">
|
||||||
|
<el-select v-model="form.model_type" placeholder="请选择类别" class="custom-input-bg" style="width: 100%;">
|
||||||
|
<el-option v-for="item in modelTypes" :key="item.value" :label="item.label" :value="item.value">
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="供应器编码" prop="provider_code" style="flex: 1;">
|
||||||
|
<el-input v-model="form.provider_code" placeholder="请输入供应器编码" class="custom-input-bg"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
|
||||||
|
<el-form-item label="名称" prop="name" style="flex: 1;">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入供应器名称" class="custom-input-bg"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序" prop="sort" style="flex: 1;">
|
||||||
|
<el-input-number v-model="form.sort" :min="0" controls-position="right" class="custom-input-bg" style="width: 100%;"></el-input-number>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;">
|
||||||
|
字段配置
|
||||||
|
<div style="display: inline-block; float: right;">
|
||||||
|
<el-button type="primary" @click="addField" size="small" style="background: #5bc98c; border: none;" :disabled="hasIncompleteFields">
|
||||||
|
添加
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="toggleSelectAllFields" size="small" style="background: #5f70f3; border: none; margin-left: 10px;">
|
||||||
|
{{ isAllFieldsSelected ? '取消全选' : '全选' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" @click="batchRemoveFields" size="small" style="background: red; border: none; margin-left: 10px;">
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="height: 2px; background: #e9e9e9; margin-bottom: 22px;"></div>
|
||||||
|
|
||||||
|
<div class="fields-container">
|
||||||
|
<el-table :data="form.fields" style="width: 100%;" border size="medium" :key="tableKey">
|
||||||
|
<el-table-column label="选择" align="center" width="50">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-checkbox v-model="scope.row.selected" @change="handleFieldSelectChange"></el-checkbox>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="字段key">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<template v-if="scope.row.editing">
|
||||||
|
<el-input v-model="scope.row.key" placeholder="字段key"></el-input>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ scope.row.key }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="字段标签">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<template v-if="scope.row.editing">
|
||||||
|
<el-input v-model="scope.row.label" placeholder="字段标签"></el-input>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ scope.row.label }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="字段类型">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<template v-if="scope.row.editing">
|
||||||
|
<el-select v-model="scope.row.type" placeholder="类型">
|
||||||
|
<el-option label="字符串" value="string"></el-option>
|
||||||
|
<el-option label="数字" value="number"></el-option>
|
||||||
|
<el-option label="布尔值" value="boolean"></el-option>
|
||||||
|
<el-option label="字典" value="dict"></el-option>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ getTypeLabel(scope.row.type) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="默认值">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<template v-if="scope.row.editing">
|
||||||
|
<el-input v-model="scope.row.default_value" placeholder="请输入默认值"></el-input>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ scope.row.default_value }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-button v-if="!scope.row.editing" type="primary" size="mini" @click="startEditing(scope.row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else type="success" size="mini" @click="stopEditing(scope.row)">
|
||||||
|
完成
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" size="mini" @click="removeField(scope.$index)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: center;">
|
||||||
|
<el-button type="primary" @click="submit" class="save-btn" :loading="saving">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
title: String,
|
||||||
|
visible: Boolean,
|
||||||
|
form: Object,
|
||||||
|
modelTypes: Array
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
saving: false,
|
||||||
|
rules: {
|
||||||
|
model_type: [{required: true, message: '请选择类别', trigger: 'change'}],
|
||||||
|
provider_code: [{required: true, message: '请输入供应器编码', trigger: 'blur'}],
|
||||||
|
name: [{required: true, message: '请输入供应器名称', trigger: 'blur'}]
|
||||||
|
},
|
||||||
|
isAllFieldsSelected: false,
|
||||||
|
tableKey: 0 // 用于强制表格重新渲染
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hasIncompleteFields() {
|
||||||
|
return this.form.fields && this.form.fields.some(field =>
|
||||||
|
!field.key || !field.label || !field.type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getTypeLabel(type) {
|
||||||
|
const typeMap = {
|
||||||
|
'string': '字符串',
|
||||||
|
'number': '数字',
|
||||||
|
'boolean': '布尔值',
|
||||||
|
'dict': '字典'
|
||||||
|
};
|
||||||
|
return typeMap[type];
|
||||||
|
},
|
||||||
|
|
||||||
|
startEditing(row) {
|
||||||
|
this.$set(row, 'editing', true);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopEditing(row) {
|
||||||
|
this.$set(row, 'editing', false);
|
||||||
|
|
||||||
|
const index = this.form.fields.indexOf(row);
|
||||||
|
if (index > -1) {
|
||||||
|
this.form.fields.splice(index, 1);
|
||||||
|
this.form.fields.push(row);
|
||||||
|
this.forceTableRerender();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFieldSelectChange() {
|
||||||
|
this.isAllFieldsSelected = this.form.fields.length > 0 &&
|
||||||
|
this.form.fields.every(field => field.selected);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSelectAllFields() {
|
||||||
|
this.isAllFieldsSelected = !this.isAllFieldsSelected;
|
||||||
|
this.form.fields = this.form.fields.map(field => ({
|
||||||
|
...field,
|
||||||
|
selected: this.isAllFieldsSelected
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleVisibleChange(val) {
|
||||||
|
this.$emit('update:visible', val);
|
||||||
|
if (!val) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleClose() {
|
||||||
|
this.resetForm();
|
||||||
|
this.$emit('update:visible', false);
|
||||||
|
this.$emit('cancel');
|
||||||
|
},
|
||||||
|
|
||||||
|
addField() {
|
||||||
|
if (this.hasIncompleteFields) {
|
||||||
|
this.$message.warning({
|
||||||
|
message: '请先完成当前字段的编辑',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.form.fields.unshift({
|
||||||
|
key: '',
|
||||||
|
label: '',
|
||||||
|
type: 'string',
|
||||||
|
default_value: '',
|
||||||
|
selected: false,
|
||||||
|
editing: true
|
||||||
|
});
|
||||||
|
this.forceTableRerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeField(index) {
|
||||||
|
this.$confirm('确定要删除该字段吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
this.form.fields = this.form.fields.filter((_, i) => i !== index);
|
||||||
|
this.updateSelectAllStatus();
|
||||||
|
this.forceTableRerender();
|
||||||
|
this.$message.success({
|
||||||
|
message: '删除成功',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
this.$message.info({
|
||||||
|
message: '已取消删除',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
batchRemoveFields() {
|
||||||
|
const selectedFields = this.form.fields.filter(field => field.selected);
|
||||||
|
if (selectedFields.length === 0) {
|
||||||
|
this.$message.warning({
|
||||||
|
message: '请先选择要删除的字段',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$confirm(`确定要删除选中的 ${selectedFields.length} 个字段吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
this.form.fields = this.form.fields.filter(field => !field.selected);
|
||||||
|
this.isAllFieldsSelected = false;
|
||||||
|
this.forceTableRerender();
|
||||||
|
this.$message.success({
|
||||||
|
message: `成功删除 ${selectedFields.length} 个字段`,
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
this.$message.info({
|
||||||
|
message: '已取消删除',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSelectAllStatus() {
|
||||||
|
this.isAllFieldsSelected = this.form.fields.length > 0 &&
|
||||||
|
this.form.fields.every(field => field.selected);
|
||||||
|
},
|
||||||
|
|
||||||
|
forceTableRerender() {
|
||||||
|
this.tableKey += 1; // 改变key值强制表格重新渲染
|
||||||
|
},
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
this.$refs.form.validate(valid => {
|
||||||
|
if (valid) {
|
||||||
|
const editingField = this.form.fields.find(field => field.editing);
|
||||||
|
if (editingField) {
|
||||||
|
this.$message.warning({
|
||||||
|
message: '请先完成当前字段的编辑',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.form.fields = this.form.fields.map(field => ({
|
||||||
|
...field,
|
||||||
|
selected: false
|
||||||
|
}));
|
||||||
|
this.isAllFieldsSelected = false;
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
this.$emit('submit', {
|
||||||
|
form: this.form,
|
||||||
|
done: () => {
|
||||||
|
this.saving = false;
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.$refs.form.resetFields();
|
||||||
|
if (this.form.fields) {
|
||||||
|
this.form.fields.forEach(field => {
|
||||||
|
field.selected = false;
|
||||||
|
field.editing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.isAllFieldsSelected = false;
|
||||||
|
this.forceTableRerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visible(val) {
|
||||||
|
if (!val) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
::v-deep .custom-dialog.el-dialog {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .custom-dialog .el-dialog__header {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #cfcfcf;
|
||||||
|
background: none;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: lighter;
|
||||||
|
color: #cfcfcf;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-close-btn:hover {
|
||||||
|
color: #409EFF;
|
||||||
|
border-color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-form .el-form-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-form .el-form-item__label {
|
||||||
|
color: #3d4566;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input-bg .el-input__inner {
|
||||||
|
background-color: #f6f8fc;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input-bg .el-input__inner::-webkit-input-placeholder {
|
||||||
|
color: #9c9f9e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-container {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: #e6f0fd;
|
||||||
|
color: #237ff4;
|
||||||
|
border: 1px solid #b3d1ff;
|
||||||
|
width: 150px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
background: linear-gradient(to right, #237ff4, #9c40d5);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table th, .el-table td {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button.is-circle{
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -336,6 +336,14 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
saveEdit(row) {
|
saveEdit(row) {
|
||||||
|
if (!row.voiceCode || !row.voiceName || !row.languageType) {
|
||||||
|
this.$message.error({
|
||||||
|
message: '音色编码、音色名称和语言类型不能为空',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -408,6 +416,12 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNew() {
|
addNew() {
|
||||||
|
const hasEditing = this.ttsModels.some(row => row.editing);
|
||||||
|
if (hasEditing) {
|
||||||
|
this.$message.warning('请先完成当前编辑再新增');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maxSort = this.ttsModels.length > 0
|
const maxSort = this.ttsModels.length > 0
|
||||||
? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0))
|
? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0))
|
||||||
: 0;
|
: 0;
|
||||||
@@ -487,7 +501,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 +662,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 +700,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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输入框自适应 */
|
/* 输入框自适应 */
|
||||||
|
|||||||
@@ -90,7 +90,14 @@ const routes = [
|
|||||||
component: function () {
|
component: function () {
|
||||||
return import('../views/DictManagement.vue')
|
return import('../views/DictManagement.vue')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
path: '/provider-management',
|
||||||
|
name: 'ProviderManagement',
|
||||||
|
component: function () {
|
||||||
|
return import('../views/ProviderManagement.vue')
|
||||||
|
}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
const router = new VueRouter({
|
const router = new VueRouter({
|
||||||
base: process.env.VUE_APP_PUBLIC_PATH || '/',
|
base: process.env.VUE_APP_PUBLIC_PATH || '/',
|
||||||
|
|||||||
@@ -294,20 +294,13 @@ export default {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.deviceList = data.data.map(device => {
|
this.deviceList = data.data.map(device => {
|
||||||
const bindDate = new Date(device.createDate);
|
|
||||||
const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth() + 1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`;
|
|
||||||
let formattedLastConversation = '';
|
|
||||||
if (device.lastConnectedAt) {
|
|
||||||
const lastConvoDate = new Date(device.lastConnectedAt);
|
|
||||||
formattedLastConversation = `${lastConvoDate.getFullYear()}-${(lastConvoDate.getMonth() + 1).toString().padStart(2, '0')}-${lastConvoDate.getDate().toString().padStart(2, '0')} ${lastConvoDate.getHours().toString().padStart(2, '0')}:${lastConvoDate.getMinutes().toString().padStart(2, '0')}:${lastConvoDate.getSeconds().toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
model: device.board,
|
model: device.board,
|
||||||
firmwareVersion: device.appVersion,
|
firmwareVersion: device.appVersion,
|
||||||
macAddress: device.macAddress,
|
macAddress: device.macAddress,
|
||||||
bindTime: formattedBindTime,
|
bindTime: device.createDate,
|
||||||
lastConversation: formattedLastConversation,
|
lastConversation: device.lastConnectedAt,
|
||||||
remark: device.alias,
|
remark: device.alias,
|
||||||
isEdit: false,
|
isEdit: false,
|
||||||
otaSwitch: device.autoUpdate === 1,
|
otaSwitch: device.autoUpdate === 1,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -25,8 +25,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column>
|
<el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column>
|
||||||
<el-table-column label="参数值" prop="paramValue" align="center"
|
<el-table-column label="参数值" prop="paramValue" align="center" show-overflow-tooltip>
|
||||||
show-overflow-tooltip></el-table-column>
|
<template slot-scope="scope">
|
||||||
|
<div v-if="isSensitiveParam(scope.row.paramCode)">
|
||||||
|
<span v-if="!scope.row.showValue">{{ maskSensitiveValue(scope.row.paramValue)
|
||||||
|
}}</span>
|
||||||
|
<span v-else>{{ scope.row.paramValue }}</span>
|
||||||
|
<el-button size="mini" type="text" @click="toggleSensitiveValue(scope.row)">
|
||||||
|
{{ scope.row.showValue ? '隐藏' : '查看' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<span v-else>{{ scope.row.paramValue }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="备注" prop="remark" align="center"></el-table-column>
|
<el-table-column label="备注" prop="remark" align="center"></el-table-column>
|
||||||
<el-table-column label="操作" align="center">
|
<el-table-column label="操作" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
@@ -100,6 +111,7 @@ export default {
|
|||||||
dialogVisible: false,
|
dialogVisible: false,
|
||||||
dialogTitle: "新增参数",
|
dialogTitle: "新增参数",
|
||||||
isAllSelected: false,
|
isAllSelected: false,
|
||||||
|
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
|
||||||
paramForm: {
|
paramForm: {
|
||||||
id: null,
|
id: null,
|
||||||
paramCode: "",
|
paramCode: "",
|
||||||
@@ -152,7 +164,8 @@ export default {
|
|||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.paramsList = data.data.list.map(item => ({
|
this.paramsList = data.data.list.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
selected: false
|
selected: false,
|
||||||
|
showValue: false
|
||||||
}));
|
}));
|
||||||
this.total = data.data.total;
|
this.total = data.data.total;
|
||||||
} else {
|
} else {
|
||||||
@@ -314,7 +327,18 @@ export default {
|
|||||||
goToPage(page) {
|
goToPage(page) {
|
||||||
this.currentPage = page;
|
this.currentPage = page;
|
||||||
this.fetchParams();
|
this.fetchParams();
|
||||||
}
|
},
|
||||||
|
isSensitiveParam(paramCode) {
|
||||||
|
return this.sensitive_keys.some(key => paramCode.toLowerCase().includes(key.toLowerCase()));
|
||||||
|
},
|
||||||
|
maskSensitiveValue(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
if (value.length <= 8) return '****';
|
||||||
|
return value.substring(0, 4) + '****' + value.substring(value.length - 4);
|
||||||
|
},
|
||||||
|
toggleSensitiveValue(row) {
|
||||||
|
this.$set(row, 'showValue', !row.showValue);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,936 @@
|
|||||||
|
<template>
|
||||||
|
<div class="welcome">
|
||||||
|
<HeaderBar/>
|
||||||
|
|
||||||
|
<div class="operation-bar">
|
||||||
|
<h2 class="page-title">供应器管理</h2>
|
||||||
|
<div class="right-operations">
|
||||||
|
<el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="handleDropdownVisibleChange">
|
||||||
|
<el-button class="category-btn">
|
||||||
|
类别筛选 {{ selectedModelTypeLabel }}<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down':DropdownVisible }"></i>
|
||||||
|
</el-button>
|
||||||
|
<el-dropdown-menu slot="dropdown">
|
||||||
|
<el-dropdown-item command="">全部</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value">
|
||||||
|
{{ item.label }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</el-dropdown>
|
||||||
|
<el-input placeholder="请输入供应器名称查询" v-model="searchName" class="search-input" @keyup.enter.native="handleSearch" clearable/>
|
||||||
|
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<div class="content-panel">
|
||||||
|
<div class="content-area">
|
||||||
|
<el-card class="params-card" shadow="never">
|
||||||
|
<el-table ref="providersTable" :data="filteredProvidersList" class="transparent-table" v-loading="loading"
|
||||||
|
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
|
||||||
|
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||||
|
:header-cell-class-name="headerCellClassName">
|
||||||
|
<el-table-column label="选择" align="center" width="120">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类别" prop="model_type" align="center" width="200">
|
||||||
|
<template slot="header" slot-scope="scope">
|
||||||
|
<el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="isDropdownOpen = $event">
|
||||||
|
<span class="dropdown-trigger" :class="{ 'active': isDropdownOpen }">
|
||||||
|
类别{{ selectedModelTypeLabel }} <i class="dropdown-arrow" :class="{ 'is-active': isDropdownOpen }"></i>
|
||||||
|
</span>
|
||||||
|
<el-dropdown-menu slot="dropdown">
|
||||||
|
<el-dropdown-item command="">全部</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value">
|
||||||
|
{{ item.label }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</el-dropdown>
|
||||||
|
</template>
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag :type="getModelTypeTag(scope.row.model_type)">
|
||||||
|
{{ getModelTypeLabel(scope.row.model_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="供应器编码" prop="provider_code" align="center" width="150"></el-table-column>
|
||||||
|
<el-table-column label="名称" prop="name" align="center"></el-table-column>
|
||||||
|
<el-table-column label="字段配置" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-popover placement="top-start" width="400" trigger="hover">
|
||||||
|
<div v-for="field in scope.row.fields" :key="field.key" class="field-item">
|
||||||
|
<span class="field-label">{{ field.label }}:</span>
|
||||||
|
<span class="field-type">{{ field.type }}</span>
|
||||||
|
<span v-if="isSensitiveField(field.key)" class="sensitive-tag">敏感</span>
|
||||||
|
</div>
|
||||||
|
<el-button slot="reference" size="mini" type="text">查看字段</el-button>
|
||||||
|
</el-popover>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="排序" prop="sort" align="center" width="80"></el-table-column>
|
||||||
|
<el-table-column label="操作" align="center" width="180">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-button size="mini" type="text" @click="editProvider(scope.row)">编辑</el-button>
|
||||||
|
<el-button size="mini" type="text" @click="deleteProvider(scope.row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="table_bottom">
|
||||||
|
<div class="ctrl_btn">
|
||||||
|
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
|
||||||
|
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
|
||||||
|
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelectedProviders">删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="custom-pagination">
|
||||||
|
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||||
|
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
|
||||||
|
首页
|
||||||
|
</button>
|
||||||
|
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button v-for="page in visiblePages" :key="page" class="pagination-btn" :class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<span class="total-text">共{{ total }}条记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑供应器对话框 -->
|
||||||
|
<provider-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="providerForm" :model-types="modelTypes" @submit="handleSubmit" @cancel="dialogVisible = false"/>
|
||||||
|
|
||||||
|
<el-footer>
|
||||||
|
<version-footer/>
|
||||||
|
</el-footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import HeaderBar from "@/components/HeaderBar.vue";
|
||||||
|
import ProviderDialog from "@/components/ProviderDialog.vue";
|
||||||
|
import VersionFooter from "@/components/VersionFooter.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {HeaderBar, ProviderDialog, VersionFooter},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
searchName: "",
|
||||||
|
searchModelType: "",
|
||||||
|
providersList: [],
|
||||||
|
modelTypes: [
|
||||||
|
{value: "ASR", label: "语音识别"},
|
||||||
|
{value: "TTS", label: "语音合成"},
|
||||||
|
{value: "LLM", label: "大语言模型"},
|
||||||
|
{value: "Intent", label: "意图识别"},
|
||||||
|
{value: "Memory", label: "记忆模块"},
|
||||||
|
{value: "VAD", label: "语音活动检测"}
|
||||||
|
],
|
||||||
|
currentPage: 1,
|
||||||
|
loading: false,
|
||||||
|
pageSize: 10,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
total: 0,
|
||||||
|
dialogVisible: false,
|
||||||
|
dialogTitle: "新增供应器",
|
||||||
|
isAllSelected: false,
|
||||||
|
isDropdownOpen: false,
|
||||||
|
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
|
||||||
|
providerForm: {
|
||||||
|
id: null,
|
||||||
|
model_type: "",
|
||||||
|
provider_code: "",
|
||||||
|
name: "",
|
||||||
|
fields: [],
|
||||||
|
sort: 0
|
||||||
|
},
|
||||||
|
DropdownVisible: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetchProviders();
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selectedModelTypeLabel() {
|
||||||
|
if (!this.searchModelType) return "(全部)";
|
||||||
|
const selectedType = this.modelTypes.find(item => item.value === this.searchModelType);
|
||||||
|
return selectedType ? `(${selectedType.label})` : "";
|
||||||
|
},
|
||||||
|
pageCount() {
|
||||||
|
return Math.ceil(this.total / this.pageSize);
|
||||||
|
},
|
||||||
|
visiblePages() {
|
||||||
|
const pages = [];
|
||||||
|
const maxVisible = 3;
|
||||||
|
let start = Math.max(1, this.currentPage - 1);
|
||||||
|
let end = Math.min(this.pageCount, start + maxVisible - 1);
|
||||||
|
|
||||||
|
if (end - start + 1 < maxVisible) {
|
||||||
|
start = Math.max(1, end - maxVisible + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
},
|
||||||
|
filteredProvidersList() {
|
||||||
|
let list = this.providersList.filter(item => {
|
||||||
|
const nameMatch = item.name.toLowerCase().includes(this.searchName.toLowerCase());
|
||||||
|
const typeMatch = !this.searchModelType || item.model_type === this.searchModelType;
|
||||||
|
return nameMatch && typeMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
list.sort((a, b) => a.sort - b.sort);
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const start = (this.currentPage - 1) * this.pageSize;
|
||||||
|
return list.slice(start, start + this.pageSize);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchProviders() {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
// 模拟API请求延迟
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
// 模拟数据 - 从数据库结构中提取
|
||||||
|
this.providersList = [
|
||||||
|
{
|
||||||
|
id: "SYSTEM_ASR_DoubaoASR",
|
||||||
|
model_type: "ASR",
|
||||||
|
provider_code: "doubao",
|
||||||
|
name: "火山引擎语音识别",
|
||||||
|
fields: JSON.parse('[{"key": "appid", "type": "string", "label": "应用ID"}, {"key": "access_token", "type": "string", "label": "访问令牌"}, {"key": "cluster", "type": "string", "label": "集群"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'),
|
||||||
|
sort: 3,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_ASR_FunASR",
|
||||||
|
model_type: "ASR",
|
||||||
|
provider_code: "fun_local",
|
||||||
|
name: "FunASR语音识别",
|
||||||
|
fields: JSON.parse('[{"key": "model_dir", "type": "string", "label": "模型目录"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'),
|
||||||
|
sort: 1,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_LLM_openai",
|
||||||
|
model_type: "LLM",
|
||||||
|
provider_code: "openai",
|
||||||
|
name: "OpenAI接口",
|
||||||
|
fields: JSON.parse('[{"key": "base_url", "type": "string", "label": "基础URL"}, {"key": "model_name", "type": "string", "label": "模型名称"}, {"key": "api_key", "type": "string", "label": "API密钥"}, {"key": "temperature", "type": "number", "label": "温度"}, {"key": "max_tokens", "type": "number", "label": "最大令牌数"}]'),
|
||||||
|
sort: 1,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_TTS_edge",
|
||||||
|
model_type: "TTS",
|
||||||
|
provider_code: "edge",
|
||||||
|
name: "Edge TTS",
|
||||||
|
fields: JSON.parse('[{"key": "voice", "type": "string", "label": "音色"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'),
|
||||||
|
sort: 1,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_Memory_mem0ai",
|
||||||
|
model_type: "Memory",
|
||||||
|
provider_code: "mem0ai",
|
||||||
|
name: "Mem0AI记忆",
|
||||||
|
fields: JSON.parse('[{"key": "api_key", "type": "string", "label": "API密钥"}]'),
|
||||||
|
sort: 1,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_VAD_SileroVAD",
|
||||||
|
model_type: "VAD",
|
||||||
|
provider_code: "silero",
|
||||||
|
name: "SileroVAD语音活动检测",
|
||||||
|
fields: JSON.parse('[{"key": "threshold", "type": "number", "label": "检测阈值"}, {"key": "model_dir", "type": "string", "label": "模型目录"}, {"key": "min_silence_duration_ms", "type": "number", "label": "最小静音时长"}]'),
|
||||||
|
sort: 1,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_Intent_nointent",
|
||||||
|
model_type: "Intent",
|
||||||
|
provider_code: "nointent",
|
||||||
|
name: "无意图识别",
|
||||||
|
fields: [],
|
||||||
|
sort: 1,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_TTS_aliyun",
|
||||||
|
model_type: "TTS",
|
||||||
|
provider_code: "aliyun",
|
||||||
|
name: "阿里云TTS",
|
||||||
|
fields: JSON.parse('[{"key": "output_dir", "type": "string", "label": "输出目录"}, {"key": "appkey", "type": "string", "label": "应用密钥"}, {"key": "token", "type": "string", "label": "访问令牌"}, {"key": "voice", "type": "string", "label": "音色"}, {"key": "access_key_id", "type": "string", "label": "访问密钥ID"}, {"key": "access_key_secret", "type": "string", "label": "访问密钥密码"}]'),
|
||||||
|
sort: 9,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_LLM_coze",
|
||||||
|
model_type: "LLM",
|
||||||
|
provider_code: "coze",
|
||||||
|
name: "Coze接口",
|
||||||
|
fields: JSON.parse('[{"key": "bot_id", "type": "string", "label": "机器人ID"}, {"key": "user_id", "type": "string", "label": "用户ID"}, {"key": "personal_access_token", "type": "string", "label": "个人访问令牌"}]'),
|
||||||
|
sort: 6,
|
||||||
|
selected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SYSTEM_TTS_TencentTTS",
|
||||||
|
model_type: "TTS",
|
||||||
|
provider_code: "tencent",
|
||||||
|
name: "腾讯语音合成",
|
||||||
|
fields: JSON.parse('[{"key": "appid", "type": "string", "label": "应用ID"}, {"key": "secret_id", "type": "string", "label": "Secret ID"}, {"key": "secret_key", "type": "string", "label": "Secret Key"}, {"key": "output_dir", "type": "string", "label": "输出目录"}, {"key": "region", "type": "string", "label": "区域"}, {"key": "voice", "type": "string", "label": "音色ID"}]'),
|
||||||
|
sort: 5,
|
||||||
|
selected: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
this.total = this.providersList.length;
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
handleSearch() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.fetchProviders();
|
||||||
|
},
|
||||||
|
handleSelectModelType(value) {
|
||||||
|
this.isDropdownOpen = false;
|
||||||
|
this.searchModelType = value;
|
||||||
|
this.handleSearch();
|
||||||
|
},
|
||||||
|
handleSelectAll() {
|
||||||
|
this.isAllSelected = !this.isAllSelected;
|
||||||
|
this.providersList.forEach(row => {
|
||||||
|
row.selected = this.isAllSelected;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
showAddDialog() {
|
||||||
|
this.dialogTitle = "新增供应器";
|
||||||
|
this.providerForm = {
|
||||||
|
id: null,
|
||||||
|
model_type: "",
|
||||||
|
provider_code: "",
|
||||||
|
name: "",
|
||||||
|
fields: [],
|
||||||
|
sort: 0
|
||||||
|
};
|
||||||
|
this.dialogVisible = true;
|
||||||
|
},
|
||||||
|
editProvider(row) {
|
||||||
|
this.dialogTitle = "编辑供应器";
|
||||||
|
this.providerForm = {
|
||||||
|
...row,
|
||||||
|
fields: JSON.parse(JSON.stringify(row.fields))
|
||||||
|
};
|
||||||
|
this.dialogVisible = true;
|
||||||
|
},
|
||||||
|
handleSubmit({form, done}) {
|
||||||
|
this.loading = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
if (form.id) {
|
||||||
|
// 模拟编辑操作
|
||||||
|
const index = this.providersList.findIndex(p => p.id === form.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.providersList.splice(index, 1, {
|
||||||
|
...form,
|
||||||
|
fields: typeof form.fields === 'string' ? JSON.parse(form.fields) : form.fields
|
||||||
|
});
|
||||||
|
this.$message.success({
|
||||||
|
message: "修改成功",
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 模拟新增操作
|
||||||
|
const newId = `SYSTEM_${form.model_type}_${form.provider_code}`;
|
||||||
|
this.providersList.unshift({
|
||||||
|
...form,
|
||||||
|
id: newId,
|
||||||
|
fields: typeof form.fields === 'string' ? JSON.parse(form.fields) : form.fields,
|
||||||
|
selected: false
|
||||||
|
});
|
||||||
|
this.total += 1;
|
||||||
|
this.$message.success({
|
||||||
|
message: "新增成功",
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dialogVisible = false;
|
||||||
|
done && done();
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
deleteSelectedProviders() {
|
||||||
|
const selectedRows = this.providersList.filter(row => row.selected);
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
this.$message.warning({
|
||||||
|
message: "请先选择需要删除的供应器",
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deleteProvider(selectedRows);
|
||||||
|
},
|
||||||
|
deleteProvider(row) {
|
||||||
|
const providers = Array.isArray(row) ? row : [row];
|
||||||
|
const providerCount = providers.length;
|
||||||
|
|
||||||
|
this.$confirm(`确定要删除选中的${providerCount}个供应器吗?`, '警告', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
const ids = providers.map(provider => provider.id);
|
||||||
|
// 模拟删除操作
|
||||||
|
this.providersList = this.providersList.filter(p => !ids.includes(p.id));
|
||||||
|
this.total = this.providersList.length;
|
||||||
|
|
||||||
|
this.$message.success({
|
||||||
|
message: `成功删除${providerCount}个供应器`,
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
this.$message({
|
||||||
|
type: 'info',
|
||||||
|
message: '已取消删除',
|
||||||
|
showClose: true,
|
||||||
|
duration: 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getModelTypeTag(type) {
|
||||||
|
const typeMap = {
|
||||||
|
'ASR': 'success',
|
||||||
|
'TTS': 'warning',
|
||||||
|
'LLM': 'danger',
|
||||||
|
'Intent': 'info',
|
||||||
|
'Memory': '',
|
||||||
|
'VAD': 'primary'
|
||||||
|
};
|
||||||
|
return typeMap[type] || '';
|
||||||
|
},
|
||||||
|
getModelTypeLabel(type) {
|
||||||
|
const typeItem = this.modelTypes.find(item => item.value === type);
|
||||||
|
return typeItem ? typeItem.label : type;
|
||||||
|
},
|
||||||
|
isSensitiveField(fieldKey) {
|
||||||
|
return this.sensitive_keys.some(key =>
|
||||||
|
fieldKey.toLowerCase().includes(key.toLowerCase())
|
||||||
|
);
|
||||||
|
},
|
||||||
|
handlePageSizeChange(val) {
|
||||||
|
this.pageSize = val;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.fetchProviders();
|
||||||
|
},
|
||||||
|
headerCellClassName({columnIndex}) {
|
||||||
|
if (columnIndex === 0) {
|
||||||
|
return "custom-selection-header";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
goFirst() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.fetchProviders();
|
||||||
|
},
|
||||||
|
goPrev() {
|
||||||
|
if (this.currentPage > 1) {
|
||||||
|
this.currentPage--;
|
||||||
|
this.fetchProviders();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goNext() {
|
||||||
|
if (this.currentPage < this.pageCount) {
|
||||||
|
this.currentPage++;
|
||||||
|
this.fetchProviders();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToPage(page) {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.fetchProviders();
|
||||||
|
},
|
||||||
|
handleDropdownVisibleChange(visible) {
|
||||||
|
this.DropdownVisible = visible;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.welcome {
|
||||||
|
min-width: 900px;
|
||||||
|
min-height: 506px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
background-size: cover;
|
||||||
|
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
|
||||||
|
-webkit-background-size: cover;
|
||||||
|
-o-background-size: cover;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-wrapper {
|
||||||
|
margin: 5px 22px;
|
||||||
|
border-radius: 15px;
|
||||||
|
min-height: calc(100vh - 24vh);
|
||||||
|
height: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
background: rgba(237, 242, 255, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-operations {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-search {
|
||||||
|
background: linear-gradient(135deg, #6b8cff, #a966ff);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 600px;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card{
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-card {
|
||||||
|
background: white;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: hidden;
|
||||||
|
::v-deep .el-card__body {
|
||||||
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl_btn {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 26px;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
min-width: 72px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 7px 12px 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
background: #5f70f3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--danger {
|
||||||
|
background: #fd5b63;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.el-select {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:first-child,
|
||||||
|
.pagination-btn:nth-child(2),
|
||||||
|
.pagination-btn:nth-last-child(2),
|
||||||
|
.pagination-btn:nth-child(3) {
|
||||||
|
min-width: 60px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
background: #dee7ff;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #d7dce6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(245, 247, 250, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn.active {
|
||||||
|
background: #5f70f3 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: #5f70f3 !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #6d7cf5 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-text {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.transparent-table) {
|
||||||
|
background: white;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__header-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__header th {
|
||||||
|
background: white !important;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__body tr {
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
td {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1144px) {
|
||||||
|
.table_bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.transparent-table) {
|
||||||
|
.el-table__body tr {
|
||||||
|
td {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + tr {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .el-button--text) {
|
||||||
|
color: #7079aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .el-button--text:hover) {
|
||||||
|
color: #5a64b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--success {
|
||||||
|
background: #5bc98c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .cell) {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-select {
|
||||||
|
width: 100px;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
background: #dee7ff;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__suffix) {
|
||||||
|
right: 6px;
|
||||||
|
width: 15px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
top: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__suffix-inner) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon-arrow-up:before) {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 9px solid #606266;
|
||||||
|
position: relative;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
--table-max-height: calc(100vh - 40vh);
|
||||||
|
max-height: var(--table-max-height);
|
||||||
|
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
max-height: calc(var(--table-max-height) - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-mask) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-spinner .circular) {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-spinner .path) {
|
||||||
|
stroke: #6b8cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-text) {
|
||||||
|
color: #6b8cff !important;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item {
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-type {
|
||||||
|
width: 80px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-tag {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger.active {
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 7px solid black;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
border-top-color: #409EFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate-down {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon-arrow-down {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.dropdown-trigger {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger.active {
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,11 +26,7 @@
|
|||||||
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
|
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
|
||||||
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
|
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
|
||||||
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
|
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
|
||||||
<el-table-column label="注册时间" prop="createDate" align="center">
|
<el-table-column label="注册时间" prop="createDate" align="center"></el-table-column>
|
||||||
<template slot-scope="scope">
|
|
||||||
{{ formatDate(scope.row.createDate) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" prop="status" align="center">
|
<el-table-column label="状态" prop="status" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-tag v-if="scope.row.status === 1" type="success">正常</el-tag>
|
<el-tag v-if="scope.row.status === 1" type="success">正常</el-tag>
|
||||||
@@ -342,11 +338,6 @@ export default {
|
|||||||
// 用户取消操作
|
// 用户取消操作
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
formatDate(dateString) {
|
|
||||||
if (!dateString) return '';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,11 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="device-list-container">
|
<div class="device-list-container">
|
||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div
|
<div v-for="i in skeletonCount" :key="'skeleton-' + i" class="skeleton-item">
|
||||||
v-for="i in skeletonCount"
|
|
||||||
:key="'skeleton-'+i"
|
|
||||||
class="skeleton-item"
|
|
||||||
>
|
|
||||||
<div class="skeleton-image"></div>
|
<div class="skeleton-image"></div>
|
||||||
<div class="skeleton-content">
|
<div class="skeleton-content">
|
||||||
<div class="skeleton-line"></div>
|
<div class="skeleton-line"></div>
|
||||||
@@ -46,14 +42,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<DeviceItem
|
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig"
|
||||||
v-for="(item, index) in devices"
|
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" @chat-history="handleShowChatHistory" />
|
||||||
:key="index"
|
|
||||||
:device="item"
|
|
||||||
@configure="goToRoleConfig"
|
|
||||||
@deviceManage="handleDeviceManage"
|
|
||||||
@delete="handleDeleteAgent"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,6 +52,7 @@
|
|||||||
<el-footer>
|
<el-footer>
|
||||||
<version-footer />
|
<version-footer />
|
||||||
</el-footer>
|
</el-footer>
|
||||||
|
<chat-history-dialog :visible.sync="showChatHistory" :agent-id="currentAgentId" :agent-name="currentAgentName" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@@ -69,13 +60,14 @@
|
|||||||
<script>
|
<script>
|
||||||
import Api from '@/apis/api';
|
import Api from '@/apis/api';
|
||||||
import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue';
|
import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue';
|
||||||
|
import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue';
|
||||||
import DeviceItem from '@/components/DeviceItem.vue';
|
import DeviceItem from '@/components/DeviceItem.vue';
|
||||||
import HeaderBar from '@/components/HeaderBar.vue';
|
import HeaderBar from '@/components/HeaderBar.vue';
|
||||||
import VersionFooter from '@/components/VersionFooter.vue';
|
import VersionFooter from '@/components/VersionFooter.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HomePage',
|
name: 'HomePage',
|
||||||
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter },
|
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter, ChatHistoryDialog },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
addDeviceDialogVisible: false,
|
addDeviceDialogVisible: false,
|
||||||
@@ -85,6 +77,9 @@ export default {
|
|||||||
searchRegex: null,
|
searchRegex: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
skeletonCount: localStorage.getItem('skeletonCount') || 8,
|
skeletonCount: localStorage.getItem('skeletonCount') || 8,
|
||||||
|
showChatHistory: false,
|
||||||
|
currentAgentId: '',
|
||||||
|
currentAgentName: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -177,6 +172,11 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
|
},
|
||||||
|
handleShowChatHistory({ agentId, agentName }) {
|
||||||
|
this.currentAgentId = agentId;
|
||||||
|
this.currentAgentName = agentName;
|
||||||
|
this.showChatHistory = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,7 +302,9 @@ export default {
|
|||||||
|
|
||||||
/* 骨架屏动画 */
|
/* 骨架屏动画 */
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
100% { transform: translateX(100%); }
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
@@ -353,12 +355,10 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg,
|
||||||
90deg,
|
rgba(255, 255, 255, 0),
|
||||||
rgba(255,255,255,0),
|
rgba(255, 255, 255, 0.3),
|
||||||
rgba(255,255,255,0.3),
|
rgba(255, 255, 255, 0));
|
||||||
rgba(255,255,255,0)
|
|
||||||
);
|
|
||||||
animation: shimmer 1.5s infinite;
|
animation: shimmer 1.5s infinite;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -15,9 +15,17 @@
|
|||||||
<img loading="lazy" src="@/assets/home/setting-user.png" alt="">
|
<img loading="lazy" src="@/assets/home/setting-user.png" alt="">
|
||||||
</div>
|
</div>
|
||||||
<span class="header-title">{{ form.agentName }}</span>
|
<span class="header-title">{{ form.agentName }}</span>
|
||||||
<button class="custom-close-btn" @click="goToHome">
|
<div class="header-actions">
|
||||||
×
|
<div class="hint-text">
|
||||||
</button>
|
<img loading="lazy" src="@/assets/home/info.png" alt="">
|
||||||
|
<span>保存配置后,需要重启设备,新的配置才会生效。</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
|
||||||
|
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
|
||||||
|
<button class="custom-close-btn" @click="goToHome">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
@@ -26,7 +34,7 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-column">
|
<div class="form-column">
|
||||||
<el-form-item label="助手昵称:">
|
<el-form-item label="助手昵称:">
|
||||||
<el-input v-model="form.agentName" class="form-input" />
|
<el-input v-model="form.agentName" class="form-input" maxlength="10" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色模版:">
|
<el-form-item label="角色模版:">
|
||||||
<div class="template-container">
|
<div class="template-container">
|
||||||
@@ -37,9 +45,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色介绍:">
|
<el-form-item label="角色介绍:">
|
||||||
<el-input type="textarea" rows="12" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
|
<el-input type="textarea" rows="9" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
|
||||||
maxlength="2000" show-word-limit class="form-textarea" />
|
maxlength="2000" show-word-limit class="form-textarea" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="记忆:">
|
||||||
|
<el-input type="textarea" rows="6" resize="none" v-model="form.summaryMemory" maxlength="2000"
|
||||||
|
show-word-limit class="form-textarea"
|
||||||
|
:disabled="form.model.memModelId !== 'Memory_mem_local_short'" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="语言编码:" style="display: none;">
|
<el-form-item label="语言编码:" style="display: none;">
|
||||||
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit
|
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit
|
||||||
class="form-input" />
|
class="form-input" />
|
||||||
@@ -48,24 +62,48 @@
|
|||||||
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit
|
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit
|
||||||
class="form-input" />
|
class="form-input" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="action-bar">
|
|
||||||
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
|
|
||||||
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
|
|
||||||
<div class="hint-text">
|
|
||||||
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
|
|
||||||
<span>保存配置后,需要重启设备,新的配置才会生效。</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-column">
|
<div class="form-column">
|
||||||
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
|
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
|
||||||
class="model-item">
|
class="model-item">
|
||||||
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select">
|
<div class="model-select-wrapper">
|
||||||
<el-option v-for="(item, optionIndex) in modelOptions[model.type]"
|
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select"
|
||||||
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
|
@change="handleModelChange(model.type, $event)">
|
||||||
</el-select>
|
<el-option v-for="(item, optionIndex) in modelOptions[model.type]"
|
||||||
|
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<div v-if="showFunctionIcons(model.type)" class="function-icons">
|
||||||
|
<el-tooltip v-for="func in currentFunctions" :key="func.name" effect="dark" placement="top"
|
||||||
|
popper-class="custom-tooltip">
|
||||||
|
<div slot="content">
|
||||||
|
<div><strong>功能名称:</strong> {{ func.name }}</div>
|
||||||
|
<div v-if="Object.keys(func.params).length > 0">
|
||||||
|
<strong>参数配置:</strong>
|
||||||
|
<div v-for="(value, key) in func.params" :key="key">
|
||||||
|
{{ key }}: {{ value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>无参数配置</div>
|
||||||
|
</div>
|
||||||
|
<div class="icon-dot" :style="{ backgroundColor: getFunctionColor(func.name) }">
|
||||||
|
{{ func.name.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-button class="edit-function-btn" @click="showFunctionDialog = true"
|
||||||
|
:class="{ 'active-btn': showFunctionDialog }">
|
||||||
|
编辑功能
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="model.type === 'Memory' && form.model.memModelId !== 'Memory_nomem'"
|
||||||
|
class="chat-history-options">
|
||||||
|
<el-radio-group v-model="form.chatHistoryConf" @change="updateChatHistoryConf">
|
||||||
|
<el-radio-button :label="1">上报文字</el-radio-button>
|
||||||
|
<el-radio-button :label="2">上报文字+语音</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色音色:">
|
<el-form-item label="角色音色">
|
||||||
<el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
|
<el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
|
||||||
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
|
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
|
||||||
:value="item.value" />
|
:value="item.value" />
|
||||||
@@ -75,28 +113,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<function-dialog v-model="showFunctionDialog" :functions="currentFunctions"
|
||||||
|
@update-functions="handleUpdateFunctions" @dialog-closed="handleDialogClosed" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Api from '@/apis/api';
|
import Api from '@/apis/api';
|
||||||
|
import FunctionDialog from "@/components/FunctionDialog.vue";
|
||||||
import HeaderBar from "@/components/HeaderBar.vue";
|
import HeaderBar from "@/components/HeaderBar.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RoleConfigPage',
|
name: 'RoleConfigPage',
|
||||||
components: { HeaderBar },
|
components: { HeaderBar, FunctionDialog },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
form: {
|
form: {
|
||||||
agentCode: "",
|
agentCode: "",
|
||||||
agentName: "",
|
agentName: "",
|
||||||
ttsVoiceId: "",
|
ttsVoiceId: "",
|
||||||
|
chatHistoryConf: 0,
|
||||||
systemPrompt: "",
|
systemPrompt: "",
|
||||||
|
summaryMemory: "",
|
||||||
langCode: "",
|
langCode: "",
|
||||||
language: "",
|
language: "",
|
||||||
sort: "",
|
sort: "",
|
||||||
@@ -121,6 +164,18 @@ export default {
|
|||||||
templates: [],
|
templates: [],
|
||||||
loadingTemplate: false,
|
loadingTemplate: false,
|
||||||
voiceOptions: [],
|
voiceOptions: [],
|
||||||
|
showFunctionDialog: false,
|
||||||
|
currentFunctions: [],
|
||||||
|
functionColorMap: [
|
||||||
|
'#FF6B6B', '#4ECDC4', '#45B7D1',
|
||||||
|
'#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E'
|
||||||
|
],
|
||||||
|
allFunctions: [
|
||||||
|
{ name: '天气', params: {} },
|
||||||
|
{ name: '新闻', params: {} },
|
||||||
|
{ name: '工具', params: {} },
|
||||||
|
{ name: '退出', params: {} }
|
||||||
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -136,12 +191,15 @@ export default {
|
|||||||
llmModelId: this.form.model.llmModelId,
|
llmModelId: this.form.model.llmModelId,
|
||||||
ttsModelId: this.form.model.ttsModelId,
|
ttsModelId: this.form.model.ttsModelId,
|
||||||
ttsVoiceId: this.form.ttsVoiceId,
|
ttsVoiceId: this.form.ttsVoiceId,
|
||||||
|
chatHistoryConf: this.form.chatHistoryConf,
|
||||||
memModelId: this.form.model.memModelId,
|
memModelId: this.form.model.memModelId,
|
||||||
intentModelId: this.form.model.intentModelId,
|
intentModelId: this.form.model.intentModelId,
|
||||||
systemPrompt: this.form.systemPrompt,
|
systemPrompt: this.form.systemPrompt,
|
||||||
|
summaryMemory: this.form.summaryMemory,
|
||||||
langCode: this.form.langCode,
|
langCode: this.form.langCode,
|
||||||
language: this.form.language,
|
language: this.form.language,
|
||||||
sort: this.form.sort
|
sort: this.form.sort,
|
||||||
|
functions: this.currentFunctions
|
||||||
};
|
};
|
||||||
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
|
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
@@ -167,7 +225,9 @@ export default {
|
|||||||
agentCode: "",
|
agentCode: "",
|
||||||
agentName: "",
|
agentName: "",
|
||||||
ttsVoiceId: "",
|
ttsVoiceId: "",
|
||||||
|
chatHistoryConf: 0,
|
||||||
systemPrompt: "",
|
systemPrompt: "",
|
||||||
|
summaryMemory: "",
|
||||||
langCode: "",
|
langCode: "",
|
||||||
language: "",
|
language: "",
|
||||||
sort: "",
|
sort: "",
|
||||||
@@ -180,12 +240,12 @@ export default {
|
|||||||
intentModelId: "",
|
intentModelId: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.currentFunctions = [];
|
||||||
this.$message.success({
|
this.$message.success({
|
||||||
message: '配置已重置',
|
message: '配置已重置',
|
||||||
showClose: true
|
showClose: true
|
||||||
})
|
})
|
||||||
}).catch(() => {
|
}).catch(() => { });
|
||||||
});
|
|
||||||
},
|
},
|
||||||
fetchTemplates() {
|
fetchTemplates() {
|
||||||
Api.agent.getAgentTemplate(({ data }) => {
|
Api.agent.getAgentTemplate(({ data }) => {
|
||||||
@@ -220,7 +280,9 @@ export default {
|
|||||||
...this.form,
|
...this.form,
|
||||||
agentName: templateData.agentName || this.form.agentName,
|
agentName: templateData.agentName || this.form.agentName,
|
||||||
ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId,
|
ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId,
|
||||||
|
chatHistoryConf: templateData.chatHistoryConf || this.form.chatHistoryConf,
|
||||||
systemPrompt: templateData.systemPrompt || this.form.systemPrompt,
|
systemPrompt: templateData.systemPrompt || this.form.systemPrompt,
|
||||||
|
summaryMemory: templateData.summaryMemory || this.form.summaryMemory,
|
||||||
langCode: templateData.langCode || this.form.langCode,
|
langCode: templateData.langCode || this.form.langCode,
|
||||||
model: {
|
model: {
|
||||||
ttsModelId: templateData.ttsModelId || this.form.model.ttsModelId,
|
ttsModelId: templateData.ttsModelId || this.form.model.ttsModelId,
|
||||||
@@ -247,6 +309,7 @@ export default {
|
|||||||
intentModelId: data.data.intentModelId
|
intentModelId: data.data.intentModelId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
this.currentFunctions = data.data.functions || [];
|
||||||
} else {
|
} else {
|
||||||
this.$message.error(data.msg || '获取配置失败');
|
this.$message.error(data.msg || '获取配置失败');
|
||||||
}
|
}
|
||||||
@@ -281,7 +344,56 @@ export default {
|
|||||||
this.voiceOptions = [];
|
this.voiceOptions = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
getFunctionColor(name) {
|
||||||
|
const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
return this.functionColorMap[hash % 7];
|
||||||
|
},
|
||||||
|
showFunctionIcons(type) {
|
||||||
|
// TODO 暂时不放出来
|
||||||
|
return false;
|
||||||
|
// return type === 'Intent' &&
|
||||||
|
// this.form.model.intentModelId !== 'Intent_nointent';
|
||||||
|
},
|
||||||
|
handleModelChange(type, value) {
|
||||||
|
if (type === 'Intent' && value !== 'Intent_nointent') {
|
||||||
|
this.fetchFunctionList();
|
||||||
|
}
|
||||||
|
if (type === 'Memory' && value === 'Memory_nomem') {
|
||||||
|
this.form.chatHistoryConf = 0;
|
||||||
|
}
|
||||||
|
if (type === 'Memory' && value !== 'Memory_nomem' && (this.form.chatHistoryConf === 0 || this.form.chatHistoryConf === null)) {
|
||||||
|
this.form.chatHistoryConf = 2;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchFunctionList() {
|
||||||
|
// 使用假数据代替API调用
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.currentFunctions = [
|
||||||
|
{ name: '天气', params: { city: '北京' } },
|
||||||
|
{ name: '新闻', params: { type: '科技' } }
|
||||||
|
];
|
||||||
|
resolve();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleUpdateFunctions(selected) {
|
||||||
|
this.currentFunctions = selected;
|
||||||
|
console.log('保存的功能列表:', selected);
|
||||||
|
this.$message.success('功能配置已保存');
|
||||||
|
},
|
||||||
|
handleDialogClosed(saved) {
|
||||||
|
if (!saved) {
|
||||||
|
// 如果未保存,恢复原始功能列表
|
||||||
|
this.currentFunctions = JSON.parse(JSON.stringify(this.originalFunctions));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateChatHistoryConf() {
|
||||||
|
if (this.form.model.memModelId === 'Memory_nomem') {
|
||||||
|
this.form.chatHistoryConf = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'form.model.ttsModelId': {
|
'form.model.ttsModelId': {
|
||||||
@@ -308,6 +420,9 @@ export default {
|
|||||||
const agentId = this.$route.query.agentId;
|
const agentId = this.$route.query.agentId;
|
||||||
if (agentId) {
|
if (agentId) {
|
||||||
this.fetchAgentConfig(agentId);
|
this.fetchAgentConfig(agentId);
|
||||||
|
this.fetchFunctionList().then(() => {
|
||||||
|
this.originalFunctions = JSON.parse(JSON.stringify(this.currentFunctions));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.fetchModelOptions();
|
this.fetchModelOptions();
|
||||||
this.fetchTemplates();
|
this.fetchTemplates();
|
||||||
@@ -318,7 +433,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 +448,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 +458,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 +530,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-content {
|
.form-content {
|
||||||
padding: 20px 0;
|
padding: 2vh 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
@@ -450,11 +564,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;
|
||||||
@@ -467,51 +581,35 @@ export default {
|
|||||||
background-color: #d0d8ff;
|
background-color: #d0d8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-bar {
|
.model-select-wrapper {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-bar {
|
|
||||||
.el-button.save-btn {
|
|
||||||
background: #5778ff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
width: 100px;
|
|
||||||
height: 35px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-button.reset-btn {
|
|
||||||
background: #e6ebff;
|
|
||||||
color: #5778ff;
|
|
||||||
border: 1px solid #adbdff;
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
width: 100%;
|
||||||
color: #979db1;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint-text img {
|
.function-icons {
|
||||||
width: 19px;
|
display: flex;
|
||||||
height: 19px;
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-dot {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep .el-form-item__label {
|
::v-deep .el-form-item__label {
|
||||||
font-size: 10px !important;
|
font-size: 12px !important;
|
||||||
color: #3d4566 !important;
|
color: #3d4566 !important;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
@@ -552,4 +650,73 @@ export default {
|
|||||||
color: #409EFF;
|
color: #409EFF;
|
||||||
border-color: #409EFF;
|
border-color: #409EFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-function-btn {
|
||||||
|
background: #e6ebff;
|
||||||
|
color: #5778ff;
|
||||||
|
border: 1px solid #adbdff;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-function-btn.active-btn {
|
||||||
|
background: #5778ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 250px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .hint-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #979db1;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .hint-text img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .save-btn {
|
||||||
|
background: #5778ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .reset-btn {
|
||||||
|
background: #e6ebff;
|
||||||
|
color: #5778ff;
|
||||||
|
border: 1px solid #adbdff;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .custom-close-btn {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -7,33 +7,47 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_exit():
|
async def wait_for_exit() -> None:
|
||||||
"""Windows 和 Linux 兼容的退出监听"""
|
"""
|
||||||
|
阻塞直到收到 Ctrl‑C / SIGTERM。
|
||||||
|
- Unix: 使用 add_signal_handler
|
||||||
|
- Windows: 依赖 KeyboardInterrupt
|
||||||
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform != "win32": # Unix / macOS
|
||||||
# Windows: 用 sys.stdin.read() 监听 Ctrl + C
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
await loop.run_in_executor(None, sys.stdin.read)
|
loop.add_signal_handler(sig, stop_event.set)
|
||||||
else:
|
|
||||||
# Linux/macOS: 用 signal 监听 Ctrl + C
|
|
||||||
def stop():
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
loop.add_signal_handler(signal.SIGINT, stop)
|
|
||||||
loop.add_signal_handler(signal.SIGTERM, stop) # 支持 kill 进程
|
|
||||||
await stop_event.wait()
|
await stop_event.wait()
|
||||||
|
else:
|
||||||
|
# Windows:await一个永远pending的fut,
|
||||||
|
# 让 KeyboardInterrupt 冒泡到 asyncio.run,以此消除遗留普通线程导致进程退出阻塞的问题
|
||||||
|
try:
|
||||||
|
await asyncio.Future()
|
||||||
|
except KeyboardInterrupt: # Ctrl‑C
|
||||||
|
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())
|
||||||
@@ -74,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("服务器已关闭,程序退出。")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,15 +101,17 @@ plugins:
|
|||||||
# 这个密钥是项目共用的key,用多了可能会被限制
|
# 这个密钥是项目共用的key,用多了可能会被限制
|
||||||
# 想稳定一点就自行申请替换,每天有1000次免费调用
|
# 想稳定一点就自行申请替换,每天有1000次免费调用
|
||||||
# 申请地址:https://console.qweather.com/#/apps/create-key/over
|
# 申请地址:https://console.qweather.com/#/apps/create-key/over
|
||||||
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
|
# 申请后通过这个链接可以找到自己的apihost:https://console.qweather.com/setting?lang=zh
|
||||||
|
get_weather: {"api_host":"mj7p3y7naa.re.qweatherapi.com", "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
|
||||||
@@ -128,7 +130,7 @@ plugins:
|
|||||||
# ################################以下是角色模型配置######################################
|
# ################################以下是角色模型配置######################################
|
||||||
|
|
||||||
prompt: |
|
prompt: |
|
||||||
我是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
|
你是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
|
||||||
[核心特征]
|
[核心特征]
|
||||||
- 讲话像连珠炮,但会突然冒出超温柔语气
|
- 讲话像连珠炮,但会突然冒出超温柔语气
|
||||||
- 用梗密度高
|
- 用梗密度高
|
||||||
@@ -142,6 +144,13 @@ prompt: |
|
|||||||
- 长篇大论,叽叽歪歪
|
- 长篇大论,叽叽歪歪
|
||||||
- 长时间严肃对话
|
- 长时间严肃对话
|
||||||
|
|
||||||
|
# 结束语prompt
|
||||||
|
end_prompt:
|
||||||
|
enable: true # 是否开启结束语
|
||||||
|
# 结束语
|
||||||
|
prompt: |
|
||||||
|
请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!
|
||||||
|
|
||||||
# 具体处理时选择的模块(The module selected for specific processing)
|
# 具体处理时选择的模块(The module selected for specific processing)
|
||||||
selected_module:
|
selected_module:
|
||||||
# 语音活动检测模块,默认使用SileroVAD模型
|
# 语音活动检测模块,默认使用SileroVAD模型
|
||||||
@@ -156,7 +165,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 +183,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 +199,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
|
||||||
@@ -212,7 +229,7 @@ ASR:
|
|||||||
FunASRServer:
|
FunASRServer:
|
||||||
# 独立部署FunASR,使用FunASR的API服务,只需要五句话
|
# 独立部署FunASR,使用FunASR的API服务,只需要五句话
|
||||||
# 第一句:mkdir -p ./funasr-runtime-resources/models
|
# 第一句:mkdir -p ./funasr-runtime-resources/models
|
||||||
# 第二句:sudo docker run -d -p 10096:10095 --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
|
# 第二句:sudo docker run -p 10096:10095 -it --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
|
||||||
# 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
|
# 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
|
||||||
# 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
|
# 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
|
||||||
# 上一句话执行后会进入到容器,继续第五句:tail -f log.txt
|
# 上一句话执行后会进入到容器,继续第五句:tail -f log.txt
|
||||||
@@ -221,6 +238,9 @@ ASR:
|
|||||||
type: fun_server
|
type: fun_server
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 10096
|
port: 10096
|
||||||
|
is_ssl: true
|
||||||
|
api_key: none
|
||||||
|
output_dir: tmp/
|
||||||
SherpaASR:
|
SherpaASR:
|
||||||
type: sherpa_onnx_local
|
type: sherpa_onnx_local
|
||||||
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
|
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
|
||||||
@@ -253,9 +273,19 @@ ASR:
|
|||||||
type: aliyun
|
type: aliyun
|
||||||
appkey: 你的阿里云智能语音交互服务项目Appkey
|
appkey: 你的阿里云智能语音交互服务项目Appkey
|
||||||
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_id,access_key_secret
|
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_id,access_key_secret
|
||||||
access_key_id: 的阿里云账号access_key_id
|
access_key_id: 你的阿里云账号access_key_id
|
||||||
access_key_secret: 你的阿里云账号access_key_secret
|
access_key_secret: 你的阿里云账号access_key_secret
|
||||||
output_dir: tmp/
|
output_dir: tmp/
|
||||||
|
BaiduASR:
|
||||||
|
# 获取AppID、API Key、Secret Key:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list
|
||||||
|
# 查看资源额度:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/overview/resource/list
|
||||||
|
type: baidu
|
||||||
|
app_id: 你的百度语音技术AppID
|
||||||
|
api_key: 你的百度语音技术APIKey
|
||||||
|
secret_key: 你的百度语音技术SecretKey
|
||||||
|
# 语言参数,1537为普通话,具体参考:https://ai.baidu.com/ai-doc/SPEECH/0lbxfnc9b
|
||||||
|
dev_pid: 1537
|
||||||
|
output_dir: tmp/
|
||||||
|
|
||||||
VAD:
|
VAD:
|
||||||
SileroVAD:
|
SileroVAD:
|
||||||
@@ -368,6 +398,12 @@ LLM:
|
|||||||
model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载
|
model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载
|
||||||
url: http://localhost:1234/v1 # LM Studio服务地址
|
url: http://localhost:1234/v1 # LM Studio服务地址
|
||||||
api_key: lm-studio # LM Studio服务的固定API Key
|
api_key: lm-studio # LM Studio服务的固定API Key
|
||||||
|
HomeAssistant:
|
||||||
|
# 定义LLM API类型
|
||||||
|
type: homeassistant
|
||||||
|
base_url: http://homeassistant.local:8123
|
||||||
|
agent_id: conversation.chatgpt
|
||||||
|
api_key: 你的home assistant api访问令牌
|
||||||
FastgptLLM:
|
FastgptLLM:
|
||||||
# 定义LLM API类型
|
# 定义LLM API类型
|
||||||
type: fastgpt
|
type: fastgpt
|
||||||
@@ -448,20 +484,13 @@ TTS:
|
|||||||
speed: 1
|
speed: 1
|
||||||
output_dir: tmp/
|
output_dir: tmp/
|
||||||
FishSpeech:
|
FishSpeech:
|
||||||
# 定义TTS API类型
|
# 参照教程:https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md
|
||||||
#启动tts方法:
|
|
||||||
#python -m tools.api_server
|
|
||||||
#--listen 0.0.0.0:8080
|
|
||||||
#--llama-checkpoint-path "checkpoints/fish-speech-1.5"
|
|
||||||
#--decoder-checkpoint-path "checkpoints/fish-speech-1.5/firefly-gan-vq-fsq-8x1024-21hz-generator.pth"
|
|
||||||
#--decoder-config-name firefly_gan_vq
|
|
||||||
#--compile
|
|
||||||
type: fishspeech
|
type: fishspeech
|
||||||
output_dir: tmp/
|
output_dir: tmp/
|
||||||
response_format: wav
|
response_format: wav
|
||||||
reference_id: null
|
reference_id: null
|
||||||
reference_audio: ["/tmp/test.wav",]
|
reference_audio: ["config/assets/wakeup_words.wav",]
|
||||||
reference_text: ["你弄来这些吟词宴曲来看,还是这些混话来欺负我。",]
|
reference_text: ["哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",]
|
||||||
normalize: true
|
normalize: true
|
||||||
max_new_tokens: 1024
|
max_new_tokens: 1024
|
||||||
chunk_length: 200
|
chunk_length: 200
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ 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.3.13"
|
SERVER_VERSION = "0.4.3"
|
||||||
|
|
||||||
|
|
||||||
def get_module_abbreviation(module_name, module_dict):
|
def get_module_abbreviation(module_name, module_dict):
|
||||||
"""获取模块名称的缩写,如果为空则返回00"""
|
"""获取模块名称的缩写,如果为空则返回00
|
||||||
return (
|
如果名称中包含下划线,则返回下划线后面的前两个字符
|
||||||
module_dict.get(module_name, "")[:2] if module_dict.get(module_name) else "00"
|
"""
|
||||||
)
|
module_value = module_dict.get(module_name, "")
|
||||||
|
if not module_value:
|
||||||
|
return "00"
|
||||||
|
if "_" in module_value:
|
||||||
|
parts = module_value.split("_")
|
||||||
|
return parts[-1][:2] if parts[-1] else "00"
|
||||||
|
return module_value[:2]
|
||||||
|
|
||||||
|
|
||||||
def build_module_string(selected_module):
|
def build_module_string(selected_module):
|
||||||
|
|||||||
@@ -145,25 +145,27 @@ def get_agent_models(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]:
|
||||||
|
try:
|
||||||
|
return ManageApiClient._instance._execute_request(
|
||||||
|
"PUT",
|
||||||
|
f"/agent/saveMemory/" + mac_address,
|
||||||
|
json={
|
||||||
|
"summaryMemory": short_momery,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"存储短期记忆到服务器失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def report(
|
def report(
|
||||||
mac_address: str, session_id: str, chat_type: int, content: str, opus_data
|
mac_address: str, session_id: str, chat_type: int, content: str, audio
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict]:
|
||||||
"""带熔断的业务方法示例"""
|
"""带熔断的业务方法示例"""
|
||||||
if not content or not ManageApiClient._instance:
|
if not content or not ManageApiClient._instance:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
# 处理opus_data为列表的情况
|
|
||||||
if isinstance(opus_data, list):
|
|
||||||
# 将列表中的所有bytes数据合并
|
|
||||||
combined_data = b"".join(opus_data)
|
|
||||||
else:
|
|
||||||
combined_data = opus_data
|
|
||||||
|
|
||||||
# 将二进制数据转换为Base64编码的字符串
|
|
||||||
opus_data_base64 = (
|
|
||||||
base64.b64encode(combined_data).decode("utf-8") if combined_data else None
|
|
||||||
)
|
|
||||||
|
|
||||||
return ManageApiClient._instance._execute_request(
|
return ManageApiClient._instance._execute_request(
|
||||||
"POST",
|
"POST",
|
||||||
f"/agent/chat-history/report",
|
f"/agent/chat-history/report",
|
||||||
@@ -172,7 +174,9 @@ def report(
|
|||||||
"sessionId": session_id,
|
"sessionId": session_id,
|
||||||
"chatType": chat_type,
|
"chatType": chat_type,
|
||||||
"content": content,
|
"content": content,
|
||||||
"opusDataBase64": opus_data_base64,
|
"audioBase64": (
|
||||||
|
base64.b64encode(audio).decode("utf-8") if audio else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,6 +20,8 @@ from core.utils.util import (
|
|||||||
get_string_no_punctuation_or_emoji,
|
get_string_no_punctuation_or_emoji,
|
||||||
extract_json_from_string,
|
extract_json_from_string,
|
||||||
initialize_modules,
|
initialize_modules,
|
||||||
|
check_vad_update,
|
||||||
|
check_asr_update,
|
||||||
)
|
)
|
||||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||||
from core.handle.sendAudioHandle import sendAudioMessage
|
from core.handle.sendAudioHandle import sendAudioMessage
|
||||||
@@ -29,7 +33,7 @@ from core.mcp.manager import MCPManager
|
|||||||
from config.config_loader import get_private_config_from_api
|
from config.config_loader import get_private_config_from_api
|
||||||
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
|
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
|
||||||
from core.utils.output_counter import add_device_output
|
from core.utils.output_counter import add_device_output
|
||||||
from core.handle.ttsReportHandle import enqueue_tts_report, report_tts
|
from core.handle.reportHandle import enqueue_tts_report, report
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
|
||||||
@@ -52,11 +56,13 @@ class ConnectionHandler:
|
|||||||
_intent,
|
_intent,
|
||||||
server=None,
|
server=None,
|
||||||
):
|
):
|
||||||
self.config = config
|
self.common_config = config
|
||||||
self.server = server
|
self.config = copy.deepcopy(config)
|
||||||
|
self.session_id = str(uuid.uuid4())
|
||||||
self.logger = setup_logging()
|
self.logger = setup_logging()
|
||||||
self.auth = AuthMiddleware(config)
|
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)
|
||||||
@@ -66,10 +72,10 @@ class ConnectionHandler:
|
|||||||
self.device_id = None
|
self.device_id = None
|
||||||
self.client_ip = None
|
self.client_ip = None
|
||||||
self.client_ip_info = {}
|
self.client_ip_info = {}
|
||||||
self.session_id = None
|
|
||||||
self.prompt = None
|
self.prompt = None
|
||||||
self.welcome_msg = None
|
self.welcome_msg = None
|
||||||
self.max_output_size = 0
|
self.max_output_size = 0
|
||||||
|
self.chat_history_conf = 0
|
||||||
|
|
||||||
# 客户端状态相关
|
# 客户端状态相关
|
||||||
self.client_abort = False
|
self.client_abort = False
|
||||||
@@ -83,12 +89,17 @@ class ConnectionHandler:
|
|||||||
self.executor = ThreadPoolExecutor(max_workers=10)
|
self.executor = ThreadPoolExecutor(max_workers=10)
|
||||||
|
|
||||||
# 上报线程
|
# 上报线程
|
||||||
self.tts_report_queue = queue.Queue()
|
self.report_queue = queue.Queue()
|
||||||
self.tts_report_thread = None
|
self.report_thread = None
|
||||||
|
# TODO(haotian): 2025/5/12 可以通过修改此处,调节asr的上报和tts的上报
|
||||||
|
self.report_asr_enable = self.read_config_from_api
|
||||||
|
self.report_tts_enable = self.read_config_from_api
|
||||||
|
|
||||||
# 依赖的组件
|
# 依赖的组件
|
||||||
self.vad = _vad
|
self.vad = None
|
||||||
self.asr = _asr
|
self.asr = None
|
||||||
|
self._asr = _asr
|
||||||
|
self._vad = _vad
|
||||||
self.llm = _llm
|
self.llm = _llm
|
||||||
self.tts = _tts
|
self.tts = _tts
|
||||||
self.memory = _memory
|
self.memory = _memory
|
||||||
@@ -123,14 +134,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
|
||||||
@@ -151,11 +166,9 @@ class ConnectionHandler:
|
|||||||
self.headers["device-id"] = query_params["device-id"][0]
|
self.headers["device-id"] = query_params["device-id"][0]
|
||||||
self.headers["client-id"] = query_params["client-id"][0]
|
self.headers["client-id"] = query_params["client-id"][0]
|
||||||
else:
|
else:
|
||||||
self.logger.bind(tag=TAG).error(
|
await ws.send("端口正常,如需测试连接,请使用test_page.html")
|
||||||
"无法从请求头和URL查询参数中获取device-id"
|
await self.close(ws)
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 获取客户端ip地址
|
# 获取客户端ip地址
|
||||||
self.client_ip = ws.remote_address[0]
|
self.client_ip = ws.remote_address[0]
|
||||||
self.logger.bind(tag=TAG).info(
|
self.logger.bind(tag=TAG).info(
|
||||||
@@ -168,7 +181,6 @@ class ConnectionHandler:
|
|||||||
# 认证通过,继续处理
|
# 认证通过,继续处理
|
||||||
self.websocket = ws
|
self.websocket = ws
|
||||||
self.device_id = self.headers.get("device-id", None)
|
self.device_id = self.headers.get("device-id", None)
|
||||||
self.session_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# 启动超时检查任务
|
# 启动超时检查任务
|
||||||
self.timeout_task = asyncio.create_task(self._check_timeout())
|
self.timeout_task = asyncio.create_task(self._check_timeout())
|
||||||
@@ -178,9 +190,9 @@ class ConnectionHandler:
|
|||||||
await self.websocket.send(json.dumps(self.welcome_msg))
|
await self.websocket.send(json.dumps(self.welcome_msg))
|
||||||
|
|
||||||
# 获取差异化配置
|
# 获取差异化配置
|
||||||
private_config = self._initialize_private_config()
|
self._initialize_private_config()
|
||||||
# 异步初始化
|
# 异步初始化
|
||||||
self.executor.submit(self._initialize_components, private_config)
|
self.executor.submit(self._initialize_components)
|
||||||
# tts 消化线程
|
# tts 消化线程
|
||||||
self.tts_priority_thread = threading.Thread(
|
self.tts_priority_thread = threading.Thread(
|
||||||
target=self._tts_priority_thread, daemon=True
|
target=self._tts_priority_thread, daemon=True
|
||||||
@@ -212,10 +224,27 @@ class ConnectionHandler:
|
|||||||
async def _save_and_close(self, ws):
|
async def _save_and_close(self, ws):
|
||||||
"""保存记忆并关闭连接"""
|
"""保存记忆并关闭连接"""
|
||||||
try:
|
try:
|
||||||
await self.memory.save_memory(self.dialogue.dialogue)
|
if self.memory:
|
||||||
|
# 使用线程池异步保存记忆
|
||||||
|
def save_memory_task():
|
||||||
|
try:
|
||||||
|
# 创建新事件循环(避免与主循环冲突)
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
loop.run_until_complete(
|
||||||
|
self.memory.save_memory(self.dialogue.dialogue)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
# 启动线程保存记忆,不等待完成
|
||||||
|
threading.Thread(target=save_memory_task, daemon=True).start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
|
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
# 立即关闭连接,不等待记忆保存完成
|
||||||
await self.close(ws)
|
await self.close(ws)
|
||||||
|
|
||||||
async def reset_timeout(self):
|
async def reset_timeout(self):
|
||||||
@@ -234,81 +263,66 @@ class ConnectionHandler:
|
|||||||
elif isinstance(message, bytes):
|
elif isinstance(message, bytes):
|
||||||
await handleAudioMessage(self, message)
|
await handleAudioMessage(self, message)
|
||||||
|
|
||||||
async def handle_config_update(self, message):
|
async def handle_restart(self, message):
|
||||||
"""处理配置更新请求"""
|
"""处理服务器重启请求"""
|
||||||
content = message.get("content", {})
|
try:
|
||||||
new_config = content
|
|
||||||
|
|
||||||
# 遍历所有支持的配置模块
|
self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...")
|
||||||
updated_modules = []
|
|
||||||
for config_model in ["tts", "llm", "vad", "asr", "memory", "intent"]:
|
|
||||||
if config_model not in new_config:
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_content = new_config[config_model]
|
# 发送确认响应
|
||||||
old_content = self.config.get(config_model, {})
|
await self.websocket.send(
|
||||||
|
json.dumps(
|
||||||
# 记录配置变更
|
{
|
||||||
self.logger.bind(tag=TAG).info(
|
"type": "server_response",
|
||||||
f"配置更新: {config_model} 旧值: {json.dumps(old_content, ensure_ascii=False)} "
|
"status": "success",
|
||||||
f"新值: {json.dumps(new_content, ensure_ascii=False)}"
|
"message": "服务器重启中...",
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 深度合并配置
|
# 异步执行重启操作
|
||||||
if isinstance(old_content, dict) and isinstance(new_content, dict):
|
def restart_server():
|
||||||
merged = {**old_content, **new_content}
|
"""实际执行重启的方法"""
|
||||||
self.config[config_model] = merged
|
time.sleep(1)
|
||||||
else:
|
self.logger.bind(tag=TAG).info("执行服务器重启...")
|
||||||
self.config[config_model] = new_content
|
subprocess.Popen(
|
||||||
|
[sys.executable, "app.py"],
|
||||||
# 标记需要重新初始化的模块
|
stdin=sys.stdin,
|
||||||
if config_model in ["llm", "tts", "asr", "vad", "intent", "memory"]:
|
stdout=sys.stdout,
|
||||||
updated_modules.append(config_model)
|
stderr=sys.stderr,
|
||||||
|
start_new_session=True,
|
||||||
# 同步更新 WebSocketServer 的配置
|
|
||||||
if self.server:
|
|
||||||
async with self.server.config_lock: # 使用锁确保线程安全
|
|
||||||
for config_model in updated_modules:
|
|
||||||
self.server.config[config_model].update(new_config[config_model])
|
|
||||||
|
|
||||||
# 批量初始化模块
|
|
||||||
if updated_modules:
|
|
||||||
try:
|
|
||||||
self._initialize_components(self.config)
|
|
||||||
self.logger.bind(tag=TAG).info(
|
|
||||||
f"已重新初始化模块: {', '.join(updated_modules)}"
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
os._exit(0)
|
||||||
self.logger.bind(tag=TAG).error(f"模块初始化失败: {str(e)}")
|
|
||||||
await self.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "config_update_response",
|
|
||||||
"status": "error",
|
|
||||||
"message": f"模块初始化失败: {str(e)}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 返回成功响应
|
# 使用线程执行重启避免阻塞事件循环
|
||||||
await self.websocket.send(
|
threading.Thread(target=restart_server, daemon=True).start()
|
||||||
json.dumps(
|
|
||||||
{
|
except Exception as e:
|
||||||
"type": "config_update_response",
|
self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}")
|
||||||
"status": "success",
|
await self.websocket.send(
|
||||||
"message": f"已更新配置: {', '.join(updated_modules)}",
|
json.dumps(
|
||||||
}
|
{
|
||||||
|
"type": "server_response",
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Restart failed: {str(e)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
def _initialize_components(self, private_config):
|
def _initialize_components(self):
|
||||||
"""初始化组件"""
|
"""初始化组件"""
|
||||||
if private_config is not None:
|
if self.config.get("prompt") is not None:
|
||||||
self._initialize_models(private_config)
|
|
||||||
else:
|
|
||||||
self.prompt = self.config["prompt"]
|
self.prompt = self.config["prompt"]
|
||||||
self.change_system_prompt(self.prompt)
|
self.change_system_prompt(self.prompt)
|
||||||
|
self.logger.bind(tag=TAG).info(
|
||||||
|
f"初始化组件: prompt成功 {self.prompt[:50]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
"""初始化本地组件"""
|
||||||
|
if self.vad is None:
|
||||||
|
self.vad = self._vad
|
||||||
|
if self.asr is None:
|
||||||
|
self.asr = self._asr
|
||||||
"""加载记忆"""
|
"""加载记忆"""
|
||||||
self._initialize_memory()
|
self._initialize_memory()
|
||||||
"""加载意图识别"""
|
"""加载意图识别"""
|
||||||
@@ -318,13 +332,15 @@ class ConnectionHandler:
|
|||||||
|
|
||||||
def _init_report_threads(self):
|
def _init_report_threads(self):
|
||||||
"""初始化ASR和TTS上报线程"""
|
"""初始化ASR和TTS上报线程"""
|
||||||
if not self.read_config_from_api:
|
if not self.read_config_from_api or self.need_bind:
|
||||||
return
|
return
|
||||||
if self.tts_report_thread is None or not self.tts_report_thread.is_alive():
|
if self.chat_history_conf == 0:
|
||||||
self.tts_report_thread = threading.Thread(
|
return
|
||||||
target=self._tts_report_worker, daemon=True
|
if self.report_thread is None or not self.report_thread.is_alive():
|
||||||
|
self.report_thread = threading.Thread(
|
||||||
|
target=self._report_worker, daemon=True
|
||||||
)
|
)
|
||||||
self.tts_report_thread.start()
|
self.report_thread.start()
|
||||||
self.logger.bind(tag=TAG).info("TTS上报线程已启动")
|
self.logger.bind(tag=TAG).info("TTS上报线程已启动")
|
||||||
|
|
||||||
def _initialize_private_config(self):
|
def _initialize_private_config(self):
|
||||||
@@ -355,54 +371,21 @@ class ConnectionHandler:
|
|||||||
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
|
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
|
||||||
private_config = {}
|
private_config = {}
|
||||||
|
|
||||||
init_tts = False
|
init_llm, init_tts, init_memory, init_intent = (
|
||||||
if private_config.get("TTS", None) is not None:
|
|
||||||
init_tts = True
|
|
||||||
self.config["TTS"] = private_config["TTS"]
|
|
||||||
self.config["selected_module"]["TTS"] = private_config["selected_module"][
|
|
||||||
"TTS"
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
modules = initialize_modules(
|
|
||||||
self.logger,
|
|
||||||
private_config,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
init_tts,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
|
|
||||||
modules = {}
|
|
||||||
if modules.get("tts", None) is not None:
|
|
||||||
self.tts = modules["tts"]
|
|
||||||
if modules.get("prompt", None) is not None:
|
|
||||||
self.change_system_prompt(modules["prompt"])
|
|
||||||
private_config["prompt"] = None
|
|
||||||
return private_config
|
|
||||||
|
|
||||||
def _initialize_models(self, private_config):
|
|
||||||
init_vad, init_asr, init_llm, init_memory, init_intent = (
|
|
||||||
False,
|
|
||||||
False,
|
False,
|
||||||
False,
|
False,
|
||||||
False,
|
False,
|
||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
if private_config.get("VAD", None) is not None:
|
|
||||||
init_vad = True
|
init_vad = check_vad_update(self.common_config, private_config)
|
||||||
self.config["VAD"] = private_config["VAD"]
|
init_asr = check_asr_update(self.common_config, private_config)
|
||||||
self.config["selected_module"]["VAD"] = private_config["selected_module"][
|
|
||||||
"VAD"
|
if private_config.get("TTS", None) is not None:
|
||||||
]
|
init_tts = True
|
||||||
if private_config.get("ASR", None) is not None:
|
self.config["TTS"] = private_config["TTS"]
|
||||||
init_asr = True
|
self.config["selected_module"]["TTS"] = private_config["selected_module"][
|
||||||
self.config["ASR"] = private_config["ASR"]
|
"TTS"
|
||||||
self.config["selected_module"]["ASR"] = private_config["selected_module"][
|
|
||||||
"ASR"
|
|
||||||
]
|
]
|
||||||
if private_config.get("LLM", None) is not None:
|
if private_config.get("LLM", None) is not None:
|
||||||
init_llm = True
|
init_llm = True
|
||||||
@@ -422,8 +405,14 @@ class ConnectionHandler:
|
|||||||
self.config["selected_module"]["Intent"] = private_config[
|
self.config["selected_module"]["Intent"] = private_config[
|
||||||
"selected_module"
|
"selected_module"
|
||||||
]["Intent"]
|
]["Intent"]
|
||||||
|
if private_config.get("prompt", None) is not None:
|
||||||
|
self.config["prompt"] = private_config["prompt"]
|
||||||
|
if private_config.get("summaryMemory", None) is not None:
|
||||||
|
self.config["summaryMemory"] = private_config["summaryMemory"]
|
||||||
if private_config.get("device_max_output_size", None) is not None:
|
if private_config.get("device_max_output_size", None) is not None:
|
||||||
self.max_output_size = int(private_config["device_max_output_size"])
|
self.max_output_size = int(private_config["device_max_output_size"])
|
||||||
|
if private_config.get("chat_history_conf", None) is not None:
|
||||||
|
self.chat_history_conf = int(private_config["chat_history_conf"])
|
||||||
try:
|
try:
|
||||||
modules = initialize_modules(
|
modules = initialize_modules(
|
||||||
self.logger,
|
self.logger,
|
||||||
@@ -431,13 +420,15 @@ class ConnectionHandler:
|
|||||||
init_vad,
|
init_vad,
|
||||||
init_asr,
|
init_asr,
|
||||||
init_llm,
|
init_llm,
|
||||||
False,
|
init_tts,
|
||||||
init_memory,
|
init_memory,
|
||||||
init_intent,
|
init_intent,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
|
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
|
||||||
modules = {}
|
modules = {}
|
||||||
|
if modules.get("tts", None) is not None:
|
||||||
|
self.tts = modules["tts"]
|
||||||
if modules.get("vad", None) is not None:
|
if modules.get("vad", None) is not None:
|
||||||
self.vad = modules["vad"]
|
self.vad = modules["vad"]
|
||||||
if modules.get("asr", None) is not None:
|
if modules.get("asr", None) is not None:
|
||||||
@@ -451,14 +442,19 @@ class ConnectionHandler:
|
|||||||
|
|
||||||
def _initialize_memory(self):
|
def _initialize_memory(self):
|
||||||
"""初始化记忆模块"""
|
"""初始化记忆模块"""
|
||||||
self.memory.init_memory(self.device_id, self.llm)
|
self.memory.init_memory(
|
||||||
|
role_id=self.device_id,
|
||||||
|
llm=self.llm,
|
||||||
|
summary_memory=self.config.get("summaryMemory", None),
|
||||||
|
save_to_file=not self.read_config_from_api,
|
||||||
|
)
|
||||||
|
|
||||||
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"]
|
||||||
@@ -515,10 +511,12 @@ class ConnectionHandler:
|
|||||||
processed_chars = 0 # 跟踪已处理的字符位置
|
processed_chars = 0 # 跟踪已处理的字符位置
|
||||||
try:
|
try:
|
||||||
# 使用带记忆的对话
|
# 使用带记忆的对话
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
memory_str = None
|
||||||
self.memory.query_memory(query), self.loop
|
if self.memory is not None:
|
||||||
)
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
memory_str = future.result()
|
self.memory.query_memory(query), self.loop
|
||||||
|
)
|
||||||
|
memory_str = future.result()
|
||||||
|
|
||||||
self.logger.bind(tag=TAG).debug(f"记忆内容: {memory_str}")
|
self.logger.bind(tag=TAG).debug(f"记忆内容: {memory_str}")
|
||||||
llm_responses = self.llm.response(
|
llm_responses = self.llm.response(
|
||||||
@@ -565,7 +563,7 @@ class ConnectionHandler:
|
|||||||
future = self.executor.submit(
|
future = self.executor.submit(
|
||||||
self.speak_and_play, segment_text, text_index
|
self.speak_and_play, segment_text, text_index
|
||||||
)
|
)
|
||||||
self.tts_queue.put(future)
|
self.tts_queue.put((future, text_index))
|
||||||
processed_chars += len(segment_text_raw) # 更新已处理字符位置
|
processed_chars += len(segment_text_raw) # 更新已处理字符位置
|
||||||
|
|
||||||
# 处理最后剩余的文本
|
# 处理最后剩余的文本
|
||||||
@@ -579,7 +577,7 @@ class ConnectionHandler:
|
|||||||
future = self.executor.submit(
|
future = self.executor.submit(
|
||||||
self.speak_and_play, segment_text, text_index
|
self.speak_and_play, segment_text, text_index
|
||||||
)
|
)
|
||||||
self.tts_queue.put(future)
|
self.tts_queue.put((future, text_index))
|
||||||
|
|
||||||
self.llm_finish_task = True
|
self.llm_finish_task = True
|
||||||
self.dialogue.put(Message(role="assistant", content="".join(response_message)))
|
self.dialogue.put(Message(role="assistant", content="".join(response_message)))
|
||||||
@@ -606,10 +604,12 @@ class ConnectionHandler:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# 使用带记忆的对话
|
# 使用带记忆的对话
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
memory_str = None
|
||||||
self.memory.query_memory(query), self.loop
|
if self.memory is not None:
|
||||||
)
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
memory_str = future.result()
|
self.memory.query_memory(query), self.loop
|
||||||
|
)
|
||||||
|
memory_str = future.result()
|
||||||
|
|
||||||
# self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
|
# self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
|
||||||
|
|
||||||
@@ -695,7 +695,7 @@ class ConnectionHandler:
|
|||||||
future = self.executor.submit(
|
future = self.executor.submit(
|
||||||
self.speak_and_play, segment_text, text_index
|
self.speak_and_play, segment_text, text_index
|
||||||
)
|
)
|
||||||
self.tts_queue.put(future)
|
self.tts_queue.put((future, text_index))
|
||||||
# 更新已处理字符位置
|
# 更新已处理字符位置
|
||||||
processed_chars += len(segment_text_raw)
|
processed_chars += len(segment_text_raw)
|
||||||
|
|
||||||
@@ -754,7 +754,7 @@ class ConnectionHandler:
|
|||||||
future = self.executor.submit(
|
future = self.executor.submit(
|
||||||
self.speak_and_play, segment_text, text_index
|
self.speak_and_play, segment_text, text_index
|
||||||
)
|
)
|
||||||
self.tts_queue.put(future)
|
self.tts_queue.put((future, text_index))
|
||||||
|
|
||||||
# 存储对话内容
|
# 存储对话内容
|
||||||
if len(response_message) > 0:
|
if len(response_message) > 0:
|
||||||
@@ -816,7 +816,7 @@ class ConnectionHandler:
|
|||||||
text = result.response
|
text = result.response
|
||||||
self.recode_first_last_text(text, text_index)
|
self.recode_first_last_text(text, text_index)
|
||||||
future = self.executor.submit(self.speak_and_play, text, text_index)
|
future = self.executor.submit(self.speak_and_play, text, text_index)
|
||||||
self.tts_queue.put(future)
|
self.tts_queue.put((future, text_index))
|
||||||
self.dialogue.put(Message(role="assistant", content=text))
|
self.dialogue.put(Message(role="assistant", content=text))
|
||||||
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
|
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
|
||||||
text = result.result
|
text = result.result
|
||||||
@@ -842,14 +842,20 @@ 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:
|
||||||
text = result.result
|
text = result.result
|
||||||
self.recode_first_last_text(text, text_index)
|
self.recode_first_last_text(text, text_index)
|
||||||
future = self.executor.submit(self.speak_and_play, text, text_index)
|
future = self.executor.submit(self.speak_and_play, text, text_index)
|
||||||
self.tts_queue.put(future)
|
self.tts_queue.put((future, text_index))
|
||||||
self.dialogue.put(Message(role="assistant", content=text))
|
self.dialogue.put(Message(role="assistant", content=text))
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
@@ -859,7 +865,10 @@ class ConnectionHandler:
|
|||||||
text = None
|
text = None
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
future = self.tts_queue.get(timeout=1)
|
item = self.tts_queue.get(timeout=1)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
future, text_index = item # 解包获取 Future 和 text_index
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
if self.stop_event.is_set():
|
if self.stop_event.is_set():
|
||||||
break
|
break
|
||||||
@@ -867,11 +876,11 @@ class ConnectionHandler:
|
|||||||
if future is None:
|
if future is None:
|
||||||
continue
|
continue
|
||||||
text = None
|
text = None
|
||||||
opus_datas, text_index, tts_file = [], 0, None
|
audio_datas, tts_file = [], None
|
||||||
try:
|
try:
|
||||||
self.logger.bind(tag=TAG).debug("正在处理TTS任务...")
|
self.logger.bind(tag=TAG).debug("正在处理TTS任务...")
|
||||||
tts_timeout = int(self.config.get("tts_timeout", 10))
|
tts_timeout = int(self.config.get("tts_timeout", 10))
|
||||||
tts_file, text, text_index = future.result(timeout=tts_timeout)
|
tts_file, text, _ = future.result(timeout=tts_timeout)
|
||||||
if text is None or len(text) <= 0:
|
if text is None or len(text) <= 0:
|
||||||
self.logger.bind(tag=TAG).error(
|
self.logger.bind(tag=TAG).error(
|
||||||
f"TTS出错:{text_index}: tts text is empty"
|
f"TTS出错:{text_index}: tts text is empty"
|
||||||
@@ -885,9 +894,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":
|
||||||
# 在这里上报TTS数据(使用文件路径)
|
audio_datas, _ = self.tts.audio_to_pcm_data(tts_file)
|
||||||
enqueue_tts_report(self, 2, text, opus_datas)
|
else:
|
||||||
|
audio_datas, _ = self.tts.audio_to_opus_data(tts_file)
|
||||||
|
# 在这里上报TTS数据
|
||||||
|
enqueue_tts_report(self, 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}"
|
||||||
@@ -898,7 +910,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
|
||||||
@@ -929,13 +941,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:
|
||||||
@@ -943,13 +955,12 @@ class ConnectionHandler:
|
|||||||
f"audio_play_priority priority_thread: {text} {e}"
|
f"audio_play_priority priority_thread: {text} {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _tts_report_worker(self):
|
def _report_worker(self):
|
||||||
"""TTS上报工作线程"""
|
"""聊天记录上报工作线程"""
|
||||||
|
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
# 从队列获取数据,设置超时以便定期检查停止事件
|
# 从队列获取数据,设置超时以便定期检查停止事件
|
||||||
item = self.tts_report_queue.get(timeout=1)
|
item = self.report_queue.get(timeout=1)
|
||||||
if item is None: # 检测毒丸对象
|
if item is None: # 检测毒丸对象
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -957,18 +968,18 @@ class ConnectionHandler:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 执行上报(传入二进制数据)
|
# 执行上报(传入二进制数据)
|
||||||
report_tts(self, type, text, audio_data)
|
report(self, type, text, audio_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.bind(tag=TAG).error(f"TTS上报线程异常: {e}")
|
self.logger.bind(tag=TAG).error(f"聊天记录上报线程异常: {e}")
|
||||||
finally:
|
finally:
|
||||||
# 标记任务完成
|
# 标记任务完成
|
||||||
self.tts_report_queue.task_done()
|
self.report_queue.task_done()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.bind(tag=TAG).error(f"TTS上报工作线程异常: {e}")
|
self.logger.bind(tag=TAG).error(f"聊天记录上报工作线程异常: {e}")
|
||||||
|
|
||||||
self.logger.bind(tag=TAG).info("TTS上报线程已退出")
|
self.logger.bind(tag=TAG).info("聊天记录上报线程已退出")
|
||||||
|
|
||||||
def speak_and_play(self, text, text_index=0):
|
def speak_and_play(self, text, text_index=0):
|
||||||
if text is None or len(text) <= 0:
|
if text is None or len(text) <= 0:
|
||||||
@@ -997,6 +1008,7 @@ class ConnectionHandler:
|
|||||||
|
|
||||||
async def close(self, ws=None):
|
async def close(self, ws=None):
|
||||||
"""资源清理方法"""
|
"""资源清理方法"""
|
||||||
|
|
||||||
# 取消超时任务
|
# 取消超时任务
|
||||||
if self.timeout_task:
|
if self.timeout_task:
|
||||||
self.timeout_task.cancel()
|
self.timeout_task.cancel()
|
||||||
@@ -1006,43 +1018,42 @@ class ConnectionHandler:
|
|||||||
if hasattr(self, "mcp_manager") and self.mcp_manager:
|
if hasattr(self, "mcp_manager") and self.mcp_manager:
|
||||||
await self.mcp_manager.cleanup_all()
|
await self.mcp_manager.cleanup_all()
|
||||||
|
|
||||||
# 触发停止事件并清理资源
|
# 触发停止事件
|
||||||
if self.stop_event:
|
if self.stop_event:
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
|
|
||||||
# 立即关闭线程池
|
|
||||||
if self.executor:
|
|
||||||
self.executor.shutdown(wait=False, cancel_futures=True)
|
|
||||||
self.executor = None
|
|
||||||
|
|
||||||
# 添加毒丸对象到上报队列确保线程退出
|
|
||||||
self.tts_report_queue.put(None)
|
|
||||||
|
|
||||||
# 清空任务队列
|
# 清空任务队列
|
||||||
self.clear_queues()
|
self.clear_queues()
|
||||||
|
|
||||||
|
# 关闭WebSocket连接
|
||||||
if ws:
|
if ws:
|
||||||
await ws.close()
|
await ws.close()
|
||||||
elif self.websocket:
|
elif self.websocket:
|
||||||
await self.websocket.close()
|
await self.websocket.close()
|
||||||
|
|
||||||
|
# 最后关闭线程池(避免阻塞)
|
||||||
|
if self.executor:
|
||||||
|
self.executor.shutdown(wait=False)
|
||||||
|
self.executor = None
|
||||||
|
|
||||||
self.logger.bind(tag=TAG).info("连接资源已释放")
|
self.logger.bind(tag=TAG).info("连接资源已释放")
|
||||||
|
|
||||||
def clear_queues(self):
|
def clear_queues(self):
|
||||||
# 清空所有任务队列
|
"""清空所有任务队列"""
|
||||||
self.logger.bind(tag=TAG).debug(
|
self.logger.bind(tag=TAG).debug(
|
||||||
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
|
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 使用非阻塞方式清空队列
|
||||||
for q in [self.tts_queue, self.audio_play_queue]:
|
for q in [self.tts_queue, self.audio_play_queue]:
|
||||||
if not q:
|
if not q:
|
||||||
continue
|
continue
|
||||||
while not q.empty():
|
while True:
|
||||||
try:
|
try:
|
||||||
q.get_nowait()
|
q.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
break
|
||||||
q.queue.clear()
|
|
||||||
# 添加毒丸信号到队列,确保线程退出
|
|
||||||
# q.queue.put(None)
|
|
||||||
self.logger.bind(tag=TAG).debug(
|
self.logger.bind(tag=TAG).debug(
|
||||||
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
|
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
|
||||||
)
|
)
|
||||||
@@ -1091,6 +1102,7 @@ def filter_sensitive_info(config: dict) -> dict:
|
|||||||
"personal_access_token",
|
"personal_access_token",
|
||||||
"access_token",
|
"access_token",
|
||||||
"token",
|
"token",
|
||||||
|
"secret",
|
||||||
"access_key_secret",
|
"access_key_secret",
|
||||||
"secret_key",
|
"secret_key",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import queue
|
|||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
|
|
||||||
async def handleAbortMessage(conn):
|
async def handleAbortMessage(conn):
|
||||||
logger.bind(tag=TAG).info("Abort message received")
|
conn.logger.bind(tag=TAG).info("Abort message received")
|
||||||
# 设置成打断状态,会自动打断llm、tts任务
|
# 设置成打断状态,会自动打断llm、tts任务
|
||||||
conn.client_abort = True
|
conn.client_abort = True
|
||||||
conn.clear_queues()
|
conn.clear_queues()
|
||||||
# 打断客户端说话状态
|
# 打断客户端说话状态
|
||||||
await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id}))
|
await conn.websocket.send(
|
||||||
|
json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id})
|
||||||
|
)
|
||||||
conn.clearSpeakStatus()
|
conn.clearSpeakStatus()
|
||||||
logger.bind(tag=TAG).info("Abort message received-end")
|
conn.logger.bind(tag=TAG).info("Abort message received-end")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from plugins_func.register import FunctionRegistry, ActionResponse, Action, Tool
|
|||||||
from plugins_func.functions.hass_init import append_devices_to_prompt
|
from plugins_func.functions.hass_init import append_devices_to_prompt
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
|
|
||||||
class FunctionHandler:
|
class FunctionHandler:
|
||||||
@@ -40,7 +39,9 @@ class FunctionHandler:
|
|||||||
for func in self.functions_desc:
|
for func in self.functions_desc:
|
||||||
func_names.append(func["function"]["name"])
|
func_names.append(func["function"]["name"])
|
||||||
# 打印当前支持的函数列表
|
# 打印当前支持的函数列表
|
||||||
logger.bind(tag=TAG).info(f"当前支持的函数列表: {func_names}")
|
self.conn.logger.bind(tag=TAG, session_id=self.conn.session_id).info(
|
||||||
|
f"当前支持的函数列表: {func_names}"
|
||||||
|
)
|
||||||
return func_names
|
return func_names
|
||||||
|
|
||||||
def get_functions(self):
|
def get_functions(self):
|
||||||
@@ -79,7 +80,9 @@ class FunctionHandler:
|
|||||||
func = funcItem.func
|
func = funcItem.func
|
||||||
arguments = function_call_data["arguments"]
|
arguments = function_call_data["arguments"]
|
||||||
arguments = json.loads(arguments) if arguments else {}
|
arguments = json.loads(arguments) if arguments else {}
|
||||||
logger.bind(tag=TAG).debug(f"调用函数: {function_name}, 参数: {arguments}")
|
self.conn.logger.bind(tag=TAG).debug(
|
||||||
|
f"调用函数: {function_name}, 参数: {arguments}"
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
funcItem.type == ToolType.SYSTEM_CTL
|
funcItem.type == ToolType.SYSTEM_CTL
|
||||||
or funcItem.type == ToolType.IOT_CTL
|
or funcItem.type == ToolType.IOT_CTL
|
||||||
@@ -94,6 +97,6 @@ class FunctionHandler:
|
|||||||
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
|
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"处理function call错误: {e}")
|
self.conn.logger.bind(tag=TAG).error(f"处理function call错误: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,7 +8,6 @@ import random
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
WAKEUP_CONFIG = {
|
WAKEUP_CONFIG = {
|
||||||
"dir": "config/assets/",
|
"dir": "config/assets/",
|
||||||
@@ -21,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))
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +51,7 @@ async def checkWakeupWords(conn, text):
|
|||||||
if file is None:
|
if file is None:
|
||||||
asyncio.create_task(wakeupWordsResponse(conn))
|
asyncio.create_task(wakeupWordsResponse(conn))
|
||||||
return False
|
return False
|
||||||
opus_packets, duration = conn.tts.audio_to_opus_data(file)
|
opus_packets, _ = conn.tts.audio_to_opus_data(file)
|
||||||
text_hello = WAKEUP_CONFIG["text"]
|
text_hello = WAKEUP_CONFIG["text"]
|
||||||
if not text_hello:
|
if not text_hello:
|
||||||
text_hello = text
|
text_hello = text
|
||||||
@@ -75,7 +82,7 @@ async def wakeupWordsResponse(conn):
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
wait_max_time -= 1
|
wait_max_time -= 1
|
||||||
if wait_max_time <= 0:
|
if wait_max_time <= 0:
|
||||||
logger.bind(tag=TAG).error("连接对象没有llm")
|
conn.logger.bind(tag=TAG).error("连接对象没有llm")
|
||||||
return
|
return
|
||||||
|
|
||||||
"""唤醒词响应"""
|
"""唤醒词响应"""
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ 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
|
from loguru import logger
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_user_intent(conn, text):
|
async def handle_user_intent(conn, text):
|
||||||
@@ -19,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进行意图分析
|
||||||
@@ -36,7 +36,7 @@ async def check_direct_exit(conn, text):
|
|||||||
cmd_exit = conn.cmd_exit
|
cmd_exit = conn.cmd_exit
|
||||||
for cmd in cmd_exit:
|
for cmd in cmd_exit:
|
||||||
if text == cmd:
|
if text == cmd:
|
||||||
logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}")
|
conn.logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}")
|
||||||
await send_stt_message(conn, text)
|
await send_stt_message(conn, text)
|
||||||
await conn.close()
|
await conn.close()
|
||||||
return True
|
return True
|
||||||
@@ -46,7 +46,7 @@ async def check_direct_exit(conn, text):
|
|||||||
async def analyze_intent_with_llm(conn, text):
|
async def analyze_intent_with_llm(conn, text):
|
||||||
"""使用LLM分析用户意图"""
|
"""使用LLM分析用户意图"""
|
||||||
if not hasattr(conn, "intent") or not conn.intent:
|
if not hasattr(conn, "intent") or not conn.intent:
|
||||||
logger.bind(tag=TAG).warning("意图识别服务未初始化")
|
conn.logger.bind(tag=TAG).warning("意图识别服务未初始化")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 对话历史记录
|
# 对话历史记录
|
||||||
@@ -55,7 +55,7 @@ async def analyze_intent_with_llm(conn, text):
|
|||||||
intent_result = await conn.intent.detect_intent(conn, dialogue.dialogue, text)
|
intent_result = await conn.intent.detect_intent(conn, dialogue.dialogue, text)
|
||||||
return intent_result
|
return intent_result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}")
|
conn.logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ async def process_intent_result(conn, intent_result, original_text):
|
|||||||
# 检查是否有function_call
|
# 检查是否有function_call
|
||||||
if "function_call" in intent_data:
|
if "function_call" in intent_data:
|
||||||
# 直接从意图识别获取了function_call
|
# 直接从意图识别获取了function_call
|
||||||
logger.bind(tag=TAG).debug(
|
conn.logger.bind(tag=TAG).debug(
|
||||||
f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
|
f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
|
||||||
)
|
)
|
||||||
function_name = intent_data["function_call"]["name"]
|
function_name = intent_data["function_call"]["name"]
|
||||||
@@ -102,49 +102,51 @@ 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)
|
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)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")
|
conn.logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")
|
||||||
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 ""
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from plugins_func.register import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_async_function(async_func):
|
def wrap_async_function(async_func):
|
||||||
@@ -21,7 +20,7 @@ def wrap_async_function(async_func):
|
|||||||
# 获取连接对象(第一个参数)
|
# 获取连接对象(第一个参数)
|
||||||
conn = args[0]
|
conn = args[0]
|
||||||
if not hasattr(conn, "loop"):
|
if not hasattr(conn, "loop"):
|
||||||
logger.bind(tag=TAG).error("Connection对象没有loop属性")
|
conn.logger.bind(tag=TAG).error("Connection对象没有loop属性")
|
||||||
return ActionResponse(
|
return ActionResponse(
|
||||||
Action.ERROR,
|
Action.ERROR,
|
||||||
"Connection对象没有loop属性",
|
"Connection对象没有loop属性",
|
||||||
@@ -35,7 +34,7 @@ def wrap_async_function(async_func):
|
|||||||
# 等待结果返回
|
# 等待结果返回
|
||||||
return future.result()
|
return future.result()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
|
conn.logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
|
||||||
return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}")
|
return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}")
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -57,7 +56,7 @@ def create_iot_function(device_name, method_name, method_info):
|
|||||||
response_failure = "操作失败"
|
response_failure = "操作失败"
|
||||||
|
|
||||||
# 打印响应参数
|
# 打印响应参数
|
||||||
logger.bind(tag=TAG).debug(
|
conn.logger.bind(tag=TAG).debug(
|
||||||
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,7 +85,9 @@ def create_iot_function(device_name, method_name, method_info):
|
|||||||
|
|
||||||
return ActionResponse(Action.RESPONSE, result, response)
|
return ActionResponse(Action.RESPONSE, result, response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"执行{device_name}的{method_name}操作失败: {e}")
|
conn.logger.bind(tag=TAG).error(
|
||||||
|
f"执行{device_name}的{method_name}操作失败: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# 操作失败时使用大模型提供的失败响应
|
# 操作失败时使用大模型提供的失败响应
|
||||||
response = response_failure
|
response = response_failure
|
||||||
@@ -104,7 +105,7 @@ def create_iot_query_function(device_name, prop_name, prop_info):
|
|||||||
async def iot_query_function(conn, response_success=None, response_failure=None):
|
async def iot_query_function(conn, response_success=None, response_failure=None):
|
||||||
try:
|
try:
|
||||||
# 打印响应参数
|
# 打印响应参数
|
||||||
logger.bind(tag=TAG).info(
|
conn.logger.bind(tag=TAG).info(
|
||||||
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,7 +123,9 @@ def create_iot_query_function(device_name, prop_name, prop_info):
|
|||||||
|
|
||||||
return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response)
|
return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"查询{device_name}的{prop_name}时出错: {e}")
|
conn.logger.bind(tag=TAG).error(
|
||||||
|
f"查询{device_name}的{prop_name}时出错: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# 查询出错时使用大模型提供的失败响应
|
# 查询出错时使用大模型提供的失败响应
|
||||||
response = response_failure
|
response = response_failure
|
||||||
@@ -280,7 +283,7 @@ async def handleIotDescriptors(conn, descriptors):
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
wait_max_time -= 1
|
wait_max_time -= 1
|
||||||
if wait_max_time <= 0:
|
if wait_max_time <= 0:
|
||||||
logger.bind(tag=TAG).debug("连接对象没有func_handler")
|
conn.logger.bind(tag=TAG).debug("连接对象没有func_handler")
|
||||||
return
|
return
|
||||||
"""处理物联网描述"""
|
"""处理物联网描述"""
|
||||||
functions_changed = False
|
functions_changed = False
|
||||||
@@ -314,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)
|
||||||
@@ -323,7 +326,7 @@ async def handleIotDescriptors(conn, descriptors):
|
|||||||
if hasattr(conn, "func_handler"):
|
if hasattr(conn, "func_handler"):
|
||||||
for func_name in device_functions:
|
for func_name in device_functions:
|
||||||
conn.func_handler.function_registry.register_function(func_name)
|
conn.func_handler.function_registry.register_function(func_name)
|
||||||
logger.bind(tag=TAG).info(
|
conn.logger.bind(tag=TAG).info(
|
||||||
f"注册IOT函数到function handler: {func_name}"
|
f"注册IOT函数到function handler: {func_name}"
|
||||||
)
|
)
|
||||||
functions_changed = True
|
functions_changed = True
|
||||||
@@ -332,8 +335,8 @@ async def handleIotDescriptors(conn, descriptors):
|
|||||||
if functions_changed and hasattr(conn, "func_handler"):
|
if functions_changed and hasattr(conn, "func_handler"):
|
||||||
conn.func_handler.upload_functions_desc()
|
conn.func_handler.upload_functions_desc()
|
||||||
func_names = conn.func_handler.current_support_functions()
|
func_names = conn.func_handler.current_support_functions()
|
||||||
logger.bind(tag=TAG).info(f"设备类型: {type_id}")
|
conn.logger.bind(tag=TAG).info(f"设备类型: {type_id}")
|
||||||
logger.bind(tag=TAG).info(
|
conn.logger.bind(tag=TAG).info(
|
||||||
f"更新function描述列表完成,当前支持的函数: {func_names}"
|
f"更新function描述列表完成,当前支持的函数: {func_names}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,13 +350,13 @@ async def handleIotStatus(conn, states):
|
|||||||
for k, v in state["state"].items():
|
for k, v in state["state"].items():
|
||||||
if property_item["name"] == k:
|
if property_item["name"] == k:
|
||||||
if type(v) != type(property_item["value"]):
|
if type(v) != type(property_item["value"]):
|
||||||
logger.bind(tag=TAG).error(
|
conn.logger.bind(tag=TAG).error(
|
||||||
f"属性{property_item['name']}的值类型不匹配"
|
f"属性{property_item['name']}的值类型不匹配"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
property_item["value"] = v
|
property_item["value"] = v
|
||||||
logger.bind(tag=TAG).info(
|
conn.logger.bind(tag=TAG).info(
|
||||||
f"物联网状态更新: {key} , {property_item['name']} = {v}"
|
f"物联网状态更新: {key} , {property_item['name']} = {v}"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
@@ -367,7 +370,7 @@ async def get_iot_status(conn, name, property_name):
|
|||||||
for property_item in value.properties:
|
for property_item in value.properties:
|
||||||
if property_item["name"] == property_name:
|
if property_item["name"] == property_name:
|
||||||
return property_item["value"]
|
return property_item["value"]
|
||||||
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
|
conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -378,16 +381,16 @@ async def set_iot_status(conn, name, property_name, value):
|
|||||||
for property_item in iot_descriptor.properties:
|
for property_item in iot_descriptor.properties:
|
||||||
if property_item["name"] == property_name:
|
if property_item["name"] == property_name:
|
||||||
if type(value) != type(property_item["value"]):
|
if type(value) != type(property_item["value"]):
|
||||||
logger.bind(tag=TAG).error(
|
conn.logger.bind(tag=TAG).error(
|
||||||
f"属性{property_item['name']}的值类型不匹配"
|
f"属性{property_item['name']}的值类型不匹配"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
property_item["value"] = value
|
property_item["value"] = value
|
||||||
logger.bind(tag=TAG).info(
|
conn.logger.bind(tag=TAG).info(
|
||||||
f"物联网状态更新: {name} , {property_name} = {value}"
|
f"物联网状态更新: {name} , {property_name} = {value}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
|
conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
|
||||||
|
|
||||||
|
|
||||||
async def send_iot_conn(conn, name, method_name, parameters):
|
async def send_iot_conn(conn, name, method_name, parameters):
|
||||||
@@ -409,6 +412,6 @@ async def send_iot_conn(conn, name, method_name, parameters):
|
|||||||
command["parameters"] = parameters
|
command["parameters"] = parameters
|
||||||
send_message = json.dumps({"type": "iot", "commands": [command]})
|
send_message = json.dumps({"type": "iot", "commands": [command]})
|
||||||
await conn.websocket.send(send_message)
|
await conn.websocket.send(send_message)
|
||||||
logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
|
conn.logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
|
||||||
return
|
return
|
||||||
logger.bind(tag=TAG).error(f"未找到方法{method_name}")
|
conn.logger.bind(tag=TAG).error(f"未找到方法{method_name}")
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
from config.logger import setup_logging
|
|
||||||
import time
|
import time
|
||||||
import copy
|
import copy
|
||||||
from core.utils.util import remove_punctuation_and_length
|
from core.utils.util import remove_punctuation_and_length
|
||||||
from core.handle.sendAudioHandle import send_stt_message
|
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.reportHandle import enqueue_asr_report
|
||||||
|
from core.utils.util import audio_to_data
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
|
|
||||||
async def handleAudioMessage(conn, audio):
|
async def handleAudioMessage(conn, audio):
|
||||||
if not conn.asr_server_receive:
|
if conn.vad is None:
|
||||||
logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
|
|
||||||
return
|
return
|
||||||
if conn.client_listen_mode == "auto":
|
if not conn.asr_server_receive:
|
||||||
|
conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
|
||||||
|
return
|
||||||
|
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
|
||||||
@@ -39,11 +40,11 @@ async def handleAudioMessage(conn, audio):
|
|||||||
conn.asr_server_receive = True
|
conn.asr_server_receive = True
|
||||||
else:
|
else:
|
||||||
text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
|
text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
|
||||||
logger.bind(tag=TAG).info(f"识别文本: {text}")
|
conn.logger.bind(tag=TAG).info(f"识别文本: {text}")
|
||||||
text_len, _ = remove_punctuation_and_length(text)
|
text_len, _ = remove_punctuation_and_length(text)
|
||||||
if text_len > 0:
|
if text_len > 0:
|
||||||
# 使用自定义模块进行上报
|
# 使用自定义模块进行上报
|
||||||
enqueue_tts_report(conn, 1, text, copy.deepcopy(conn.asr_audio))
|
enqueue_asr_report(conn, text, copy.deepcopy(conn.asr_audio))
|
||||||
|
|
||||||
await startToChat(conn, text)
|
await startToChat(conn, text)
|
||||||
else:
|
else:
|
||||||
@@ -75,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:
|
||||||
@@ -97,9 +98,14 @@ async def no_voice_close_connect(conn):
|
|||||||
conn.close_after_chat = True
|
conn.close_after_chat = True
|
||||||
conn.client_abort = False
|
conn.client_abort = False
|
||||||
conn.asr_server_receive = False
|
conn.asr_server_receive = False
|
||||||
prompt = (
|
end_prompt = conn.config.get("end_prompt", {})
|
||||||
"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。"
|
if end_prompt and end_prompt.get("enable", True) is False:
|
||||||
)
|
conn.logger.bind(tag=TAG).info("结束对话,无需发送结束提示语")
|
||||||
|
await conn.close()
|
||||||
|
return
|
||||||
|
prompt = end_prompt.get("prompt")
|
||||||
|
if not prompt:
|
||||||
|
prompt = "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。!"
|
||||||
await startToChat(conn, prompt)
|
await startToChat(conn, prompt)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +116,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, _ = conn.tts.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
|
||||||
|
|
||||||
@@ -119,7 +125,7 @@ async def check_bind_device(conn):
|
|||||||
if conn.bind_code:
|
if conn.bind_code:
|
||||||
# 确保bind_code是6位数字
|
# 确保bind_code是6位数字
|
||||||
if len(conn.bind_code) != 6:
|
if len(conn.bind_code) != 6:
|
||||||
logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
|
conn.logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
|
||||||
text = "绑定码格式错误,请检查配置。"
|
text = "绑定码格式错误,请检查配置。"
|
||||||
await send_stt_message(conn, text)
|
await send_stt_message(conn, text)
|
||||||
return
|
return
|
||||||
@@ -132,7 +138,7 @@ async def check_bind_device(conn):
|
|||||||
|
|
||||||
# 播放提示音
|
# 播放提示音
|
||||||
music_path = "config/assets/bind_code.wav"
|
music_path = "config/assets/bind_code.wav"
|
||||||
opus_packets, _ = conn.tts.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))
|
||||||
|
|
||||||
# 逐个播放数字
|
# 逐个播放数字
|
||||||
@@ -140,10 +146,10 @@ 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, _ = conn.tts.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:
|
||||||
logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
|
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
|
||||||
@@ -152,5 +158,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, _ = conn.tts.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))
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
TTS上报功能已集成到ConnectionHandler类中。
|
||||||
|
|
||||||
|
上报功能包括:
|
||||||
|
1. 每个连接对象拥有自己的上报队列和处理线程
|
||||||
|
2. 上报线程的生命周期与连接对象绑定
|
||||||
|
3. 使用ConnectionHandler.enqueue_tts_report方法进行上报
|
||||||
|
|
||||||
|
具体实现请参考core/connection.py中的相关代码。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import opuslib_next
|
||||||
|
|
||||||
|
from config.manage_api_client import report as manage_report
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
|
||||||
|
|
||||||
|
def report(conn, type, text, opus_data):
|
||||||
|
"""执行聊天记录上报操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: 连接对象
|
||||||
|
type: 上报类型,1为用户,2为智能体
|
||||||
|
text: 合成文本
|
||||||
|
opus_data: opus音频数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if opus_data:
|
||||||
|
audio_data = opus_to_wav(conn, opus_data)
|
||||||
|
else:
|
||||||
|
audio_data = None
|
||||||
|
# 执行上报
|
||||||
|
manage_report(
|
||||||
|
mac_address=conn.device_id,
|
||||||
|
session_id=conn.session_id,
|
||||||
|
chat_type=type,
|
||||||
|
content=text,
|
||||||
|
audio=audio_data,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
conn.logger.bind(tag=TAG).error(f"聊天记录上报失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def opus_to_wav(conn, opus_data):
|
||||||
|
"""将Opus数据转换为WAV格式的字节流
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: 输出目录(保留参数以保持接口兼容)
|
||||||
|
opus_data: opus音频数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: WAV格式的音频数据
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
||||||
|
|
||||||
|
if not pcm_data:
|
||||||
|
raise ValueError("没有有效的PCM数据")
|
||||||
|
|
||||||
|
# 创建WAV文件头
|
||||||
|
pcm_data_bytes = b"".join(pcm_data)
|
||||||
|
num_samples = len(pcm_data_bytes) // 2 # 16-bit samples
|
||||||
|
|
||||||
|
# WAV文件头
|
||||||
|
wav_header = bytearray()
|
||||||
|
wav_header.extend(b"RIFF") # ChunkID
|
||||||
|
wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize
|
||||||
|
wav_header.extend(b"WAVE") # Format
|
||||||
|
wav_header.extend(b"fmt ") # Subchunk1ID
|
||||||
|
wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size
|
||||||
|
wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM)
|
||||||
|
wav_header.extend((1).to_bytes(2, "little")) # NumChannels
|
||||||
|
wav_header.extend((16000).to_bytes(4, "little")) # SampleRate
|
||||||
|
wav_header.extend((32000).to_bytes(4, "little")) # ByteRate
|
||||||
|
wav_header.extend((2).to_bytes(2, "little")) # BlockAlign
|
||||||
|
wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample
|
||||||
|
wav_header.extend(b"data") # Subchunk2ID
|
||||||
|
wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size
|
||||||
|
|
||||||
|
# 返回完整的WAV数据
|
||||||
|
return bytes(wav_header) + pcm_data_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_tts_report(conn, text, opus_data):
|
||||||
|
if not conn.read_config_from_api or conn.need_bind or not conn.report_tts_enable:
|
||||||
|
return
|
||||||
|
if conn.chat_history_conf == 0:
|
||||||
|
return
|
||||||
|
"""将TTS数据加入上报队列
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: 连接对象
|
||||||
|
text: 合成文本
|
||||||
|
opus_data: opus音频数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
|
||||||
|
if conn.chat_history_conf == 2:
|
||||||
|
conn.report_queue.put((2, text, opus_data))
|
||||||
|
conn.logger.bind(tag=TAG).debug(
|
||||||
|
f"TTS数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.report_queue.put((2, text, None))
|
||||||
|
conn.logger.bind(tag=TAG).debug(
|
||||||
|
f"TTS数据已加入上报队列: {conn.device_id}, 不上报音频"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_asr_report(conn, text, opus_data):
|
||||||
|
if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable:
|
||||||
|
return
|
||||||
|
if conn.chat_history_conf == 0:
|
||||||
|
return
|
||||||
|
"""将ASR数据加入上报队列
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: 连接对象
|
||||||
|
text: 合成文本
|
||||||
|
opus_data: opus音频数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
|
||||||
|
if conn.chat_history_conf == 2:
|
||||||
|
conn.report_queue.put((1, text, opus_data))
|
||||||
|
conn.logger.bind(tag=TAG).debug(
|
||||||
|
f"ASR数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.report_queue.put((1, text, None))
|
||||||
|
conn.logger.bind(tag=TAG).debug(
|
||||||
|
f"ASR数据已加入上报队列: {conn.device_id}, 不上报音频"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
conn.logger.bind(tag=TAG).error(f"加入ASR上报队列失败: {text}, {e}")
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
from config.logger import setup_logging
|
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from core.utils.util import get_string_no_punctuation_or_emoji, analyze_emotion
|
from core.utils.util import get_string_no_punctuation_or_emoji, analyze_emotion
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
emoji_map = {
|
emoji_map = {
|
||||||
"neutral": "😶",
|
"neutral": "😶",
|
||||||
@@ -49,10 +47,10 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if text_index == conn.tts_first_text_index:
|
if text_index == conn.tts_first_text_index:
|
||||||
logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
|
conn.logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
|
||||||
await send_tts_message(conn, "sentence_start", text)
|
await send_tts_message(conn, "sentence_start", text)
|
||||||
|
|
||||||
is_first_audio = (text_index == conn.tts_first_text_index)
|
is_first_audio = text_index == conn.tts_first_text_index
|
||||||
await sendAudio(conn, audios, pre_buffer=is_first_audio)
|
await sendAudio(conn, audios, pre_buffer=is_first_audio)
|
||||||
|
|
||||||
await send_tts_message(conn, "sentence_end", text)
|
await send_tts_message(conn, "sentence_end", text)
|
||||||
@@ -117,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()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from config.logger import setup_logging
|
|
||||||
import json
|
import json
|
||||||
from core.handle.abortHandle import handleAbortMessage
|
from core.handle.abortHandle import handleAbortMessage
|
||||||
from core.handle.helloHandle import handleHelloMessage
|
from core.handle.helloHandle import handleHelloMessage
|
||||||
@@ -6,29 +5,30 @@ from core.utils.util import remove_punctuation_and_length
|
|||||||
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
|
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
|
||||||
from core.handle.sendAudioHandle import send_stt_message, send_tts_message
|
from core.handle.sendAudioHandle import send_stt_message, send_tts_message
|
||||||
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
|
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
|
||||||
from core.handle.ttsReportHandle import enqueue_tts_report
|
from core.handle.reportHandle import enqueue_asr_report
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
|
|
||||||
async def handleTextMessage(conn, message):
|
async def handleTextMessage(conn, message):
|
||||||
"""处理文本消息"""
|
"""处理文本消息"""
|
||||||
logger.bind(tag=TAG).info(f"收到文本消息:{message}")
|
conn.logger.bind(tag=TAG).info(f"收到文本消息:{message}")
|
||||||
try:
|
try:
|
||||||
msg_json = json.loads(message)
|
msg_json = json.loads(message)
|
||||||
if isinstance(msg_json, int):
|
if isinstance(msg_json, int):
|
||||||
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":
|
||||||
if "mode" in msg_json:
|
if "mode" in msg_json:
|
||||||
conn.client_listen_mode = msg_json["mode"]
|
conn.client_listen_mode = msg_json["mode"]
|
||||||
logger.bind(tag=TAG).debug(f"客户端拾音模式:{conn.client_listen_mode}")
|
conn.logger.bind(tag=TAG).debug(
|
||||||
|
f"客户端拾音模式:{conn.client_listen_mode}"
|
||||||
|
)
|
||||||
if msg_json["state"] == "start":
|
if msg_json["state"] == "start":
|
||||||
conn.client_have_voice = True
|
conn.client_have_voice = True
|
||||||
conn.client_voice_stop = False
|
conn.client_voice_stop = False
|
||||||
@@ -56,11 +56,11 @@ async def handleTextMessage(conn, message):
|
|||||||
await send_tts_message(conn, "stop", None)
|
await send_tts_message(conn, "stop", None)
|
||||||
elif is_wakeup_words:
|
elif is_wakeup_words:
|
||||||
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
|
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
|
||||||
enqueue_tts_report(conn, 1, "嘿,你好呀", [])
|
enqueue_asr_report(conn, "嘿,你好呀", [])
|
||||||
await startToChat(conn, "嘿,你好呀")
|
await startToChat(conn, "嘿,你好呀")
|
||||||
else:
|
else:
|
||||||
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
|
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
|
||||||
enqueue_tts_report(conn, 1, text, [])
|
enqueue_asr_report(conn, text, [])
|
||||||
# 否则需要LLM对文字内容进行答复
|
# 否则需要LLM对文字内容进行答复
|
||||||
await startToChat(conn, text)
|
await startToChat(conn, text)
|
||||||
elif msg_json["type"] == "iot":
|
elif msg_json["type"] == "iot":
|
||||||
@@ -80,7 +80,7 @@ async def handleTextMessage(conn, message):
|
|||||||
await conn.websocket.send(
|
await conn.websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "config_update_response",
|
"type": "server",
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "服务器密钥验证失败",
|
"message": "服务器密钥验证失败",
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,55 @@ async def handleTextMessage(conn, message):
|
|||||||
return
|
return
|
||||||
# 动态更新配置
|
# 动态更新配置
|
||||||
if msg_json["action"] == "update_config":
|
if msg_json["action"] == "update_config":
|
||||||
await conn.handle_config_update(msg_json)
|
try:
|
||||||
|
# 更新WebSocketServer的配置
|
||||||
|
if not conn.server:
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "config_update_response",
|
||||||
|
"status": "error",
|
||||||
|
"message": "无法获取服务器实例",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not await conn.server.update_config():
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "config_update_response",
|
||||||
|
"status": "error",
|
||||||
|
"message": "更新服务器配置失败",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 发送成功响应
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "config_update_response",
|
||||||
|
"status": "success",
|
||||||
|
"message": "配置更新成功",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
conn.logger.bind(tag=TAG).error(f"更新配置失败: {str(e)}")
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "config_update_response",
|
||||||
|
"status": "error",
|
||||||
|
"message": f"更新配置失败: {str(e)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 重启服务器
|
||||||
|
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)
|
||||||
|
|||||||