Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e1a29145 | ||
|
|
e59df53c99 | ||
|
|
cdf0f91251 | ||
|
|
32ecbe55fc | ||
|
|
cc9d5306e6 | ||
|
|
5149a8049a | ||
|
|
cde093dd20 | ||
|
|
455b3df5ae | ||
|
|
4d14caf0d4 | ||
|
|
47b44bb124 | ||
|
|
eb743ab577 | ||
|
|
d6344bde5e | ||
|
|
7ce1672d98 | ||
|
|
4e3701e62c | ||
|
|
a035ed525a | ||
|
|
dbea7ae11e | ||
|
|
26127e9b4a | ||
|
|
7035a57cf1 | ||
|
|
aee9bdc514 | ||
|
|
9018422ecb | ||
|
|
da8d8c53c0 | ||
|
|
2c428a380e | ||
|
|
bde50e661d | ||
|
|
2beeb825cf | ||
|
|
371dab1282 | ||
|
|
3ba437c937 | ||
|
|
4b2d7da4e5 | ||
|
|
d00592020e | ||
|
|
e0b24f4e59 | ||
|
|
4b7be99837 | ||
|
|
99db948b96 | ||
|
|
2b3e205cda | ||
|
|
c9b7d77094 | ||
|
|
f3818167f3 | ||
|
|
55d6c2a193 | ||
|
|
6cfb6a8f43 | ||
|
|
396c3eb18b | ||
|
|
d8c4c1f207 | ||
|
|
268ce9fff3 | ||
|
|
bbc3b44336 | ||
|
|
fb223984d3 | ||
|
|
9f21711cae | ||
|
|
c139701468 | ||
|
|
66d3bcfa13 | ||
|
|
ff048797a1 | ||
|
|
ad6607b7f5 | ||
|
|
c992ed48da | ||
|
|
bdf9cda4c5 | ||
|
|
c85ffcc181 | ||
|
|
c53e3ed965 | ||
|
|
d374b77baa | ||
|
|
f3b2339803 | ||
|
|
27ed540113 | ||
|
|
434fba55f7 | ||
|
|
280c04e753 | ||
|
|
24832822a5 | ||
|
|
a357f0818d | ||
|
|
e4bf5d2d21 | ||
|
|
8b261d473a | ||
|
|
bba84382ec | ||
|
|
d081803434 | ||
|
|
9194ffa6b7 | ||
|
|
c06a7b1db6 | ||
|
|
1736e10189 | ||
|
|
43b79c50a0 | ||
|
|
2e6aced1bd | ||
|
|
2dc80c8432 | ||
|
|
01e7a7d3b8 | ||
|
|
e5e9f2bdea | ||
|
|
06eff425f1 | ||
|
|
d2aae057f6 | ||
|
|
b7b39de445 | ||
|
|
4aedcb8419 | ||
|
|
6c5bd395cb | ||
|
|
6ad9ae8d1b | ||
|
|
48e2369964 | ||
|
|
e753c242ad | ||
|
|
c1e0b5181e | ||
|
|
d3371c2875 | ||
|
|
2488678f34 | ||
|
|
958d50c119 | ||
|
|
19c16cfcd8 | ||
|
|
c66c92be73 | ||
|
|
4f9ea49f8e | ||
|
|
b776c545e3 | ||
|
|
d65969a2b2 | ||
|
|
d3eb8df7f0 | ||
|
|
89e299764d | ||
|
|
66fb7933b7 | ||
|
|
49e1bbbf15 | ||
|
|
af02d53718 | ||
|
|
f8b1e8199c | ||
|
|
008456d7cd | ||
|
|
aa7c07167c | ||
|
|
d451fe7a0b | ||
|
|
3b8bbb5c5f | ||
|
|
29cd69415a | ||
|
|
bc99da5b28 | ||
|
|
5b38d92cc6 | ||
|
|
0e1392de35 | ||
|
|
a033b56b8d | ||
|
|
c449d29de0 | ||
|
|
afc142b397 | ||
|
|
55348ffe65 | ||
|
|
174b9f5898 | ||
|
|
3b69629a61 | ||
|
|
56eb25f8e2 | ||
|
|
1400e48fc7 | ||
|
|
bccda3e7b2 | ||
|
|
dfb0a2b213 | ||
|
|
f94b376f31 | ||
|
|
13ce67e4c7 | ||
|
|
eece86ab71 | ||
|
|
4fd294dc80 | ||
|
|
4952ccfbf7 | ||
|
|
471ad864b6 | ||
|
|
bec32bd085 | ||
|
|
087fd5d547 | ||
|
|
99f178274d | ||
|
|
450560f92a | ||
|
|
691a0529b0 | ||
|
|
38e63eb665 | ||
|
|
ad8390301e | ||
|
|
acee7e27c6 | ||
|
|
e790b668ef | ||
|
|
432a5d01f8 | ||
|
|
374f2a92aa | ||
|
|
a22ad985dc | ||
|
|
44f41ebd11 | ||
|
|
a10e94bef6 | ||
|
|
e7cb67f846 | ||
|
|
b5a3cc1c78 | ||
|
|
1d107fb39b | ||
|
|
1ec8f639cb | ||
|
|
c15069a9b6 | ||
|
|
13ae5e3ade | ||
|
|
9cffc8b0ec | ||
|
|
c8c5b1095b | ||
|
|
9803b3c690 | ||
|
|
341c0087b7 | ||
|
|
0cd752be05 | ||
|
|
2e39b1c6f5 | ||
|
|
c4026ef9ac | ||
|
|
271a2e3e96 | ||
|
|
bc3e10f09b | ||
|
|
fed3501bf6 | ||
|
|
877cb0d645 | ||
|
|
e930935202 | ||
|
|
0af81fa366 | ||
|
|
3249970b7b | ||
|
|
1466806339 | ||
|
|
05389b1368 | ||
|
|
bef7f79f40 | ||
|
|
8d7394cd46 | ||
|
|
65053e98e2 | ||
|
|
c1bea43598 | ||
|
|
31aeb63f89 | ||
|
|
0175a7a1d4 | ||
|
|
1417ee017e | ||
|
|
5b4d79902f | ||
|
|
f10fddc59a | ||
|
|
ed07793c47 | ||
|
|
2ed2e9c391 | ||
|
|
2b9fed2609 | ||
|
|
848ce65d13 | ||
|
|
f0512ce1cc | ||
|
|
313fad1f81 | ||
|
|
ff26a84988 | ||
|
|
f7ee54598e | ||
|
|
7a62462c1b | ||
|
|
a4433ebe68 | ||
|
|
48feea55fe | ||
|
|
6e5f708f5a | ||
|
|
f2ed645f9c | ||
|
|
d47664e670 | ||
|
|
8f3229ddda | ||
|
|
4f2b7524aa | ||
|
|
4e99e176e9 | ||
|
|
cfc7047967 | ||
|
|
776b5fb3b4 | ||
|
|
f16d42d2b5 | ||
|
|
344d183b6f | ||
|
|
feb668c96c | ||
|
|
f345568675 | ||
|
|
11207f793d | ||
|
|
ce09eb48c4 | ||
|
|
6925732f72 | ||
|
|
dd9d08c8c3 | ||
|
|
1443c883f1 | ||
|
|
30676e2170 | ||
|
|
e41e6d4284 | ||
|
|
1a7b1d24fa | ||
|
|
99443d1c9f | ||
|
|
a84b8d818e | ||
|
|
ef4abf08f0 | ||
|
|
dfcac51312 | ||
|
|
c963a2de18 | ||
|
|
9ec8ecf7f4 | ||
|
|
5d77c96d76 | ||
|
|
a8932f3743 | ||
|
|
d022dd385a | ||
|
|
b2dfbb7973 | ||
|
|
7fee922796 | ||
|
|
9b6acf1ffc | ||
|
|
a0e7cb56dc | ||
|
|
f8447033b9 | ||
|
|
bb07015feb | ||
|
|
111d945a9a | ||
|
|
d1587cc645 | ||
|
|
e50e21c240 | ||
|
|
9bbcf7f31a | ||
|
|
4f29e375c3 | ||
|
|
e15c784bdb | ||
|
|
e77a3b79cd | ||
|
|
7ac5113fef | ||
|
|
ae58537979 | ||
|
|
ebf10828f0 | ||
|
|
33bad7ed40 | ||
|
|
af9ed0cd86 | ||
|
|
14a0829190 | ||
|
|
ca400d8ea7 | ||
|
|
d28750dae0 | ||
|
|
ef81a17311 | ||
|
|
8891a96731 | ||
|
|
49e69ecad4 | ||
|
|
1db2483557 | ||
|
|
7067050006 | ||
|
|
55b455216c | ||
|
|
ffba2f4fa3 | ||
|
|
9f303d840a | ||
|
|
86b4e61c1c | ||
|
|
3232b36e15 | ||
|
|
83329cf9db | ||
|
|
4608c81ea6 | ||
|
|
41679f4e1d | ||
|
|
559699ec19 | ||
|
|
f785219a43 | ||
|
|
bf2b6d7554 | ||
|
|
9a6348c71a | ||
|
|
646f4cbfe9 | ||
|
|
d4c6b6840e | ||
|
|
758aa1c78c | ||
|
|
ec2b52ccac | ||
|
|
38e1eb0c18 | ||
|
|
00e8478f61 | ||
|
|
0c556b1424 | ||
|
|
9987555a72 | ||
|
|
ebb4ba5595 | ||
|
|
7e338b28df | ||
|
|
c4b3e30c5d | ||
|
|
4d283c3504 | ||
|
|
ffc1541343 | ||
|
|
588d75763c | ||
|
|
9e218281da | ||
|
|
6fa58c7948 | ||
|
|
7fe40579b9 | ||
|
|
7e2782ce22 | ||
|
|
1fbe8dab77 | ||
|
|
89ef36711a | ||
|
|
0ba3e2c964 | ||
|
|
0aec742bd3 | ||
|
|
a6f5d868dd | ||
|
|
966d8a8a84 | ||
|
|
611d1a65e7 | ||
|
|
b23e31bb4b | ||
|
|
bec1c7b88f | ||
|
|
5d407b9227 | ||
|
|
e5bc029d87 | ||
|
|
608abb7dc3 | ||
|
|
5fcc7592c9 | ||
|
|
3fb925f13d | ||
|
|
4e958ce57c | ||
|
|
7f6ce89e39 | ||
|
|
71274cf0a3 | ||
|
|
58127cdfdc | ||
|
|
09d5d37867 | ||
|
|
6a8c4ccbf8 | ||
|
|
6d7bbfca95 | ||
|
|
3de39c8cdc | ||
|
|
387398a069 | ||
|
|
f17011939c | ||
|
|
9b6f564d30 | ||
|
|
e23989add0 | ||
|
|
a199e47701 | ||
|
|
4f64316144 | ||
|
|
68e10f42f8 | ||
|
|
3d1cae4692 | ||
|
|
31e84dadd1 | ||
|
|
c331b8322c | ||
|
|
ba4423684b | ||
|
|
62eaf46f34 | ||
|
|
3767bf8c81 | ||
|
|
300a33ddc2 | ||
|
|
34d7bd92ca | ||
|
|
44e7380c6b | ||
|
|
15ca08ce14 | ||
|
|
83b88eeaa0 | ||
|
|
290c92ee39 | ||
|
|
c2985c0db3 | ||
|
|
334dfc7917 | ||
|
|
03b51f73e7 | ||
|
|
af079d6c18 | ||
|
|
99008cf813 | ||
|
|
5fcb670821 | ||
|
|
3bdf8e20c7 | ||
|
|
e8d44b13cf | ||
|
|
c6a7871e5b | ||
|
|
4c9824b16f | ||
|
|
3e54a3272a | ||
|
|
8851406f84 | ||
|
|
fe85ea049a | ||
|
|
221c01e642 | ||
|
|
753aa280f6 | ||
|
|
64f3062eb2 | ||
|
|
f5143d60b7 | ||
|
|
e9fb36bf8f | ||
|
|
11e8736e66 | ||
|
|
30ce36b169 | ||
|
|
1541128e03 | ||
|
|
9d808ec733 | ||
|
|
8f526820a8 | ||
|
|
c0689c6916 | ||
|
|
893872e509 | ||
|
|
2c4b92f941 | ||
|
|
21cc1153b0 | ||
|
|
3c35380e9d | ||
|
|
2b65cfd369 | ||
|
|
8a44aaa7f9 | ||
|
|
4e0bf5542c | ||
|
|
f788e45bc1 | ||
|
|
5b4381c257 | ||
|
|
7396605844 | ||
|
|
b0308af05d | ||
|
|
98a1483032 | ||
|
|
07242dbfc0 | ||
|
|
a9b0775469 | ||
|
|
26a49ba34c | ||
|
|
f3431ea977 | ||
|
|
f0a7ee1c0c | ||
|
|
ad5f085c1f | ||
|
|
e7f5f5da34 | ||
|
|
d10b632875 | ||
|
|
8dd39c5a5b | ||
|
|
cdd1345c19 | ||
|
|
84067a1f57 | ||
|
|
26fd3110b4 | ||
|
|
5bd78cac1a | ||
|
|
0738c52d14 | ||
|
|
c93cb3bb52 | ||
|
|
eea7689c3d | ||
|
|
7b9e34c3e5 | ||
|
|
f81be33539 | ||
|
|
21a1cb65fc | ||
|
|
ef02a01439 | ||
|
|
0b22f4bbd2 | ||
|
|
9f8ae62ebb | ||
|
|
cec71a28eb | ||
|
|
7d060e9994 | ||
|
|
a3a6e889ee | ||
|
|
80283db9bd | ||
|
|
906a322675 | ||
|
|
bc829d9ef0 | ||
|
|
c5e6a46450 | ||
|
|
77f0bdc4ca | ||
|
|
8b9f13283b | ||
|
|
c3c65708e0 | ||
|
|
6bc64aa4aa | ||
|
|
eee907b1a2 | ||
|
|
ddd63f4136 | ||
|
|
b12c1efc90 | ||
|
|
01838f598c | ||
|
|
aef6d5dc1d | ||
|
|
9262963f20 | ||
|
|
d3e2aaacb9 | ||
|
|
755c81286c | ||
|
|
9dd2d998e2 | ||
|
|
abbb13161e | ||
|
|
23dc6f1ea9 | ||
|
|
8aed23f21a | ||
|
|
ae117e3579 | ||
|
|
0d19c693a9 | ||
|
|
283e57ff71 | ||
|
|
eed9503391 | ||
|
|
d66db60177 | ||
|
|
25d9e6c8cf | ||
|
|
a81f433228 | ||
|
|
257e365299 | ||
|
|
554afa3f46 | ||
|
|
d04ec9d510 | ||
|
|
4a802e00e4 | ||
|
|
a7007f5ae2 | ||
|
|
2b3cacd5b0 | ||
|
|
49565b4b3c | ||
|
|
bd8ae27f92 | ||
|
|
3fe0b8b669 | ||
|
|
f0e0b97217 | ||
|
|
67e07c916e | ||
|
|
b3a6ae2441 | ||
|
|
74f321a0fd | ||
|
|
64c841d00a | ||
|
|
5afa2fdc98 | ||
|
|
74bc5dbb7d | ||
|
|
5c67f2ecfd | ||
|
|
66f382039c | ||
|
|
3cfc1cf97a | ||
|
|
b88b102d38 | ||
|
|
fa3ddc2e2b | ||
|
|
f45bc5d9c9 | ||
|
|
79956ade97 | ||
|
|
b9df6d4ad2 | ||
|
|
fa79e2b379 | ||
|
|
5db638c075 | ||
|
|
0f0a0814b7 | ||
|
|
a842f0688f | ||
|
|
c3c2e7c28e | ||
|
|
0364d1199b | ||
|
|
c428a9ddee | ||
|
|
dba571727a | ||
|
|
d31761e78a | ||
|
|
63dfcde4c8 | ||
|
|
32e6ecf00d | ||
|
|
2481509dc5 | ||
|
|
44dd7fc58c | ||
|
|
87f50e5253 | ||
|
|
bffed8bb8a | ||
|
|
ff3c0ab7ca | ||
|
|
6d0addf005 | ||
|
|
1520874432 | ||
|
|
eaade698fb | ||
|
|
b4f4995ff9 | ||
|
|
76a7ac04c9 | ||
|
|
3e1765a26d | ||
|
|
95b80f6bb1 | ||
|
|
caf5f3f04c | ||
|
|
41b8fac3aa | ||
|
|
f6f240bc4b | ||
|
|
a79d8d61e3 | ||
|
|
a5e748f775 | ||
|
|
0fc4edc431 | ||
|
|
c82cf670de | ||
|
|
a3827aed6e | ||
|
|
cc7ba0872c | ||
|
|
3378d2cd8f | ||
|
|
e004a85f53 | ||
|
|
91cd843cfe | ||
|
|
880b669877 | ||
|
|
e53ba28c65 | ||
|
|
bdb6d3c093 | ||
|
|
9af77d0f47 | ||
|
|
15291bfcf2 | ||
|
|
ae228f3b86 | ||
|
|
b8803f92e7 | ||
|
|
1515784700 | ||
|
|
06e2909fda | ||
|
|
9012deeae7 | ||
|
|
31657e1dc0 | ||
|
|
5a08a41cc8 | ||
|
|
3e48d7e331 | ||
|
|
5993470d3f | ||
|
|
18acec1a81 | ||
|
|
701936326d | ||
|
|
5b7d613a35 | ||
|
|
0ce55ff7a4 | ||
|
|
595edb6f80 | ||
|
|
d4b75c5d99 | ||
|
|
1988bced60 | ||
|
|
60f8dbf418 | ||
|
|
a708382cfd | ||
|
|
1d95e1420a | ||
|
|
e1bcff82e2 | ||
|
|
216f1b701f | ||
|
|
0ea18d87a3 | ||
|
|
6a79d57a93 | ||
|
|
31f596f96c | ||
|
|
7f99895116 | ||
|
|
789d756ce1 | ||
|
|
570fb29d3c | ||
|
|
d7dc636d3f | ||
|
|
67d3a8f94f | ||
|
|
7d7b8ddfb1 | ||
|
|
74826c1c59 | ||
|
|
0676b87832 | ||
|
|
d138e1dcef | ||
|
|
372dc8ecb0 | ||
|
|
e8bfe9850e | ||
|
|
ad50fbe3c9 | ||
|
|
61636dd085 | ||
|
|
5ea669be5a | ||
|
|
5260b62287 | ||
|
|
984a84edc1 | ||
|
|
32548878e4 | ||
|
|
093db56c98 | ||
|
|
a17f8518d6 | ||
|
|
e9e9c5355d | ||
|
|
61ee21d627 | ||
|
|
a86707f1ff | ||
|
|
be42d80061 | ||
|
|
16da6f9696 | ||
|
|
427b0aa197 | ||
|
|
a2432d45cc | ||
|
|
f62b5d4f22 | ||
|
|
001225c27b | ||
|
|
3e68f188fa | ||
|
|
2d75eb76f1 | ||
|
|
bcc3ebb6bb | ||
|
|
52c4e65686 | ||
|
|
44a5afb8a2 | ||
|
|
3057d35111 | ||
|
|
bdf99bc554 | ||
|
|
e70415690c | ||
|
|
9b5e4eb9b7 | ||
|
|
e2324d3c37 | ||
|
|
c395c31e46 | ||
|
|
94662e17ba | ||
|
|
0bfa5cf723 | ||
|
|
d2f29f335e | ||
|
|
4423cb85c2 | ||
|
|
46fb997b52 | ||
|
|
dcf8c134a2 | ||
|
|
7c0df908e9 | ||
|
|
7bb76d7ced | ||
|
|
1cfb8de3a0 | ||
|
|
768d2b82d6 | ||
|
|
7a4431d80c | ||
|
|
7e63e1d607 | ||
|
|
bda6c1e3ed | ||
|
|
a9d116df2f | ||
|
|
e7c6c32d40 | ||
|
|
cdfe24fcdb | ||
|
|
a9199171df | ||
|
|
76b9542507 | ||
|
|
d30b6a8dcf | ||
|
|
76b5ad1c99 | ||
|
|
7840e83ada | ||
|
|
c4ac9da3e5 | ||
|
|
311e5d5cfd | ||
|
|
a1f6795d44 | ||
|
|
f8a30ce261 | ||
|
|
ccfd15bda0 | ||
|
|
a9610e6ee7 | ||
|
|
733b36b869 | ||
|
|
70c3f9f929 | ||
|
|
210bda15b8 | ||
|
|
93be1b3443 | ||
|
|
533a001aeb | ||
|
|
0b57d78732 | ||
|
|
dce9d1a1e2 | ||
|
|
1cff39cec4 | ||
|
|
c7392dcf5a | ||
|
|
3eb39ebef2 | ||
|
|
041d8d5643 | ||
|
|
1c19046442 | ||
|
|
3d696aaa64 | ||
|
|
76516336af | ||
|
|
fc18677409 | ||
|
|
029d3730a8 | ||
|
|
8509e62114 | ||
|
|
6e8f9ca22e | ||
|
|
dbc1a515d2 | ||
|
|
70221e7f62 | ||
|
|
28f921c38d | ||
|
|
30a773601f | ||
|
|
6cddd5c704 | ||
|
|
709bc12b1c | ||
|
|
171892a894 | ||
|
|
535ab315d9 | ||
|
|
de28247b7e | ||
|
|
6186eb753a | ||
|
|
a18bc85a9b | ||
|
|
806e4c398d | ||
|
|
a4f1abfa42 | ||
|
|
f2efd9718e | ||
|
|
031c56e30b | ||
|
|
994715d9eb | ||
|
|
90f7af7afa | ||
|
|
2f3c466833 | ||
|
|
d742bca205 | ||
|
|
8e51d52155 | ||
|
|
a1c4f72470 | ||
|
|
2c11bba25e | ||
|
|
42a5b4fae7 | ||
|
|
a61aedc906 | ||
|
|
7b72d517ab | ||
|
|
1c43295fbc | ||
|
|
747c432ea3 | ||
|
|
235bdba42a | ||
|
|
b3f603732e | ||
|
|
6091eae7fc | ||
|
|
ef36ad1ec1 | ||
|
|
f832611d41 | ||
|
|
4d50eeb830 | ||
|
|
abf1fa0ef4 | ||
|
|
903c0e51ee | ||
|
|
ce6a711c8e | ||
|
|
095fe72db0 | ||
|
|
1b6cd01eaf | ||
|
|
ac1e19f621 | ||
|
|
faf2890695 | ||
|
|
75f0773247 | ||
|
|
8b81b918cc | ||
|
|
5fe91a80fa | ||
|
|
6f3dee74be | ||
|
|
9391e8cc1a | ||
|
|
8aa5537029 | ||
|
|
f40c3ec0f6 | ||
|
|
408143e55e | ||
|
|
71eef4693d | ||
|
|
0b7814882d | ||
|
|
99cf26ee9e | ||
|
|
775f754ff3 | ||
|
|
b6ee2dee68 | ||
|
|
ef25e82544 | ||
|
|
14b7631dd6 | ||
|
|
ef0099b3c9 | ||
|
|
38d60affce | ||
|
|
c8a2c9bbd4 | ||
|
|
9c2084b62e | ||
|
|
9edd083411 | ||
|
|
80e8ecc4f4 | ||
|
|
e5d3048fb2 | ||
|
|
67c4622ca7 | ||
|
|
121f1c4698 | ||
|
|
ff9fb9eb1b | ||
|
|
b07a8796ff | ||
|
|
8ad5ff457e | ||
|
|
99f6209c57 | ||
|
|
1b3a55b105 | ||
|
|
8b59a94324 | ||
|
|
b17f20eece | ||
|
|
d9062a0bb0 | ||
|
|
04746961f2 | ||
|
|
5f96f56574 | ||
|
|
076aaae944 | ||
|
|
aed54bb3a6 | ||
|
|
7c03677295 | ||
|
|
3d3875f14f | ||
|
|
f6dd493997 | ||
|
|
d5e3989ed1 | ||
|
|
b7c0201ca3 |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: pip
|
||||
directory: /main/xiaozhi-server
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Build Base Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'main/xiaozhi-server/requirements.txt'
|
||||
- 'Dockerfile-server-base'
|
||||
- '.github/workflows/build-base-image.yml'
|
||||
|
||||
jobs:
|
||||
build-base:
|
||||
name: Build and push server base image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Build and push server-base
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile-server-base
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository }}:server-base
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=server-base
|
||||
cache-to: type=gha,mode=max,scope=server-base
|
||||
build-args: |
|
||||
BUILDKIT_PROGRESS=plain
|
||||
|
||||
- name: Output image info
|
||||
run: |
|
||||
echo "✅ Base image built and pushed successfully!"
|
||||
echo "📦 Tag: ghcr.io/${{ github.repository }}:server-base"
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
tags:
|
||||
- 'v*.*.*' # 只在以 v 开头的标签推送时触发,例如 v1.0.0
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Build Base Image"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -31,6 +35,9 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -59,7 +66,11 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:server_{1},ghcr.io/{0}:server_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:server_latest', github.repository) }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_PROGRESS=plain
|
||||
|
||||
# 构建 manager-api 镜像
|
||||
- name: Build and push manager-web
|
||||
@@ -70,4 +81,8 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:web_{1},ghcr.io/{0}:web_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:web_latest', github.repository) }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_PROGRESS=plain
|
||||
|
||||
@@ -175,3 +175,11 @@ uploadfile
|
||||
*.json
|
||||
.vscode
|
||||
.cursor
|
||||
|
||||
!package.json
|
||||
!**/package.json
|
||||
|
||||
# Do not ignore env and json files inside manager-mobile
|
||||
!main/manager-mobile/**/env/
|
||||
!main/manager-mobile/**/.env*
|
||||
!main/manager-mobile/**/*.json
|
||||
@@ -1,29 +1,6 @@
|
||||
# 第一阶段:构建Python依赖
|
||||
FROM python:3.10-slim AS builder
|
||||
# 生产镜像,仅包含应用代码
|
||||
FROM ghcr.io/xinnan-tech/xiaozhi-esp32-server:server-base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY main/xiaozhi-server/requirements.txt .
|
||||
|
||||
# 安装Python依赖
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 第二阶段:生产镜像
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /opt/xiaozhi-esp32-server
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libopus0 ffmpeg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从构建阶段复制Python包和前端构建产物
|
||||
COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
|
||||
COPY --from=builder /usr/local/bin/mcp-proxy /usr/local/bin/mcp-proxy
|
||||
|
||||
# 复制应用代码
|
||||
COPY main/xiaozhi-server .
|
||||
|
||||
# 启动应用
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Dockerfile-server-base
|
||||
# 基础镜像,包含系统依赖和Python包
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libopus0 ffmpeg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 配置pip使用国内镜像源(阿里云)并设置超时和重试
|
||||
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||
pip config set global.trusted-host mirrors.aliyun.com && \
|
||||
pip config set global.timeout 120 && \
|
||||
pip config set install.retries 5
|
||||
|
||||
WORKDIR /opt/xiaozhi-esp32-server
|
||||
|
||||
# 复制requirements.txt
|
||||
COPY main/xiaozhi-server/requirements.txt .
|
||||
|
||||
# 安装Python依赖
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements.txt --default-timeout=120 --retries 5
|
||||
@@ -18,12 +18,18 @@ FROM bellsoft/liberica-runtime-container:jre-21-glibc
|
||||
|
||||
# 安装Nginx和字体库
|
||||
RUN apk update && \
|
||||
apk add --no-cache nginx bash && \
|
||||
apk add --no-cache fontconfig ttf-dejavu msttcorefonts-installer && \
|
||||
rm -rf /var/cache/apk/*
|
||||
apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \
|
||||
nginx \
|
||||
bash \
|
||||
fontconfig \
|
||||
ttf-dejavu \
|
||||
msttcorefonts-installer \
|
||||
&& ACCEPT_EULA=Y apk add --no-cache msttcorefonts-installer \
|
||||
&& fc-cache -f -v \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# 更新字体缓存
|
||||
RUN printf 'YES\n' | update-ms-fonts && fc-cache -f -v
|
||||
RUN (printf 'YES\n' | update-ms-fonts || true) && fc-cache -f -v
|
||||
|
||||
# 配置Nginx
|
||||
COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
本项目基于人机共生智能理论和技术研发智能终端软硬件体系<br/>为开源智能硬件项目
|
||||
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/>
|
||||
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python、Java、Vue实现<br/>
|
||||
支持MCP接入点和声纹识别
|
||||
支持MQTT+UDP协议、Websocket协议、MCP接入点、声纹识别
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -38,9 +38,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
By Professor Siyuan Liu Research and Development Group ( South China University of Technology)
|
||||
Spearheaded by Professor Siyuan Liu's Team (South China University of Technology)
|
||||
</br>
|
||||
刘思源教授团队研发(华南理工大学)
|
||||
刘思源教授团队主导研发(华南理工大学)
|
||||
</br>
|
||||
<img src="./docs/images/hnlg.jpg" alt="华南理工大学" width="50%">
|
||||
</p>
|
||||
@@ -59,24 +59,40 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="小智esp32连接自己的后台模型" src="docs/images/demo1.png" />
|
||||
<img alt="响应速度感受" src="docs/images/demo9.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" />
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" />
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
@@ -85,18 +101,16 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<picture>
|
||||
<img alt="成本最低配置" src="docs/images/demo4.png" />
|
||||
<img alt="MCP接入点" src="docs/images/demo13.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="自定义音色" src="docs/images/demo6.png" />
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
@@ -114,20 +128,6 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV178XuYfEpi" target="_blank">
|
||||
<picture>
|
||||
<img alt="IOT指令控制设备" src="docs/images/demo9.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV17LXWYvENb" target="_blank">
|
||||
<picture>
|
||||
<img alt="播报新闻" src="docs/images/demo0.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
@@ -145,23 +145,23 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" />
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="MCP接入点" src="docs/images/demo13.png" />
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV17LXWYvENb" target="_blank">
|
||||
<picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" />
|
||||
<img alt="播报新闻" src="docs/images/demo0.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
@@ -191,11 +191,13 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
||||
| **最简化安装** | 智能对话、IOT、MCP、视觉感知 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./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)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
|
||||
| **全模块安装** | 智能对话、IOT、MCP接入点、声纹识别、视觉感知、OTA、智控台 | 完整功能体验,数据存储在数据库 |[①Docker版](./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) / [②源码部署](./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) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
|
||||
|
||||
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
|
||||
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据,
|
||||
|
||||
```
|
||||
智控台地址: https://2662r3426b.vicp.fun
|
||||
智控台(h5版): https://2662r3426b.vicp.fun/h5/index.html
|
||||
|
||||
服务测试工具: https://2662r3426b.vicp.fun/test/
|
||||
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||
@@ -214,21 +216,22 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 模块名称 | 入门全免费设置 | 流式配置 |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR(语音识别) | FunASR(本地) | 👍FunASRServer 或 👍DoubaoStreamASR |
|
||||
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍DoubaoLLM(火山doubao-1-5-pro-32k-250115) |
|
||||
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
|
||||
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) |
|
||||
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) |
|
||||
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) |
|
||||
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
||||
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||
|
||||
如果您关心各组件的耗时,请查阅[小智各组件性能测试报告](https://github.com/xinnan-tech/xiaozhi-performance-research),可按报告中的测试方法在您的环境中实际测试。
|
||||
|
||||
#### 🔧 测试工具
|
||||
本项目提供以下测试工具,帮助您验证系统和选择合适的模型:
|
||||
|
||||
| 工具名称 | 位置 | 使用方法 | 功能说明 |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| 音频交互测试工具 | main》xiaozhi-server》test》test_page.html | 使用谷歌浏览器直接打开 | 测试音频播放和接收功能,验证Python端音频处理是否正常 |
|
||||
| 模型响应测试工具1 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、TTS(语音合成)三个核心模块的响应速度 |
|
||||
| 模型响应测试工具2 | main》xiaozhi-server》performance_tester_vllm.py | 执行 `python performance_tester_vllm.py` | 测试VLLM(视觉模型)的响应速度 |
|
||||
| 模型响应测试工具 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、VLLM(视觉模型)、TTS(语音合成)三个核心模块的响应速度 |
|
||||
|
||||
> 💡 提示:测试模型速度时,只会测试配置了密钥的模型。
|
||||
|
||||
@@ -238,7 +241,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||

|
||||
| 功能模块 | 描述 |
|
||||
|:---:|:---|
|
||||
| 核心架构 | 基于WebSocket和HTTP服务器,提供完整的控制台管理和认证系统 |
|
||||
| 核心架构 | 基于[MQTT+UDP网关](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md)、WebSocket、HTTP服务器,提供完整的控制台管理和认证系统 |
|
||||
| 语音交互 | 支持流式ASR(语音识别)、流式TTS(语音合成)、VAD(语音活动检测),支持多语言识别和语音处理 |
|
||||
| 声纹识别 | 支持多用户声纹注册、管理和识别,与ASR并行处理,实时识别说话人身份并传递给LLM进行个性化回应 |
|
||||
| 智能对话 | 支持多种LLM(大语言模型),实现智能对话 |
|
||||
@@ -246,27 +249,22 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| 意图识别 | 支持LLM意图识别、Function Call函数调用,提供插件化意图处理机制 |
|
||||
| 记忆系统 | 支持本地短期记忆、mem0ai接口记忆,具备记忆总结功能 |
|
||||
| 工具调用 | 支持客户端IOT协议、客户MCP协议、服务端MCP协议、MCP接入点协议、自定义工具函数 |
|
||||
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理 |
|
||||
| 指令下发 | 依托MQTT协议,支持从智控台将MCP指令下发到ESP32设备 |
|
||||
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理;界面支持中文简体、中文繁体、英文显示 |
|
||||
| 测试工具 | 提供性能测试工具、视觉模型测试工具和音频交互测试工具 |
|
||||
| 部署支持 | 支持Docker部署和本地部署,提供完整的配置文件管理 |
|
||||
| 插件系统 | 支持功能插件扩展、自定义插件开发和插件热加载 |
|
||||
|
||||
### 正在开发 🚧
|
||||
|
||||
想了解具体开发计划进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
|
||||
想了解具体开发计划进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)。常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
|
||||
|
||||
如果你是一名软件开发者,这里有一份[《致开发者的公开信》](docs/contributor_open_letter.md),欢迎加入!
|
||||
|
||||
---
|
||||
|
||||
## 产品生态 👬
|
||||
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的优秀项目
|
||||
|
||||
| 项目名称 | 项目地址 | 项目描述 |
|
||||
|:---------------------|:--------|:--------|
|
||||
| 小智安卓客户端 | [xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) | 一个基于xiaozhi-server的Android、IOS语音对话应用,支持实时语音交互和文字对话。<br/>现在是flutter版本,打通IOS、Android端。 |
|
||||
| 小智电脑客户端 | [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) | 该项目提供了一个基于 Python 实现的小白 AI 客户端,使得在不具备实体硬件条件的情况下,<br/>依然能够体过代码体验小智 AI 的功能。 |
|
||||
| 小智Java服务端 | [xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) | 小智开源后端服务 Java 版本是一个基于 Java 的开源项目。<br/>它包括前后端的服务,旨在为用户提供一个完整的后端服务解决方案。 |
|
||||
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的[优秀项目](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE)
|
||||
|
||||
---
|
||||
|
||||
@@ -280,6 +278,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| dify 接口调用 | Dify | - |
|
||||
| fastgpt 接口调用 | Fastgpt | - |
|
||||
| coze 接口调用 | Coze | - |
|
||||
| xinference 接口调用 | Xinference | - |
|
||||
| homeassistant 接口调用 | HomeAssistant | - |
|
||||
|
||||
实际上,任何支持 openai 接口调用的 LLM 均可接入使用。
|
||||
|
||||
@@ -299,8 +299,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 接口调用 | EdgeTTS、火山引擎豆包TTS、腾讯云、阿里云TTS、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS、灵犀流式TTS | 灵犀流式TTS、EdgeTTS、CosyVoiceSiliconflow(部分) |
|
||||
| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS |
|
||||
| 接口调用 | EdgeTTS、火山引擎豆包TTS、腾讯云、阿里云TTS、阿里云流式TTS、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS、灵犀流式TTS、MinimaxTTS、火山双流式TTS | 灵犀流式TTS、EdgeTTS、CosyVoiceSiliconflow(部分) |
|
||||
| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、Index-TTS、PaddleSpeech | Index-TTS、PaddleSpeech、FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -317,7 +317,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR |
|
||||
| 接口调用 | DoubaoASR、FunASRServer、TencentASR、AliyunASR | FunASRServer |
|
||||
| 接口调用 | DoubaoASR、Doubao流式ASR、FunASRServer、TencentASR、AliyunASR、Aliyun流式ASR、百度ASR、OpenAI ASR | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
@@ -335,6 +335,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|:------:|:---------------:|:----:|:---------:|:--:|
|
||||
| Memory | mem0ai | 接口调用 | 1000次/月额度 | |
|
||||
| Memory | mem_local_short | 本地总结 | 免费 | |
|
||||
| Memory | nomem | 无记忆模式 | 免费 | |
|
||||
|
||||
---
|
||||
|
||||
@@ -344,6 +345,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|:------:|:-------------:|:----:|:-------:|:---------------------:|
|
||||
| Intent | intent_llm | 接口调用 | 根据LLM收费 | 通过大模型识别意图,通用性强 |
|
||||
| Intent | function_call | 接口调用 | 根据LLM收费 | 通过大模型函数调用完成意图,速度快,效果好 |
|
||||
| Intent | nointent | 无意图模式 | 免费 | 不进行意图识别,直接返回对话结果 |
|
||||
|
||||
---
|
||||
|
||||
@@ -354,8 +356,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| <img src="./docs/images/logo_bailing.png" width="160"> | [百聆语音对话机器人](https://github.com/wwbin2017/bailing) | 本项目受[百聆语音对话机器人](https://github.com/wwbin2017/bailing)启发,并在其基础上实现 |
|
||||
| <img src="./docs/images/logo_tenclass.png" width="160"> | [十方融海](https://www.tenclass.com/) | 感谢[十方融海](https://www.tenclass.com/)为小智生态制定了标准的通讯协议、多设备兼容性方案及高并发场景实践示范;为本项目提供了全链路技术文档支持 |
|
||||
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [玄凤科技](https://github.com/Eric0308) | 感谢[玄凤科技](https://github.com/Eric0308)贡献函数调用框架、MCP通信协议及插件化调用机制的实现代码,通过标准化的指令调度体系与动态扩展能力,显著提升了前端设备(IoT)的交互效率和功能延展性 |
|
||||
| <img src="./docs/images/logo_junsen.png" width="160"> | [huangjunsen](https://github.com/huangjunsen0406) | 感谢[huangjunsen](https://github.com/huangjunsen0406) 贡献`智控台移动端`模块,实现了跨平台移动设备的高效控制与实时交互,大幅提升了系统在移动场景下的操作便捷性和管理效率 |
|
||||
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [汇远设计](http://ui.kwd988.net/) | 感谢[汇远设计](http://ui.kwd988.net/)为本项目提供专业视觉解决方案,用其服务超千家企业的设计实战经验,赋能本项目产品用户体验 |
|
||||
| <img src="./docs/images/logo_qinren.png" width="160"> | [西安勤人信息科技](https://www.029app.com/) | 感谢[西安勤人信息科技](https://www.029app.com/)深化本项目视觉体系,确保整体设计风格在多场景应用中的一致性和扩展性 |
|
||||
| <img src="./docs/images/logo_contributors.png" width="160"> | [代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | 感谢[所有代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors)贡献者,你们的付出让项目更加健壮和强大。 |
|
||||
|
||||
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
@@ -38,9 +38,9 @@ Supports MCP endpoints and voiceprint recognition
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
By Professor Siyuan Liu Research and Development Group (South China University of Technology)
|
||||
Spearheaded by Professor Siyuan Liu's Team (South China University of Technology)
|
||||
</br>
|
||||
刘思源教授团队研发(华南理工大学)
|
||||
刘思源教授团队主导研发(华南理工大学)
|
||||
</br>
|
||||
<img src="./docs/images/hnlg.jpg" alt="South China University of Technology" width="50%">
|
||||
</p>
|
||||
@@ -58,72 +58,72 @@ Want to see the usage effects? Click the videos below 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="Xiaozhi ESP32 connecting to own backend model" src="docs/images/demo1.png" />
|
||||
<img alt="响应速度感受" src="docs/images/demo9.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="Custom voice timbre" src="docs/images/demo2.png" />
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="Using Cantonese for communication" src="docs/images/demo3.png" />
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="Controlling home appliances" src="docs/images/demo5.png" />
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="Lowest cost configuration" src="docs/images/demo4.png" />
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<picture>
|
||||
<img alt="MCP接入点" src="docs/images/demo13.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Playing music" src="docs/images/demo7.png" />
|
||||
<img alt="播放音乐" 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 command control devices" src="docs/images/demo9.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV17LXWYvENb" target="_blank">
|
||||
<picture>
|
||||
<img alt="News broadcasting" src="docs/images/demo0.png" />
|
||||
<img alt="天气插件" src="docs/images/demo8.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
@@ -132,35 +132,35 @@ Want to see the usage effects? Click the videos below 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Real-time interruption" src="docs/images/demo10.png" />
|
||||
<img alt="实时打断" src="docs/images/demo10.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="Photo recognition of objects" src="docs/images/demo12.png" />
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Multi-command tasks" src="docs/images/demo11.png" />
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="MCP endpoint" src="docs/images/demo13.png" />
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<a href="https://www.bilibili.com/video/BV17LXWYvENb" target="_blank">
|
||||
<picture>
|
||||
<img alt="Voiceprint recognition" src="docs/images/demo14.png" />
|
||||
<img alt="播报新闻" src="docs/images/demo0.png" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
@@ -194,6 +194,7 @@ This project provides two deployment methods. Please choose based on your specif
|
||||
|
||||
```
|
||||
Intelligent Control Console Address: https://2662r3426b.vicp.fun
|
||||
Intelligent Control Console Address (H5): https://2662r3426b.vicp.fun/h5/index.html
|
||||
|
||||
Service Test Tool: https://2662r3426b.vicp.fun/test/
|
||||
OTA Interface Address: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||
@@ -236,15 +237,16 @@ This project provides the following testing tools to help you verify the system
|
||||

|
||||
| Feature Module | Description |
|
||||
|:---:|:---|
|
||||
| Core Architecture | Based on WebSocket and HTTP servers, provides complete console management and authentication system |
|
||||
| Core Architecture | Based on [MQTT+UDP gateway](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md), WebSocket and HTTP servers, provides complete console management and authentication system |
|
||||
| Voice Interaction | Supports streaming ASR(speech recognition), streaming TTS(speech synthesis), VAD(voice activity detection), supports multi-language recognition and voice processing |
|
||||
| Voiceprint Recognition | Supports multi-user voiceprint registration, management, and recognition, processes in parallel with ASR, real-time speaker identity recognition and passes to LLM for personalized responses |
|
||||
| Intelligent Dialogue | Supports multiple LLM(large language models), implements intelligent dialogue |
|
||||
| Visual Perception | Supports multiple VLLM(vision large models), implements multimodal interaction |
|
||||
| Intent Recognition | Supports LLM intent recognition, Function Call function calling, provides plugin-based intent processing mechanism |
|
||||
| Memory System | Supports local short-term memory, mem0ai interface memory, with memory summarization functionality |
|
||||
| Command Delivery | Supports MCP command delivery to ESP32 devices via MQTT protocol from Smart Console |
|
||||
| Tool Calling | Supports client IOT protocol, client MCP protocol, server MCP protocol, MCP endpoint protocol, custom tool functions |
|
||||
| Management Backend | Provides Web management interface, supports user management, system configuration, and device management |
|
||||
| Management Backend | Provides Web management interface, supports user management, system configuration and device management; Supports Simplified Chinese, Traditional Chinese and English display |
|
||||
| Testing Tools | Provides performance testing tools, vision model testing tools, and audio interaction testing tools |
|
||||
| Deployment Support | Supports Docker deployment and local deployment, provides complete configuration file management |
|
||||
| Plugin System | Supports functional plugin extensions, custom plugin development, and plugin hot-loading |
|
||||
@@ -258,7 +260,7 @@ If you are a software developer, here is an [Open Letter to Developers](docs/con
|
||||
---
|
||||
|
||||
## Product Ecosystem 👬
|
||||
Xiaozhi is an ecosystem. When using this product, you can also check out other excellent projects in this ecosystem
|
||||
Xiaozhi is an ecosystem. When using this product, you can also check out other [excellent projects](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) in this ecosystem
|
||||
|
||||
| Project Name | Project Address | Project Description |
|
||||
|:---------------------|:--------|:--------|
|
||||
@@ -279,7 +281,7 @@ Xiaozhi is an ecosystem. When using this product, you can also check out other e
|
||||
| FastGPT interface calls | FastGPT | - |
|
||||
| Coze interface calls | Coze | - |
|
||||
|
||||
In fact, any LLM that supports OpenAI interface calls can be integrated and used.
|
||||
In fact, any LLM that supports OpenAI interface calls can be integrated and used, including Xinference and HomeAssistant interfaces.
|
||||
|
||||
---
|
||||
|
||||
@@ -297,7 +299,7 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| Interface calls | EdgeTTS, Volcano Engine Doubao TTS, Tencent Cloud, Alibaba Cloud TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi Streaming TTS | Lingxi Streaming TTS, EdgeTTS, CosyVoiceSiliconflow(partial) |
|
||||
| Interface calls | EdgeTTS, Volcano Engine Doubao TTS, Tencent Cloud, Alibaba Cloud TTS, AliYun Stream TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi Streaming TTS, MinimaxTTS | Lingxi Streaming TTS, EdgeTTS, CosyVoiceSiliconflow(partial) |
|
||||
| Local services | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS |
|
||||
|
||||
---
|
||||
@@ -352,8 +354,10 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
||||
| <img src="./docs/images/logo_bailing.png" width="160"> | [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) | This project is inspired by [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) and implemented on its basis |
|
||||
| <img src="./docs/images/logo_tenclass.png" width="160"> | [Tenclass](https://www.tenclass.com/) | Thanks to [Tenclass](https://www.tenclass.com/) for formulating standard communication protocols, multi-device compatibility solutions, and high-concurrency scenario practice demonstrations for the Xiaozhi ecosystem; providing full-link 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 calling framework, MCP communication protocol, and plugin-based calling mechanism implementation code. Through standardized instruction scheduling system and dynamic expansion capabilities, it significantly improves the interaction efficiency and functional extensibility of frontend devices (IoT) |
|
||||
| <img src="./docs/images/logo_junsen.png" width="160"> | [huangjunsen](https://github.com/huangjunsen0406) | Thanks to [huangjunsen](https://github.com/huangjunsen0406) for contributing the `Smart Control Console Mobile` module, which enables efficient control and real-time interaction across mobile devices, significantly enhancing the system's operational convenience and management efficiency in mobile scenarios. |
|
||||
| <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, using their design practical experience serving over a thousand enterprises to empower this project's product user experience |
|
||||
| <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 |
|
||||
| <img src="./docs/images/logo_contributors.png" width="160"> | [Code Contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Thanks to [all code contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors), your efforts have made the project more robust and powerful. |
|
||||
|
||||
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
@@ -1,105 +1,413 @@
|
||||
#!/bin/sh
|
||||
# 脚本作者@VanillaNahida
|
||||
# 本文件是用于一键自动下载本项目所需文件,自动创建好目录
|
||||
# 所需条件(否则无法使用):
|
||||
# 1、请确保你的环境可以正常访问 GitHub 否则无法下载脚本
|
||||
#
|
||||
# 检测操作系统类型
|
||||
case "$(uname -s)" in
|
||||
Linux*) OS=Linux;;
|
||||
Darwin*) OS=Mac;;
|
||||
CYGWIN*) OS=Windows;;
|
||||
MINGW*) OS=Windows;;
|
||||
MSYS*) OS=Windows;;
|
||||
*) OS=UNKNOWN;;
|
||||
# 暂且只支持X86版本的Ubuntu系统,其他系统未测试
|
||||
|
||||
# 定义中断处理函数
|
||||
handle_interrupt() {
|
||||
echo ""
|
||||
echo "安装已被用户中断(Ctrl+C或Esc)"
|
||||
echo "如需重新安装,请再次运行脚本"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置信号捕获,处理Ctrl+C
|
||||
trap handle_interrupt SIGINT
|
||||
|
||||
# 处理Esc键
|
||||
# 保存终端设置
|
||||
old_stty_settings=$(stty -g)
|
||||
# 设置终端立即响应,不回显
|
||||
stty -icanon -echo min 1 time 0
|
||||
|
||||
# 后台进程检测Esc键
|
||||
(while true; do
|
||||
read -r key
|
||||
if [[ $key == $'\e' ]]; then
|
||||
# 检测到Esc键,触发中断处理
|
||||
kill -SIGINT $$
|
||||
break
|
||||
fi
|
||||
done) &
|
||||
|
||||
# 脚本结束时恢复终端设置
|
||||
trap 'stty "$old_stty_settings"' EXIT
|
||||
|
||||
|
||||
# 打印彩色字符画
|
||||
echo -e "\e[1;32m" # 设置颜色为亮绿色
|
||||
cat << "EOF"
|
||||
脚本作者:@Bilibili 香草味的纳西妲喵
|
||||
__ __ _ _ _ _ _ _ _ _
|
||||
\ \ / / (_)| || | | \ | | | | (_) | |
|
||||
\ \ / /__ _ _ __ _ | || | __ _ | \| | __ _ | |__ _ __| | __ _
|
||||
\ \/ // _` || '_ \ | || || | / _` | | . ` | / _` || '_ \ | | / _` | / _` |
|
||||
\ /| (_| || | | || || || || (_| | | |\ || (_| || | | || || (_| || (_| |
|
||||
\/ \__,_||_| |_||_||_||_| \__,_| |_| \_| \__,_||_| |_||_| \__,_| \__,_|
|
||||
EOF
|
||||
echo -e "\e[0m" # 重置颜色
|
||||
echo -e "\e[1;36m 小智服务端全量部署一键安装脚本 Ver 0.2 2025年8月20日更新 \e[0m\n"
|
||||
sleep 1
|
||||
|
||||
|
||||
|
||||
# 检查并安装whiptail
|
||||
check_whiptail() {
|
||||
if ! command -v whiptail &> /dev/null; then
|
||||
echo "正在安装whiptail..."
|
||||
apt update
|
||||
apt install -y whiptail
|
||||
fi
|
||||
}
|
||||
|
||||
check_whiptail
|
||||
|
||||
# 创建确认对话框
|
||||
whiptail --title "安装确认" --yesno "即将安装小智服务端,是否继续?" \
|
||||
--yes-button "继续" --no-button "退出" 10 50
|
||||
|
||||
# 根据用户选择执行操作
|
||||
case $? in
|
||||
0)
|
||||
;;
|
||||
1)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# 设置颜色(Windows CMD 不支持,但不影响使用)
|
||||
if [ "$OS" = "Windows" ]; then
|
||||
GREEN=""
|
||||
RED=""
|
||||
NC=""
|
||||
else
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
fi
|
||||
|
||||
echo "${GREEN}开始安装小智服务端...${NC}"
|
||||
|
||||
# 创建必要的目录
|
||||
echo "创建目录结构..."
|
||||
mkdir -p xiaozhi-server/data xiaozhi-server/models/SenseVoiceSmall
|
||||
cd xiaozhi-server || exit
|
||||
|
||||
# 根据操作系统选择下载命令
|
||||
if [ "$OS" = "Windows" ]; then
|
||||
DOWNLOAD_CMD="curl -L -o"
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
DOWNLOAD_CMD="powershell -Command Invoke-WebRequest -Uri"
|
||||
DOWNLOAD_CMD_SUFFIX="-OutFile"
|
||||
fi
|
||||
else
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
DOWNLOAD_CMD="curl -L -o"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
DOWNLOAD_CMD="wget -O"
|
||||
else
|
||||
echo "${RED}错误: 需要安装 curl 或 wget${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 下载语音识别模型
|
||||
echo "下载语音识别模型..."
|
||||
if [ "$DOWNLOAD_CMD" = "powershell -Command Invoke-WebRequest -Uri" ]; then
|
||||
$DOWNLOAD_CMD "https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt" $DOWNLOAD_CMD_SUFFIX "models/SenseVoiceSmall/model.pt"
|
||||
else
|
||||
$DOWNLOAD_CMD "models/SenseVoiceSmall/model.pt" "https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt"
|
||||
fi
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "${RED}模型下载失败。请手动从以下地址下载:${NC}"
|
||||
echo "1. https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt"
|
||||
echo "2. 百度网盘: https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg (提取码: qvna)"
|
||||
echo "下载后请将文件放置在 models/SenseVoiceSmall/model.pt"
|
||||
fi
|
||||
|
||||
# 下载配置文件
|
||||
echo "下载配置文件..."
|
||||
if [ "$DOWNLOAD_CMD" = "powershell -Command Invoke-WebRequest -Uri" ]; then
|
||||
$DOWNLOAD_CMD "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/docker-compose.yml" $DOWNLOAD_CMD_SUFFIX "docker-compose.yml"
|
||||
$DOWNLOAD_CMD "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/config.yaml" $DOWNLOAD_CMD_SUFFIX "data/.config.yaml"
|
||||
else
|
||||
$DOWNLOAD_CMD "docker-compose.yml" "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/docker-compose.yml"
|
||||
$DOWNLOAD_CMD "data/.config.yaml" "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/config.yaml"
|
||||
fi
|
||||
|
||||
# 检查文件是否存在
|
||||
echo "检查文件完整性..."
|
||||
FILES_TO_CHECK="docker-compose.yml data/.config.yaml models/SenseVoiceSmall/model.pt"
|
||||
ALL_FILES_EXIST=true
|
||||
|
||||
for FILE in $FILES_TO_CHECK; do
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "${RED}错误: $FILE 不存在${NC}"
|
||||
ALL_FILES_EXIST=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_FILES_EXIST" = false ]; then
|
||||
echo "${RED}某些文件下载失败,请检查上述错误信息并手动下载缺失的文件。${NC}"
|
||||
# 检查root权限
|
||||
if [ $EUID -ne 0 ]; then
|
||||
whiptail --title "权限错误" --msgbox "请使用root权限运行本脚本" 10 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${GREEN}文件下载完成!${NC}"
|
||||
echo "请编辑 data/.config.yaml 文件配置你的API密钥。"
|
||||
echo "配置完成后,运行以下命令启动服务:"
|
||||
echo "${GREEN}docker-compose up -d${NC}"
|
||||
echo "查看日志请运行:"
|
||||
echo "${GREEN}docker logs -f xiaozhi-esp32-server${NC}"
|
||||
# 检查系统版本
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
if [ "$ID" != "debian" ] && [ "$ID" != "ubuntu" ]; then
|
||||
whiptail --title "系统错误" --msgbox "该脚本只支持Debian/Ubuntu系统执行" 10 60
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
whiptail --title "系统错误" --msgbox "无法确定系统版本,该脚本只支持Debian/Ubuntu系统执行" 10 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 提示用户编辑配置文件
|
||||
echo "\n${RED}重要提示:${NC}"
|
||||
echo "1. 请确保编辑 data/.config.yaml 文件,配置必要的API密钥"
|
||||
echo "2. 特别是 ChatGLM 和 mem0ai 的密钥必须配置"
|
||||
echo "3. 配置完成后再启动 docker 服务"
|
||||
# 下载配置文件函数
|
||||
check_and_download() {
|
||||
local filepath=$1
|
||||
local url=$2
|
||||
if [ ! -f "$filepath" ]; then
|
||||
if ! curl -fL --progress-bar "$url" -o "$filepath"; then
|
||||
whiptail --title "错误" --msgbox "${filepath}文件下载失败" 10 50
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "${filepath}文件已存在,跳过下载"
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查是否已安装
|
||||
check_installed() {
|
||||
# 检查目录是否存在且非空
|
||||
if [ -d "/opt/xiaozhi-server/" ] && [ "$(ls -A /opt/xiaozhi-server/)" ]; then
|
||||
DIR_CHECK=1
|
||||
else
|
||||
DIR_CHECK=0
|
||||
fi
|
||||
|
||||
# 检查容器是否存在
|
||||
if docker inspect xiaozhi-esp32-server > /dev/null 2>&1; then
|
||||
CONTAINER_CHECK=1
|
||||
else
|
||||
CONTAINER_CHECK=0
|
||||
fi
|
||||
|
||||
# 两次检查都通过
|
||||
if [ $DIR_CHECK -eq 1 ] && [ $CONTAINER_CHECK -eq 1 ]; then
|
||||
return 0 # 已安装
|
||||
else
|
||||
return 1 # 未安装
|
||||
fi
|
||||
}
|
||||
|
||||
# 更新相关
|
||||
if check_installed; then
|
||||
if whiptail --title "已安装检测" --yesno "检测到小智服务端已安装,是否进行升级?" 10 60; then
|
||||
# 用户选择升级,执行清理操作
|
||||
echo "开始升级操作..."
|
||||
|
||||
# 停止并移除所有docker-compose服务
|
||||
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml down
|
||||
|
||||
# 停止并删除特定容器(考虑容器可能不存在的情况)
|
||||
containers=(
|
||||
"xiaozhi-esp32-server"
|
||||
"xiaozhi-esp32-server-web"
|
||||
"xiaozhi-esp32-server-db"
|
||||
"xiaozhi-esp32-server-redis"
|
||||
)
|
||||
|
||||
for container in "${containers[@]}"; do
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
|
||||
docker stop "$container" >/dev/null 2>&1 && \
|
||||
docker rm "$container" >/dev/null 2>&1 && \
|
||||
echo "成功移除容器: $container"
|
||||
else
|
||||
echo "容器不存在,跳过: $container"
|
||||
fi
|
||||
done
|
||||
|
||||
# 删除特定镜像(考虑镜像可能不存在的情况)
|
||||
images=(
|
||||
"ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest"
|
||||
"ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest"
|
||||
)
|
||||
|
||||
for image in "${images[@]}"; do
|
||||
if docker images --format '{{.Repository}}:{{.Tag}}' | grep -q "^${image}$"; then
|
||||
docker rmi "$image" >/dev/null 2>&1 && \
|
||||
echo "成功删除镜像: $image"
|
||||
else
|
||||
echo "镜像不存在,跳过: $image"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "所有清理操作完成"
|
||||
|
||||
# 备份原有配置文件
|
||||
mkdir -p /opt/xiaozhi-server/backup/
|
||||
if [ -f /opt/xiaozhi-server/data/.config.yaml ]; then
|
||||
cp /opt/xiaozhi-server/data/.config.yaml /opt/xiaozhi-server/backup/.config.yaml
|
||||
echo "已备份原有配置文件到 /opt/xiaozhi-server/backup/.config.yaml"
|
||||
fi
|
||||
|
||||
# 下载最新版配置文件
|
||||
check_and_download "/opt/xiaozhi-server/docker-compose_all.yml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/docker-compose_all.yml"
|
||||
check_and_download "/opt/xiaozhi-server/data/.config.yaml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/config_from_api.yaml"
|
||||
|
||||
# 启动Docker服务
|
||||
echo "开始启动最新版本服务..."
|
||||
# 升级完成后标记,跳过后续下载步骤
|
||||
UPGRADE_COMPLETED=1
|
||||
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
|
||||
else
|
||||
whiptail --title "跳过升级" --msgbox "已取消升级,将继续使用当前版本。" 10 50
|
||||
# 跳过升级,继续执行后续安装流程
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# 检查curl安装
|
||||
if ! command -v curl &> /dev/null; then
|
||||
echo "------------------------------------------------------------"
|
||||
echo "未检测到curl,正在安装..."
|
||||
apt update
|
||||
apt install -y curl
|
||||
else
|
||||
echo "------------------------------------------------------------"
|
||||
echo "curl已安装,跳过安装步骤"
|
||||
fi
|
||||
|
||||
# 检查Docker安装
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "------------------------------------------------------------"
|
||||
echo "未检测到Docker,正在安装..."
|
||||
|
||||
# 使用国内镜像源替代官方源
|
||||
DISTRO=$(lsb_release -cs)
|
||||
MIRROR_URL="https://mirrors.aliyun.com/docker-ce/linux/ubuntu"
|
||||
GPG_URL="https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg"
|
||||
|
||||
# 安装基础依赖
|
||||
apt update
|
||||
apt install -y apt-transport-https ca-certificates curl software-properties-common gnupg
|
||||
|
||||
# 创建密钥目录并添加国内镜像源密钥
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL "$GPG_URL" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# 添加国内镜像源
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $MIRROR_URL $DISTRO stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
|
||||
# 添加备用官方源密钥(避免国内源密钥验证失败)
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 7EA0A9C3F273FCD8 2>/dev/null || \
|
||||
echo "警告:部分密钥添加失败,继续尝试安装..."
|
||||
|
||||
# 安装Docker
|
||||
apt update
|
||||
apt install -y docker-ce docker-ce-cli containerd.io
|
||||
|
||||
# 启动服务
|
||||
systemctl start docker
|
||||
systemctl enable docker
|
||||
|
||||
# 检查是否安装成功
|
||||
if docker --version; then
|
||||
echo "------------------------------------------------------------"
|
||||
echo "Docker安装完成!"
|
||||
else
|
||||
whiptail --title "错误" --msgbox "Docker安装失败,请检查日志。" 10 50
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Docker已安装,跳过安装步骤"
|
||||
fi
|
||||
|
||||
# Docker镜像源配置
|
||||
MIRROR_OPTIONS=(
|
||||
"1" "轩辕镜像 (推荐)"
|
||||
"2" "腾讯云镜像源"
|
||||
"3" "中科大镜像源"
|
||||
"4" "网易163镜像源"
|
||||
"5" "华为云镜像源"
|
||||
"6" "阿里云镜像源"
|
||||
"7" "自定义镜像源"
|
||||
"8" "跳过配置"
|
||||
)
|
||||
|
||||
MIRROR_CHOICE=$(whiptail --title "选择Docker镜像源" --menu "请选择要使用的Docker镜像源" 20 60 10 \
|
||||
"${MIRROR_OPTIONS[@]}" 3>&1 1>&2 2>&3) || {
|
||||
echo "用户取消选择,退出脚本"
|
||||
exit 1
|
||||
}
|
||||
|
||||
case $MIRROR_CHOICE in
|
||||
1) MIRROR_URL="https://docker.xuanyuan.me" ;;
|
||||
2) MIRROR_URL="https://mirror.ccs.tencentyun.com" ;;
|
||||
3) MIRROR_URL="https://docker.mirrors.ustc.edu.cn" ;;
|
||||
4) MIRROR_URL="https://hub-mirror.c.163.com" ;;
|
||||
5) MIRROR_URL="https://05f073ad3c0010ea0f4bc00b7105ec20.mirror.swr.myhuaweicloud.com" ;;
|
||||
6) MIRROR_URL="https://registry.aliyuncs.com" ;;
|
||||
7) MIRROR_URL=$(whiptail --title "自定义镜像源" --inputbox "请输入完整的镜像源URL:" 10 60 3>&1 1>&2 2>&3) ;;
|
||||
8) MIRROR_URL="" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$MIRROR_URL" ]; then
|
||||
mkdir -p /etc/docker
|
||||
if [ -f /etc/docker/daemon.json ]; then
|
||||
cp /etc/docker/daemon.json /etc/docker/daemon.json.bak
|
||||
fi
|
||||
cat > /etc/docker/daemon.json <<EOF
|
||||
{
|
||||
"dns": ["8.8.8.8", "114.114.114.114"],
|
||||
"registry-mirrors": ["$MIRROR_URL"]
|
||||
}
|
||||
EOF
|
||||
whiptail --title "配置成功" --msgbox "已成功添加镜像源: $MIRROR_URL\n请按Enter键重启Docker服务并继续..." 12 60
|
||||
echo "------------------------------------------------------------"
|
||||
echo "开始重启Docker服务..."
|
||||
systemctl restart docker.service
|
||||
fi
|
||||
|
||||
# 创建安装目录
|
||||
echo "------------------------------------------------------------"
|
||||
echo "开始创建安装目录..."
|
||||
# 检查并创建数据目录
|
||||
if [ ! -d /opt/xiaozhi-server/data ]; then
|
||||
mkdir -p /opt/xiaozhi-server/data
|
||||
echo "已创建数据目录: /opt/xiaozhi-server/data"
|
||||
else
|
||||
echo "目录xiaozhi-server/data已存在,跳过创建"
|
||||
fi
|
||||
|
||||
# 检查并创建模型目录
|
||||
if [ ! -d /opt/xiaozhi-server/models/SenseVoiceSmall ]; then
|
||||
mkdir -p /opt/xiaozhi-server/models/SenseVoiceSmall
|
||||
echo "已创建模型目录: /opt/xiaozhi-server/models/SenseVoiceSmall"
|
||||
else
|
||||
echo "目录xiaozhi-server/models/SenseVoiceSmall已存在,跳过创建"
|
||||
fi
|
||||
|
||||
echo "------------------------------------------------------------"
|
||||
echo "开始下载语音识别模型"
|
||||
# 下载模型文件
|
||||
MODEL_PATH="/opt/xiaozhi-server/models/SenseVoiceSmall/model.pt"
|
||||
if [ ! -f "$MODEL_PATH" ]; then
|
||||
(
|
||||
for i in {1..20}; do
|
||||
echo $((i*5))
|
||||
sleep 0.5
|
||||
done
|
||||
) | whiptail --title "下载中" --gauge "开始下载语音识别模型..." 10 60 0
|
||||
curl -fL --progress-bar https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt -o "$MODEL_PATH" || {
|
||||
whiptail --title "错误" --msgbox "model.pt文件下载失败" 10 50
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo "model.pt文件已存在,跳过下载"
|
||||
fi
|
||||
|
||||
# 如果不是升级完成,才执行下载
|
||||
if [ -z "$UPGRADE_COMPLETED" ]; then
|
||||
check_and_download "/opt/xiaozhi-server/docker-compose_all.yml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/docker-compose_all.yml"
|
||||
check_and_download "/opt/xiaozhi-server/data/.config.yaml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/config_from_api.yaml"
|
||||
fi
|
||||
|
||||
# 启动Docker服务
|
||||
(
|
||||
echo "------------------------------------------------------------"
|
||||
echo "正在拉取Docker镜像..."
|
||||
echo "这可能需要几分钟时间,请耐心等待"
|
||||
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
whiptail --title "错误" --msgbox "Docker服务启动失败,请尝试更换镜像源后重新执行本脚本" 10 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "------------------------------------------------------------"
|
||||
echo "正在检查服务启动状态..."
|
||||
TIMEOUT=300
|
||||
START_TIME=$(date +%s)
|
||||
while true; do
|
||||
CURRENT_TIME=$(date +%s)
|
||||
if [ $((CURRENT_TIME - START_TIME)) -gt $TIMEOUT ]; then
|
||||
whiptail --title "错误" --msgbox "服务启动超时,未在指定时间内找到预期日志内容" 10 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if docker logs xiaozhi-esp32-server-web 2>&1 | grep -q "Started AdminApplication in"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "服务端启动成功!正在完成配置..."
|
||||
echo "正在启动服务..."
|
||||
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
|
||||
echo "服务启动完成!"
|
||||
)
|
||||
|
||||
# 密钥配置
|
||||
|
||||
# 获取服务器公网地址
|
||||
PUBLIC_IP=$(hostname -I | awk '{print $1}')
|
||||
whiptail --title "配置服务器密钥" --msgbox "请使用浏览器,访问下方链接,打开智控台并注册账号: \n\n内网地址:http://127.0.0.1:8002/\n公网地址:http://$PUBLIC_IP:8002/ (若是云服务器请在服务器安全组放行端口 8000 8001 8002)。\n\n注册的第一个用户即是超级管理员,以后注册的用户都是普通用户。普通用户只能绑定设备和配置智能体; 超级管理员可以进行模型管理、用户管理、参数配置等功能。\n\n注册好后请按Enter键继续" 18 70
|
||||
SECRET_KEY=$(whiptail --title "配置服务器密钥" --inputbox "请使用超级管理员账号登录智控台\n内网地址:http://127.0.0.1:8002/\n公网地址:http://$PUBLIC_IP:8002/\n在顶部菜单 参数字典 → 参数管理 找到参数编码: server.secret (服务器密钥) \n复制该参数值并输入到下面输入框\n\n请输入密钥(留空则跳过配置):" 15 60 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -n "$SECRET_KEY" ]; then
|
||||
python3 -c "
|
||||
import sys, yaml;
|
||||
config_path = '/opt/xiaozhi-server/data/.config.yaml';
|
||||
with open(config_path, 'r') as f:
|
||||
config = yaml.safe_load(f) or {};
|
||||
config['manager-api'] = {'url': 'http://xiaozhi-esp32-server-web:8002/xiaozhi', 'secret': '$SECRET_KEY'};
|
||||
with open(config_path, 'w') as f:
|
||||
yaml.dump(config, f);
|
||||
"
|
||||
docker restart xiaozhi-esp32-server
|
||||
fi
|
||||
|
||||
# 获取并显示地址信息
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
# 修复日志文件获取不到ws的问题,改为硬编码
|
||||
whiptail --title "安装完成!" --msgbox "\
|
||||
服务端相关地址如下:\n\
|
||||
管理后台访问地址: http://$LOCAL_IP:8002\n\
|
||||
OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\
|
||||
视觉分析接口地址: http://$LOCAL_IP:8003/mcp/vision/explain\n\
|
||||
WebSocket 地址: ws://$LOCAL_IP:8000/xiaozhi/v1/\n\
|
||||
\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70
|
||||
|
||||
@@ -2,53 +2,19 @@
|
||||

|
||||
# 方式一:Docker只运行Server
|
||||
|
||||
docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统上运行。
|
||||
`0.8.2`版本开始,本项目发行的docker镜像只支持`x86架构`,如果需要在`arm64架构`的CPU上部署,可按照[这个教程](docker-build.md)在本机编译`arm64的镜像`。
|
||||
|
||||
## 1. 安装docker
|
||||
|
||||
如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html)
|
||||
|
||||
如果你已经安装好docker,你可以[1.1使用懒人脚本](#11-懒人脚本)自动帮你下载所需的文件和配置文件,你可以使用docker[1.2手动部署](#12-手动部署)。
|
||||
安装好docker后,进继续。
|
||||
|
||||
### 1.1 懒人脚本
|
||||
### 1.1 手动部署
|
||||
|
||||
你可以使用以下命令一键下载并执行部署脚本:
|
||||
请确保你的环境可以正常访问 GitHub 否则无法下载脚本。
|
||||
```bash
|
||||
curl -L -o docker-setup.sh https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/docker-setup.sh
|
||||
```
|
||||
#### 1.1.1 创建目录
|
||||
|
||||
如果您的电脑是windows系统,请使用使用 Git Bash、WSL、PowerShell 或 CMD 运行以下命令:
|
||||
```bash
|
||||
# Git Bash 或 WSL
|
||||
sh docker-setup.sh
|
||||
# PowerShell 或 CMD
|
||||
.\docker-setup.sh
|
||||
```
|
||||
|
||||
如果您的电脑是linux 或者 macos 系统,请使用终端运行以下命令:
|
||||
```bash
|
||||
chmod +x docker-setup.sh
|
||||
./docker-setup.sh
|
||||
```
|
||||
|
||||
脚本会自动完成以下操作:
|
||||
> 1. 创建必要的目录结构
|
||||
> 2. 下载语音识别模型
|
||||
> 3. 下载配置文件
|
||||
> 4. 检查文件完整性
|
||||
>
|
||||
> 执行完成后,请按照提示配置 API 密钥。
|
||||
|
||||
当你一切顺利完成以上操作后,继续操作[配置项目文件](#2-配置项目文件)
|
||||
|
||||
### 1.2 手动部署
|
||||
|
||||
如果懒人脚本无法正常运行,请按本章节1.2进行手动部署。
|
||||
|
||||
#### 1.2.1 创建目录
|
||||
|
||||
安装完后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`。
|
||||
安装完docker后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`。
|
||||
|
||||
创建好目录后,你需要在`xiaozhi-server`下面创建`data`文件夹和`models`文件夹,`models`下面还要再创建`SenseVoiceSmall`文件夹。
|
||||
|
||||
@@ -61,18 +27,18 @@ xiaozhi-server
|
||||
├─ SenseVoiceSmall
|
||||
```
|
||||
|
||||
#### 1.2.2 下载语音识别模型文件
|
||||
#### 1.1.2 下载语音识别模型文件
|
||||
|
||||
你需要下载语音识别的模型文件,因为本项目的默认语音识别用的是本地离线语音识别方案。可通过这个方式下载
|
||||
[跳转到下载语音识别模型文件](#模型文件)
|
||||
|
||||
下载完后,回到本教程。
|
||||
|
||||
#### 1.2.3 下载配置文件
|
||||
#### 1.1.3 下载配置文件
|
||||
|
||||
你需要下载两个配置文件:`docker-compose.yaml` 和 `config.yaml`。需要从项目仓库下载这两个文件。
|
||||
|
||||
##### 1.2.3.1 下载 docker-compose.yaml
|
||||
##### 1.1.3.1 下载 docker-compose.yaml
|
||||
|
||||
用浏览器打开[这个链接](../main/xiaozhi-server/docker-compose.yml)。
|
||||
|
||||
@@ -81,7 +47,7 @@ xiaozhi-server
|
||||
|
||||
下载完后,回到本教程继续往下。
|
||||
|
||||
##### 1.2.3.2 创建 config.yaml
|
||||
##### 1.1.3.2 创建 config.yaml
|
||||
|
||||
用浏览器打开[这个链接](../main/xiaozhi-server/config.yaml)。
|
||||
|
||||
@@ -173,6 +139,9 @@ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/
|
||||
|
||||
conda install libopus -y
|
||||
conda install ffmpeg -y
|
||||
|
||||
# 在 Linux 环境下进行部署时,如出现类似缺失 libiconv.so.2 动态库的报错 请通过以下命令进行安装
|
||||
conda install libiconv -y
|
||||
```
|
||||
|
||||
请注意,以上命令,不是一股脑执行就成功的,你需要一步步执行,每一步执行完后,都检查一下输出的日志,查看是否成功。
|
||||
@@ -260,7 +229,7 @@ LLM:
|
||||
文件放在`models/SenseVoiceSmall`
|
||||
目录下。下面两个下载路线任选一个。
|
||||
|
||||
- 线路一:阿里魔塔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
|
||||
- 线路一:阿里魔搭下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
|
||||
- 线路二:百度网盘下载[SenseVoiceSmall](https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg&pwd=qvna) 提取码:
|
||||
`qvna`
|
||||
|
||||
@@ -290,19 +259,33 @@ LLM:
|
||||
|
||||
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
||||
|
||||
|
||||
# 常见问题
|
||||
以下是一些常见问题,供参考:
|
||||
|
||||
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
|
||||
|
||||
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
|
||||
|
||||
[3、TTS 经常失败,经常超时](./FAQ.md)
|
||||
|
||||
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
|
||||
|
||||
[5、如何提高小智对话响应速度?](./FAQ.md)
|
||||
|
||||
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
|
||||
|
||||
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
|
||||
1、[为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)<br/>
|
||||
2、[为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)<br/>
|
||||
3、[TTS 经常失败,经常超时](./FAQ.md)<br/>
|
||||
4、[使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)<br/>
|
||||
5、[如何提高小智对话响应速度?](./FAQ.md)<br/>
|
||||
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
|
||||
## 部署相关教程
|
||||
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||
2、[如何部署MQTT网关开启MQTT+UDP协议](./mqtt-gateway-integration.md)<br/>
|
||||
3、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
## 拓展相关教程
|
||||
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||
3、[如何开启视觉模型实现拍照识物](./mcp-vision-integration.md)<br/>
|
||||
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||
7、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
8、[天气插件使用指南](./weather-integration.md)<br/>
|
||||
## 语音克隆、本地语音部署相关教程
|
||||
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
|
||||
2、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
3、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
4、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
## 性能测试教程
|
||||
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
# 部署架构图
|
||||

|
||||
# 方式一:Docker运行全模块
|
||||
docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统上运行。
|
||||
`0.8.2`版本开始,本项目发行的docker镜像只支持`x86架构`,如果需要在`arm64架构`的CPU上部署,可按照[这个教程](docker-build.md)在本机编译`arm64的镜像`。
|
||||
|
||||
## 1. 安装docker
|
||||
|
||||
如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html)
|
||||
|
||||
#### 1.1 创建目录
|
||||
docker 安装全模块有两种方式,你可以[使用懒人脚本](./Deployment_all.md#11-懒人脚本)(作者[@VanillaNahida](https://github.com/VanillaNahida))
|
||||
脚本会自动帮你下载所需的文件和配置文件,你也可以使用[手动部署](./Deployment_all.md#12-手动部署)从零搭建。
|
||||
|
||||
|
||||
|
||||
### 1.1 懒人脚本
|
||||
部署简便,可以参考[视频教程](https://www.bilibili.com/video/BV17bbvzHExd/) ,文字版教程如下:
|
||||
> [!NOTE]
|
||||
> 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug
|
||||
|
||||
使用SSH工具连接到服务器,以root权限执行如下脚本
|
||||
```bash
|
||||
sudo bash -c "$(wget -qO- https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/docker-setup.sh)"
|
||||
```
|
||||
|
||||
脚本会自动完成以下操作:
|
||||
> 1. 安装Docker
|
||||
> 2. 配置镜像源
|
||||
> 3. 下载/拉取镜像
|
||||
> 4. 下载语音识别模型文件
|
||||
> 5. 引导配置服务端
|
||||
>
|
||||
|
||||
执行完成后简单配置后,再参照[4. 运行程序](#4. 运行程序)和[5.重启xiaozhi-esp32-server](#5.重启xiaozhi-esp32-server)里提到的最重要的3件事情,完成3这三项配置后即可使用。
|
||||
|
||||
### 1.2 手动部署
|
||||
|
||||
#### 1.2.1 创建目录
|
||||
|
||||
安装完后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`。
|
||||
|
||||
@@ -22,22 +49,22 @@ xiaozhi-server
|
||||
├─ SenseVoiceSmall
|
||||
```
|
||||
|
||||
#### 1.2 下载语音识别模型文件
|
||||
#### 1.2.2 下载语音识别模型文件
|
||||
|
||||
本项目语音识别模型,默认使用`SenseVoiceSmall`模型,进行语音转文字。因为模型较大,需要独立下载,下载后把`model.pt`
|
||||
文件放在`models/SenseVoiceSmall`
|
||||
目录下。下面两个下载路线任选一个。
|
||||
|
||||
- 线路一:阿里魔塔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
|
||||
- 线路一:阿里魔搭下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
|
||||
- 线路二:百度网盘下载[SenseVoiceSmall](https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg&pwd=qvna) 提取码:
|
||||
`qvna`
|
||||
|
||||
|
||||
#### 1.3 下载配置文件
|
||||
#### 1.2.3 下载配置文件
|
||||
|
||||
你需要下载两个配置文件:`docker-compose_all.yaml` 和 `config_from_api.yaml`。需要从项目仓库下载这两个文件。
|
||||
|
||||
##### 1.3.1 下载 docker-compose_all.yaml
|
||||
##### 1.2.3.1 下载 docker-compose_all.yaml
|
||||
|
||||
用浏览器打开[这个链接](../main/xiaozhi-server/docker-compose_all.yml)。
|
||||
|
||||
@@ -48,7 +75,7 @@ xiaozhi-server
|
||||
|
||||
下载完后,回到本教程继续往下。
|
||||
|
||||
##### 1.3.2 下载 config_from_api.yaml
|
||||
##### 1.2.3.2 下载 config_from_api.yaml
|
||||
|
||||
用浏览器打开[这个链接](../main/xiaozhi-server/config_from_api.yaml)。
|
||||
|
||||
@@ -179,12 +206,12 @@ docker logs -f xiaozhi-esp32-server
|
||||
|
||||
OTA接口:
|
||||
```
|
||||
http://你电脑局域网的ip:8002/xiaozhi/ota/
|
||||
http://你宿主机局域网的ip:8002/xiaozhi/ota/
|
||||
```
|
||||
|
||||
Websocket接口:
|
||||
```
|
||||
ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
||||
ws://你宿主机的ip:8000/xiaozhi/v1/
|
||||
```
|
||||
|
||||
### 第三件重要的事情
|
||||
@@ -328,6 +355,9 @@ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/
|
||||
|
||||
conda install libopus -y
|
||||
conda install ffmpeg -y
|
||||
|
||||
# 在 Linux 环境下进行部署时,如出现类似缺失 libiconv.so.2 动态库的报错 请通过以下命令进行安装
|
||||
conda install libiconv -y
|
||||
```
|
||||
|
||||
请注意,以上命令,不是一股脑执行就成功的,你需要一步步执行,每一步执行完后,都检查一下输出的日志,查看是否成功。
|
||||
@@ -358,7 +388,7 @@ pip install -r requirements.txt
|
||||
文件放在`models/SenseVoiceSmall`
|
||||
目录下。下面两个下载路线任选一个。
|
||||
|
||||
- 线路一:阿里魔塔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
|
||||
- 线路一:阿里魔搭下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
|
||||
- 线路二:百度网盘下载[SenseVoiceSmall](https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg&pwd=qvna) 提取码:
|
||||
`qvna`
|
||||
|
||||
@@ -431,19 +461,32 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
||||
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
||||
|
||||
# 常见问题
|
||||
|
||||
以下是一些常见问题,供参考:
|
||||
|
||||
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
|
||||
|
||||
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
|
||||
|
||||
[3、TTS 经常失败,经常超时](./FAQ.md)
|
||||
|
||||
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
|
||||
|
||||
[5、如何提高小智对话响应速度?](./FAQ.md)
|
||||
|
||||
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
|
||||
|
||||
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
|
||||
1、[为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)<br/>
|
||||
2、[为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)<br/>
|
||||
3、[TTS 经常失败,经常超时](./FAQ.md)<br/>
|
||||
4、[使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)<br/>
|
||||
5、[如何提高小智对话响应速度?](./FAQ.md)<br/>
|
||||
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
|
||||
## 部署相关教程
|
||||
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||
2、[如何部署MQTT网关开启MQTT+UDP协议](./mqtt-gateway-integration.md)<br/>
|
||||
3、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
## 拓展相关教程
|
||||
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||
3、[如何开启视觉模型实现拍照识物](./mcp-vision-integration.md)<br/>
|
||||
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||
7、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
8、[天气插件使用指南](./weather-integration.md)<br/>
|
||||
## 语音克隆、本地语音部署相关教程
|
||||
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
|
||||
2、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
3、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
4、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
## 性能测试教程
|
||||
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||
|
||||
@@ -34,59 +34,18 @@ conda install conda-forge::ffmpeg
|
||||
### 5、如何提高小智对话响应速度? ⚡
|
||||
|
||||
本项目默认配置为低成本方案,建议初学者先使用默认免费模型,解决"跑得动"的问题,再优化"跑得快"。
|
||||
如需提升响应速度,可尝试更换各组件。以下为各组件的响应速度测试数据(仅供参考,不构成承诺):
|
||||
如需提升响应速度,可尝试更换各组件。自`0.5.2`版本起,项目支持流式配置,相比早期版本,响应速度提升约`2.5秒`,显著改善用户体验。
|
||||
|
||||
| 影响因素 | 因素值 |
|
||||
|:-----:|:----------------:|
|
||||
| 测试地点 | 广东省广州市海珠区 |
|
||||
| 测试时间 | 2025年2月19日 12:52 |
|
||||
| 宽带运营商 | 中国联通 |
|
||||
| 模块名称 | 入门全免费设置 | 流式配置 |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
|
||||
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) |
|
||||
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) |
|
||||
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) |
|
||||
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
||||
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||
|
||||
测试方法:
|
||||
|
||||
1、把各组件的密钥配置上去,只有配置了密钥的组件才参与测试。
|
||||
|
||||
2、配置完密钥后,执行以下方法
|
||||
|
||||
```
|
||||
# 进入项目根目录,执行以下命令:
|
||||
conda activate xiaozhi-esp32-server
|
||||
python performance_tester.py
|
||||
```
|
||||
|
||||
生成报告如下
|
||||
|
||||
LLM 性能排行:
|
||||
|
||||
| 模块名称 | 平均首Token时间 | 平均总响应时间 |
|
||||
|:-----------|:-----------|:--------|
|
||||
| AliLLM | 0.547s | 1.485s |
|
||||
| ChatGLMLLM | 0.677s | 3.057s |
|
||||
|
||||
TTS 性能排行:
|
||||
|
||||
| 模块名称 | 平均合成时间 |
|
||||
|----------------------|--------|
|
||||
| EdgeTTS | 1.019s |
|
||||
| DoubaoTTS | 0.503s |
|
||||
| CosyVoiceSiliconflow | 3.732s |
|
||||
|
||||
推荐配置组合 (综合响应速度):
|
||||
|
||||
| 组合方案 | 综合得分 | LLM首Token | TTS合成 |
|
||||
|-------------------------------|-------|-----------|--------|
|
||||
| AliLLM + DoubaoTTS | 0.539 | 0.547s | 0.503s |
|
||||
| AliLLM + EdgeTTS | 0.642 | 0.547s | 1.019s |
|
||||
| ChatGLMLLM + DoubaoTTS | 0.642 | 0.677s | 0.503s |
|
||||
| ChatGLMLLM + EdgeTTS | 0.745 | 0.677s | 1.019s |
|
||||
| AliLLM + CosyVoiceSiliconflow | 1.184 | 0.547s | 3.732s |
|
||||
|
||||
### 结论 🔍
|
||||
|
||||
`2025年2月19日`,如果我的电脑在`广东省广州市海珠区`,且使用的是`中国联通`网络,我会优先使用:
|
||||
|
||||
- LLM:`AliLLM`
|
||||
- TTS:`DoubaoTTS`
|
||||
如果您关心各组件的耗时,请查阅[小智各组件性能测试报告](https://github.com/xinnan-tech/xiaozhi-performance-research),可按报告中的测试方法在您的环境中实际测试。
|
||||
|
||||
### 6、我说话很慢,停顿时小智老是抢话 🗣️
|
||||
|
||||
@@ -100,30 +59,37 @@ VAD:
|
||||
min_silence_duration_ms: 700 # 如果说话停顿较长,可将此值调大
|
||||
```
|
||||
|
||||
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
|
||||
### 7、部署相关教程
|
||||
1、[如何进行最简化部署](./Deployment.md)<br/>
|
||||
2、[如何进行全模块部署](./Deployment_all.md)<br/>
|
||||
3、[如何部署MQTT网关开启MQTT+UDP协议](./mqtt-gateway-integration.md)<br/>
|
||||
4、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||
5、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
|
||||
参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
|
||||
### 9、编译固件相关教程
|
||||
1、[如何自己编译小智固件](./firmware-build.md)<br/>
|
||||
2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)<br/>
|
||||
|
||||
### 8、如何开启手机注册智控台 📱
|
||||
### 10、拓展相关教程
|
||||
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||
3、[如何开启视觉模型实现拍照识物](./mcp-vision-integration.md)<br/>
|
||||
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||
6、[MCP方法如何获取设备信息](./mcp-get-device-info.md)<br/>
|
||||
7、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||
8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
|
||||
参考教程[阿里云短信集成指南](./ali-sms-integration.md)
|
||||
### 11、语音克隆、本地语音部署相关教程
|
||||
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
|
||||
2、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
3、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
4、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
|
||||
### 9、如何开启视觉模型实现拍照识物 📷
|
||||
|
||||
参考教程[视觉模型使用指南](./mcp-vision-integration.md)
|
||||
|
||||
### 10、如何开启MCP接入点 🔧
|
||||
|
||||
1、先参考教程[MCP 接入点部署使用指南](./mcp-endpoint-enable.md)
|
||||
|
||||
2、再参考教程[MCP 接入点使用指南](./mcp-endpoint-integration.md)
|
||||
|
||||
### 12、如何开启声纹识别 🔊
|
||||
|
||||
参考教程[声纹识别启用指南](./voiceprint-integration.md)
|
||||
### 12、性能测试教程
|
||||
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||
|
||||
### 13、更多问题,可联系我们反馈 💬
|
||||
|
||||
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
|
||||
|
||||
也可以发邮件我们取得联系:huangrongzhuang@xin-nan.com
|
||||
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
|
||||
@@ -105,6 +105,8 @@ else
|
||||
fi
|
||||
|
||||
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev &
|
||||
|
||||
tail tail -f nohup.out
|
||||
```
|
||||
|
||||
保存好后执行赋权命令
|
||||
@@ -134,8 +136,12 @@ else
|
||||
echo "已杀掉进程 $PID"
|
||||
fi
|
||||
cd main/xiaozhi-server
|
||||
# 初始化conda环境
|
||||
source ~/.bashrc
|
||||
conda activate xiaozhi-esp32-server
|
||||
pip install -r requirements.txt
|
||||
nohup python app.py >/dev/null &
|
||||
tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server.log
|
||||
```
|
||||
|
||||
保存好后执行赋权命令
|
||||
@@ -149,8 +155,6 @@ chmod 777 update_8000.sh
|
||||
以上的脚本都建立好后,日常更新,我们只要依次执行以下命令就可以做到自动更新和启动
|
||||
|
||||
```
|
||||
# 进入pyhton环境
|
||||
conda activate xiaozhi-esp32-server
|
||||
cd /home/system/xiaozhi
|
||||
# 更新并启动Java程序
|
||||
./update_8001.sh
|
||||
@@ -158,9 +162,11 @@ cd /home/system/xiaozhi
|
||||
./update_8002.sh
|
||||
# 更新并启动python程序
|
||||
./update_8000.sh
|
||||
# 查看Java日志
|
||||
|
||||
|
||||
# 后期想查看java日志,执行以下命令
|
||||
tail -f nohup.out
|
||||
# 查看Python日志
|
||||
# 后期想查看python日志,执行以下命令
|
||||
tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server.log
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 智控台 火山双流式语音合成+音色克隆配置教程
|
||||
|
||||
本教程分为4个阶段:准备阶段、配置阶段、克隆阶段、使用阶段。主要是介绍通过智控台配置火山双流式语音合成+音色克隆的过程。
|
||||
|
||||
## 第一阶段:准备阶段
|
||||
超级管理员先预先把火山引擎服务开通好,获取到App Id,Access Token。默认火上引擎会赠送一个音色资源。这个音色资源需要把它复制到本项目里。
|
||||
|
||||
如果你想克隆多个音色,需要购买开通多个音色资源。只要把每个音色资源的声音ID(S_xxxxx)复制到本项目。然后分配给系统的账号使用即可。以下是详细步骤:
|
||||
|
||||
### 1.开通火山引擎服务
|
||||
访问 https://console.volcengine.com/speech/app 在应用管理创建应用,勾选语音合成大模型和声音复刻大模型。
|
||||
|
||||
### 2.获取音色资源ID
|
||||
访问 https://console.volcengine.com/speech/service/9999 复制三项内容,分别是App Id,Access Token以及声音ID(S_xxxxx)。如图
|
||||
|
||||

|
||||
|
||||
## 第二阶段:配置火山引擎服务
|
||||
|
||||
### 1.填写火山引擎配置
|
||||
|
||||
使用超级管理员账号登录智控台,点击顶部【模型配置】,再点击模型配置页面左侧的【语音合成】,搜索找到“火山双流式语音合成”,点击修改,将你火山引擎的`App Id`填入到【应用ID】字段里,将`Access Token`填入到【访问令牌】字段里。然后保存。
|
||||
|
||||
### 2.将音色资源ID分配给系统账号
|
||||
|
||||
使用超级管理员账号登录智控台,点击顶部【音色克隆】、【音色资源】。
|
||||
|
||||
点击新增按钮,在【平台名称】选择“火山双流式语音合成”;
|
||||
|
||||
在【音色资源ID】填入你火山引擎的声音资源ID(S_xxxxx),填入后按回车;
|
||||
|
||||
在【归属账号】选择你要分配给的系统账号,你可以分配给你自己。然后点击保存
|
||||
|
||||
## 第三阶段:克隆阶段
|
||||
|
||||
如果登录后,点击顶部【音色克隆】》【音色克隆】,显示【您的账号暂无音色资源请联系管理员分配音色资源】,说明你在第二阶段还没有把音色资源ID分配给这个账号。那就是回到第二阶段,分配音色资源给对应的账号。
|
||||
|
||||
如果登录后,点击顶部【音色克隆】》【音色克隆】,能看到对应的音色列表。请继续。
|
||||
|
||||
在列表里会看到对应的音色列表。选择其中一个音色资源,点击【上传音频】按钮。上传后,可以试听一下声音或者截取某段声音。确认后点击【上传音频】按钮。
|
||||

|
||||
|
||||
上传音频后,在列表里会看到对应的音色会变成“待复刻”状态。点击【立即复刻】按钮。等1~2秒会返回结果。
|
||||
|
||||
如果复刻失败,请将鼠标放到“错误信息”图标上,会显示失败的原因。
|
||||
|
||||
如果复刻成功,在列表里会看到对应的音色会变成“训练成功”状态。此时你可以点击【声音名称】栏的修改按钮,修改音色资源的名称,方便后期选择使用。
|
||||
|
||||
## 第四阶段:使用阶段
|
||||
|
||||
点击顶部【智能体管理】,选择任意一个智能体,点击【配置角色】按钮。
|
||||
|
||||
语音合成(TTS)选择“火山双流式语音合成”。在列表里,找到名字带有“克隆音色”的音色资源(如图),选择它,点击保存。
|
||||

|
||||
|
||||
接下来,可以唤醒小智和它对话。
|
||||
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 655 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 579 KiB |
|
After Width: | Height: | Size: 418 KiB |
@@ -0,0 +1,195 @@
|
||||
# IndexStreamTTS 使用指南
|
||||
|
||||
## 环境准备
|
||||
### 1. 克隆项目
|
||||
```bash
|
||||
git clone https://github.com/Ksuriuri/index-tts-vllm.git
|
||||
```
|
||||
进入解压后的目录
|
||||
```bash
|
||||
cd index-tts-vllm
|
||||
```
|
||||
切换到指定版本 (使用VLLM-0.10.2的历史版本)
|
||||
```bash
|
||||
git checkout 224e8d5e5c8f66801845c66b30fa765328fd0be3
|
||||
```
|
||||
|
||||
### 2. 创建并激活 conda 环境
|
||||
```bash
|
||||
conda create -n index-tts-vllm python=3.12
|
||||
conda activate index-tts-vllm
|
||||
```
|
||||
|
||||
### 3. 安装PyTorch 需要版本为2.8.0(最新版)
|
||||
#### 查看显卡最高支持的版本和实际安装的版本
|
||||
```bash
|
||||
nvidia-smi
|
||||
nvcc --version
|
||||
```
|
||||
#### 驱动支持的最高 CUDA 版本
|
||||
```bash
|
||||
CUDA Version: 12.8
|
||||
```
|
||||
#### 实际安装的 CUDA 编译器版本
|
||||
```bash
|
||||
Cuda compilation tools, release 12.8, V12.8.89
|
||||
```
|
||||
#### 那么对应的安装命令(pytorch默认给的是12.8的驱动版本)
|
||||
```bash
|
||||
pip install torch torchvision
|
||||
```
|
||||
需要 pytorch 版本 2.8.0(对应 vllm 0.10.2),具体安装指令请参考:[pytorch 官网](https://pytorch.org/get-started/locally/)
|
||||
|
||||
### 4. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 5. 下载模型权重
|
||||
### 方案一:下载官方权重文件后转换
|
||||
此为官方权重文件,下载到本地任意路径即可,支持 IndexTTS-1.5 的权重
|
||||
| HuggingFace | ModelScope |
|
||||
|---------------------------------------------------------------|---------------------------------------------------------------------|
|
||||
| [IndexTTS](https://huggingface.co/IndexTeam/Index-TTS) | [IndexTTS](https://modelscope.cn/models/IndexTeam/Index-TTS) |
|
||||
| [IndexTTS-1.5](https://huggingface.co/IndexTeam/IndexTTS-1.5) | [IndexTTS-1.5](https://modelscope.cn/models/IndexTeam/IndexTTS-1.5) |
|
||||
|
||||
下面以ModelScope的安装方法为例
|
||||
#### 请注意:git需要安装并初始化启用lfs(如已安装可以跳过)
|
||||
```bash
|
||||
sudo apt-get install git-lfs
|
||||
git lfs install
|
||||
```
|
||||
创建模型目录,并拉取模型
|
||||
```bash
|
||||
mkdir model_dir
|
||||
cd model_dir
|
||||
git clone https://www.modelscope.cn/IndexTeam/IndexTTS-1.5.git
|
||||
```
|
||||
|
||||
#### 模型权重转换
|
||||
```bash
|
||||
bash convert_hf_format.sh /path/to/your/model_dir
|
||||
```
|
||||
例如:你下载的IndexTTS-1.5模型存放在model_dir目录下,则执行以下命令
|
||||
```bash
|
||||
bash convert_hf_format.sh model_dir/IndexTTS-1.5
|
||||
```
|
||||
此操作会将官方的模型权重转换为 transformers 库兼容的版本,保存在模型权重路径下的 vllm 文件夹中,方便后续 vllm 库加载模型权重
|
||||
|
||||
### 6. 更改接口适配一下项目
|
||||
接口返回数据与项目不适配需要调整一下,使其直接返回音频数据
|
||||
```bash
|
||||
vi api_server.py
|
||||
```
|
||||
```bash
|
||||
@app.post("/tts", responses={
|
||||
200: {"content": {"application/octet-stream": {}}},
|
||||
500: {"content": {"application/json": {}}}
|
||||
})
|
||||
async def tts_api(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
text = data["text"]
|
||||
character = data["character"]
|
||||
|
||||
global tts
|
||||
sr, wav = await tts.infer_with_ref_audio_embed(character, text)
|
||||
|
||||
return Response(content=wav.tobytes(), media_type="application/octet-stream")
|
||||
|
||||
except Exception as ex:
|
||||
tb_str = ''.join(traceback.format_exception(type(ex), ex, ex.__traceback__))
|
||||
print(tb_str)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"status": "error",
|
||||
"error": str(tb_str)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 7.编写sh启动脚本(请注意要在相应的conda环境下运行)
|
||||
```bash
|
||||
vi start_api.sh
|
||||
```
|
||||
### 将下面内容粘贴进去并按:输入wq保存
|
||||
#### 脚本中的/home/system/index-tts-vllm/model_dir/IndexTTS-1.5 请自行修改为实际路径
|
||||
```bash
|
||||
# 激活conda环境
|
||||
conda activate index-tts-vllm
|
||||
echo "激活项目conda环境"
|
||||
sleep 2
|
||||
# 查找占用11996端口的进程号
|
||||
PID_VLLM=$(sudo netstat -tulnp | grep 11996 | awk '{print $7}' | cut -d'/' -f1)
|
||||
|
||||
# 检查是否找到进程号
|
||||
if [ -z "$PID_VLLM" ]; then
|
||||
echo "没有找到占用11996端口的进程"
|
||||
else
|
||||
echo "找到占用11996端口的进程,进程号为: $PID_VLLM"
|
||||
# 先尝试普通kill,等待2秒
|
||||
kill $PID_VLLM
|
||||
sleep 2
|
||||
# 检查进程是否还在
|
||||
if ps -p $PID_VLLM > /dev/null; then
|
||||
echo "进程仍在运行,强制终止..."
|
||||
kill -9 $PID_VLLM
|
||||
fi
|
||||
echo "已终止进程 $PID_VLLM"
|
||||
fi
|
||||
|
||||
# 查找占用VLLM::EngineCore进程
|
||||
GPU_PIDS=$(ps aux | grep -E "VLLM|EngineCore" | grep -v grep | awk '{print $2}')
|
||||
|
||||
# 检查是否找到进程号
|
||||
if [ -z "$GPU_PIDS" ]; then
|
||||
echo "没有找到VLLM相关进程"
|
||||
else
|
||||
echo "找到VLLM相关进程,进程号为: $GPU_PIDS"
|
||||
# 先尝试普通kill,等待2秒
|
||||
kill $GPU_PIDS
|
||||
sleep 2
|
||||
# 检查进程是否还在
|
||||
if ps -p $GPU_PIDS > /dev/null; then
|
||||
echo "进程仍在运行,强制终止..."
|
||||
kill -9 $GPU_PIDS
|
||||
fi
|
||||
echo "已终止进程 $GPU_PIDS"
|
||||
fi
|
||||
|
||||
# 创建tmp目录(如果不存在)
|
||||
mkdir -p tmp
|
||||
|
||||
# 后台运行api_server.py,日志重定向到tmp/server.log
|
||||
nohup python api_server.py --model_dir /home/system/index-tts-vllm/model_dir/IndexTTS-1.5 --port 11996 > tmp/server.log 2>&1 &
|
||||
echo "api_server.py 已在后台运行,日志请查看 tmp/server.log"
|
||||
```
|
||||
给脚本执行权限并运行脚本
|
||||
```bash
|
||||
chmod +x start_api.sh
|
||||
./start_api.sh
|
||||
```
|
||||
日志会在tmp/server.log中输出,可以通过以下命令查看日志情况
|
||||
```bash
|
||||
tail -f tmp/server.log
|
||||
```
|
||||
如果显卡内存足够,可在脚本中添加启动参数 ----gpu_memory_utilization 来调整显存占用比例,默认值为 0.25
|
||||
|
||||
## 音色配置
|
||||
index-tts-vllm支持通过配置文件注册自定义音色,支持单音色和混合音色配置。
|
||||
在项目根目录下的assets/speaker.json文件中配置自定义音色
|
||||
### 配置格式说明
|
||||
```bash
|
||||
{
|
||||
"说话人名称1": [
|
||||
"音频文件路径1.wav",
|
||||
"音频文件路径2.wav"
|
||||
],
|
||||
"说话人名称2": [
|
||||
"音频文件路径3.wav"
|
||||
]
|
||||
}
|
||||
```
|
||||
### 注意 (配置角色后需重启服务进行音色注册)
|
||||
添加后需在智控台中添加相应的说话人(单模块则更换相应的voice)
|
||||
@@ -0,0 +1,40 @@
|
||||
# MCP 方法如何获取设备信息
|
||||
|
||||
本教程将指导你如何使用MCP方法获取设备信息。
|
||||
|
||||
第一步:自定义你的`agent-base-prompt.txt`文件
|
||||
|
||||
把xiaozhi-server目录的`agent-base-prompt.txt`文件内容复制到你的`data`目录下,并重命名为`.agent-base-prompt.txt`。
|
||||
|
||||
第二步:修改`data/.agent-base-prompt.txt`文件,找到`<context>`标签,在标签内容中添加以下代码内容:
|
||||
```
|
||||
- **设备ID:** {{device_id}}
|
||||
```
|
||||
|
||||
添加完成后,你的`data/.agent-base-prompt.txt`文件的`<context>`标签内容大致如下:
|
||||
```
|
||||
<context>
|
||||
【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】
|
||||
- **设备ID:** {{device_id}}
|
||||
- **当前时间:** {{current_time}}
|
||||
- **今天日期:** {{today_date}} ({{today_weekday}})
|
||||
- **今天农历:** {{lunar_date}}
|
||||
- **用户所在城市:** {{local_address}}
|
||||
- **当地未来7天天气:** {{weather_info}}
|
||||
</context>
|
||||
```
|
||||
|
||||
第三步:修改`data/.config.yaml`文件,找到`agent-base-prompt`配置,修改前内容如下:
|
||||
```
|
||||
prompt_template: agent-base-prompt.txt
|
||||
```
|
||||
修改成
|
||||
```
|
||||
prompt_template: data/.agent-base-prompt.txt
|
||||
```
|
||||
|
||||
第四步:重启你的xiaozhi-server服务。
|
||||
|
||||
第五步:在你的mcp方法增加名称为`device_id`,类型为`string`,描述为`设备ID`的参数。
|
||||
|
||||
第六步:重新唤醒小智,让他调用mcp方法,查看你的mcp方法是否可以获取`设备ID`。
|
||||
@@ -0,0 +1,179 @@
|
||||
# MQTT 网关部署教程
|
||||
|
||||
`xiaozhi-esp32-server`项目,可结合虾哥开源的[xiaozhi-mqtt-gateway](https://github.com/78/xiaozhi-mqtt-gateway) 项目进行简单改造,即可实现小智硬件MQTT+UDP连接。
|
||||
本教程分为三部分,你可以根据你是全模块部署还是单模块部署,选择对应的部分接入MQTT网关:
|
||||
- 第一部分:部署MQTT网关
|
||||
- 第二部分:全模块运行实现小智硬件MQTT+UDP连接
|
||||
- 第三部分:单模块运行xiaozhi-server实现小智硬件MQTT+UDP连接
|
||||
|
||||
## 准备阶段
|
||||
准备好你的`xiaozhi-server`的`mqtt-websocket`连接地址。在你原来的`websocket地址`基础上,添加`?from=mqtt_gateway`字符,就可以得到`mqtt-websocket`连接地址
|
||||
|
||||
1、如果你是源码部署,你的`mqtt-websocket`地址是:
|
||||
```
|
||||
ws://127.0.0.1:8000/xiaozhi/v1?from=mqtt_gateway
|
||||
```
|
||||
|
||||
2、如果你是docker部署,你的`mqtt-websocket`地址是
|
||||
```
|
||||
ws://你宿主机局域网IP:8000/xiaozhi/v1?from=mqtt_gateway
|
||||
```
|
||||
|
||||
## 重要提示
|
||||
|
||||
如果你是服务器部署,需要确保服务器`1883`、`8884`、`8007`端口都对外开放。`8884`选择的协议类型是`UDP`,其他是`TCP`。
|
||||
|
||||
如果你是服务器部署,需要确保服务器`1883`、`8884`、`8007`端口都对外开放。`8884`选择的协议类型是`UDP`,其他是`TCP`。
|
||||
|
||||
如果你是服务器部署,需要确保服务器`1883`、`8884`、`8007`端口都对外开放。`8884`选择的协议类型是`UDP`,其他是`TCP`。
|
||||
|
||||
|
||||
## 第一部分:部署MQTT网关
|
||||
|
||||
1. 克隆[改造后的xiaozhi-mqtt-gateway项目](https://github.com/xinnan-tech/xiaozhi-mqtt-gateway.git):
|
||||
```bash
|
||||
git clone https://ghfast.top/https://github.com/xinnan-tech/xiaozhi-mqtt-gateway.git
|
||||
cd xiaozhi-mqtt-gateway
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
3. 配置 `config.json`:
|
||||
```bash
|
||||
cp config/mqtt.json.example config/mqtt.json
|
||||
```
|
||||
|
||||
4. 编辑配置文件 config/mqtt.json,把你在`本文准备阶段`的`mqtt-websocket`地址替换到`chat_servers`里。例如源码部署的`xiaozhi-server`就是如下配置:
|
||||
|
||||
```
|
||||
{
|
||||
"production": {
|
||||
"chat_servers": [
|
||||
"ws://127.0.0.1:8000/xiaozhi/v1?from=mqtt_gateway"
|
||||
]
|
||||
},
|
||||
"debug": false,
|
||||
"max_mqtt_payload_size": 8192,
|
||||
"mcp_client": {
|
||||
"capabilities": {
|
||||
},
|
||||
"client_info": {
|
||||
"name": "xiaozhi-mqtt-client",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"max_tools_count": 128
|
||||
}
|
||||
}
|
||||
```
|
||||
5. 在项目根目录创建下`.env`文件,并设置以下环境变量:
|
||||
```
|
||||
PUBLIC_IP=your-ip # 服务器公网IP
|
||||
MQTT_PORT=1883 # MQTT服务器端口
|
||||
UDP_PORT=8884 # UDP服务器端口
|
||||
API_PORT=8007 # 管理API端口
|
||||
MQTT_SIGNATURE_KEY=test # MQTT签名密钥
|
||||
```
|
||||
请注意`PUBLIC_IP`配置,确保其与实际公网IP一致,如果有域名就填域名。
|
||||
|
||||
`MQTT_SIGNATURE_KEY` 是用于MQTT连接认证的密钥,最好设置成复杂一点的,最好是设置成8个字符以上且同时包含大小写字母,这个密钥稍后还会用到。
|
||||
|
||||
- 注意不要用简单的密码,比如`123456`、`test`等。
|
||||
- 注意不要用简单的密码,比如`123456`、`test`等。
|
||||
- 注意不要用简单的密码,比如`123456`、`test`等。
|
||||
|
||||
6. 启动MQTT网关
|
||||
```
|
||||
# 启动服务
|
||||
pm2 start ecosystem.config.js
|
||||
|
||||
# 查看日志
|
||||
pm2 logs xz-mqtt
|
||||
```
|
||||
|
||||
当你看到如下日志,说明MQTT网关启动成功:
|
||||
```
|
||||
0|xz-mqtt | 2025-09-11T12:14:48: MQTT 服务器正在监听端口 1883
|
||||
0|xz-mqtt | 2025-09-11T12:14:48: UDP 服务器正在监听 x.x.x.x:8884
|
||||
```
|
||||
|
||||
如果需要重启MQTT网关,执行如下命令:
|
||||
```
|
||||
pm2 restart xz-mqtt
|
||||
```
|
||||
|
||||
## 第二部分:全模块运行实现小智硬件MQTT+UDP连接
|
||||
|
||||
查看你智控台首页底部的版本号,确认你的智控台版本是否是`0.7.7`及以上版本。如果不是,需要升级智控台。
|
||||
|
||||
1. 在智控台顶部,点击`参数管理`,搜索`server.mqtt_gateway`,点击编辑,填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`MQTT_PORT`。类似这样
|
||||
```
|
||||
192.168.0.7:1883
|
||||
```
|
||||
2. 在智控台顶部,点击`参数管理`,搜索`server.mqtt_signature_key`,点击编辑,填入你在`.env`文件中设置的`MQTT_SIGNATURE_KEY`。
|
||||
|
||||
3. 在智控台顶部,点击`参数管理`,搜索`server.udp_gateway`,点击编辑,填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`UDP_PORT`。类似这样
|
||||
```
|
||||
192.168.0.7:8884
|
||||
```
|
||||
4. 在智控台顶部,点击`参数管理`,搜索`server.mqtt_manager_api`,点击编辑,填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`UDP_PORT`。类似这样
|
||||
```
|
||||
192.168.0.7:8007
|
||||
```
|
||||
|
||||
上面的配置完成后,你可以使用curl命令,验证你的ota地址是否会下发mqtt配置,把下面的`http://localhost:8002/xiaozhi/ota/`改成你的ota地址
|
||||
```
|
||||
curl 'http://localhost:8002/xiaozhi/ota/' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Client-Id: 7b94d69a-9808-4c59-9c9b-704333b38aff' \
|
||||
-H 'Device-Id: 11:22:33:44:55:66' \
|
||||
--data-raw $'{\n "application": {\n "version": "1.0.1",\n "elf_sha256": "1"\n },\n "board": {\n "mac": "11:22:33:44:55:66"\n }\n}'
|
||||
```
|
||||
|
||||
如果返回的内容包含`mqtt`相关的配置,说明配置成功。类似这样
|
||||
|
||||
```
|
||||
{"server_time":{"timestamp":1757567894012,"timeZone":"Asia/Shanghai","timezone_offset":480},"activation":{"code":"460609","message":"http://xiaozhi.server.com\n460609","challenge":"11:22:33:44:55:66"},"firmware":{"version":"1.0.1","url":"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL"},"websocket":{"url":"ws://192.168.4.23:8000/xiaozhi/v1/"},"mqtt":{"endpoint":"192.168.0.7:1883","client_id":"GID_default@@@11_22_33_44_55_66@@@7b94d69a-9808-4c59-9c9b-704333b38aff","username":"eyJpcCI6IjA6MDowOjA6MDowOjA6MSJ9","password":"Y8XP9xcUhVIN9OmbCHT9ETBiYNE3l3Z07Wk46wV9PE8=","publish_topic":"device-server","subscribe_topic":"devices/p2p/11_22_33_44_55_66"}}
|
||||
```
|
||||
|
||||
由于MQTT信息是需要靠OTA地址下发的,因此只有你保证能正常连接服务器的OTA地址,重启唤醒即可。
|
||||
|
||||
唤醒后留意mqtt-gateway的日志,确认是否有连接成功的日志。
|
||||
```
|
||||
pm2 logs xz-mqtt
|
||||
```
|
||||
|
||||
## 第三部分:全模块运行实现小智硬件MQTT+UDP连接
|
||||
|
||||
打开你的`data/.config.yaml`文件,在`server`下找到`mqtt_gateway`填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`MQTT_PORT`。类似这样
|
||||
```
|
||||
192.168.0.7:1883
|
||||
```
|
||||
在`server`下找到`mqtt_signature_key`填入你在`.env`文件中设置的`MQTT_SIGNATURE_KEY`。
|
||||
|
||||
在`server`下找到`udp_gateway`填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`UDP_PORT`。类似这样
|
||||
```
|
||||
192.168.0.7:8884
|
||||
```
|
||||
|
||||
上面的配置完成后,你可以使用curl命令,验证你的ota地址是否会下发mqtt配置,把下面的`http://localhost:8002/xiaozhi/ota/`改成你的ota地址
|
||||
```
|
||||
curl 'http://localhost:8002/xiaozhi/ota/' \
|
||||
-H 'Device-Id: 11:22:33:44:55:66' \
|
||||
--data-raw $'{\n "application": {\n "version": "1.0.1",\n "elf_sha256": "1"\n },\n "board": {\n "mac": "11:22:33:44:55:66"\n }\n}'
|
||||
```
|
||||
|
||||
如果返回的内容包含`mqtt`相关的配置,说明配置成功。类似这样
|
||||
```
|
||||
{"server_time":{"timestamp":1758781561083,"timeZone":"GMT+08:00","timezone_offset":480},"activation":{"code":"527111","message":"http://xiaozhi.server.com\n527111","challenge":"11:22:33:44:55:66"},"firmware":{"version":"1.0.1","url":"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL"},"websocket":{"url":"ws://192.168.1.15:8000/xiaozhi/v1/"},"mqtt":{"endpoint":"192.168.1.15:1883","client_id":"GID_default@@@11_22_33_44_55_66@@@11_22_33_44_55_66","username":"eyJpcCI6IjE5Mi4xNjguMS4xNSJ9","password":"fjAYs49zTJecWqJ3jBt+kqxVn/x7vkXRAc85ak/va7Y=","publish_topic":"device-server","subscribe_topic":"devices/p2p/11_22_33_44_55_66"}}
|
||||
```
|
||||
|
||||
由于MQTT信息是需要靠OTA地址下发的,因此只有你保证能正常连接服务器的OTA地址,重启唤醒即可。
|
||||
|
||||
唤醒后留意mqtt-gateway的日志,确认是否有连接成功的日志。
|
||||
```
|
||||
pm2 logs xz-mqtt
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# PaddleSpeechTTS集成xiaozhi服务
|
||||
|
||||
## 重点说明
|
||||
- 优点:本地离线部署、速度快
|
||||
- 缺点:截止2025年9月25日,默认的模型是中文模型,不支持英文转语音。如果含英文会发不出声音,如需同时支持中英文需要自己训练。
|
||||
|
||||
## 一、基础环境要求
|
||||
操作系统:Windows / Linux / WSL 2
|
||||
|
||||
Python 版本:3.9以上(请根据Paddle官方教程调整)
|
||||
|
||||
Paddle 版本:官方最新版本 ```https://www.paddlepaddle.org.cn/install```
|
||||
|
||||
依赖管理工具:conda 或 venv
|
||||
|
||||
## 二、启动paddlespeech服务
|
||||
### 1.从paddlespeech官方仓库拉取源码
|
||||
```bash
|
||||
git clone https://github.com/PaddlePaddle/PaddleSpeech.git
|
||||
```
|
||||
### 2.建立虚拟环境
|
||||
```bash
|
||||
|
||||
conda create -n paddle_env python=3.10 -y
|
||||
conda activate paddle_env
|
||||
```
|
||||
### 3.安装paddle
|
||||
因CPU架构、GPU架构不同,请根据Paddle官方支持的python版本建立环境
|
||||
```
|
||||
https://www.paddlepaddle.org.cn/install
|
||||
```
|
||||
|
||||
### 4.进入paddlespeech目录
|
||||
```bash
|
||||
cd PaddleSpeech
|
||||
```
|
||||
### 5.安装paddlespeech
|
||||
```bash
|
||||
pip install pytest-runner -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
#以下命令使用任意一个
|
||||
pip install paddlepaddle -i https://mirror.baidu.com/pypi/simple
|
||||
pip install paddlespeech -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
```
|
||||
### 6.使用命令自动下载语音模型
|
||||
```bash
|
||||
paddlespeech tts --input "你好,这是一次测试"
|
||||
```
|
||||
此步骤会自动下载模型缓存至本地 .paddlespeech/models 目录
|
||||
|
||||
### 7.修改tts_online_application.yaml配置
|
||||
参考目录 ```"PaddleSpeech\demos\streaming_tts_server\conf\tts_online_application.yaml"```
|
||||
选择```tts_online_application.yaml```文件用编辑器打开,设置```protocol```为```websocket```
|
||||
|
||||
### 8.启动服务
|
||||
```yaml
|
||||
paddlespeech_server start --config_file ./demos/streaming_tts_server/conf/tts_online_application.yaml
|
||||
#官方默认启动命令:
|
||||
paddlespeech_server start --config_file ./conf/tts_online_application.yaml
|
||||
```
|
||||
请根据你的```tts_online_application.yaml```的实际目录来启动命令,看到如下日志即启动成功
|
||||
```
|
||||
Prefix dict has been built successfully.
|
||||
[2025-08-07 10:03:11,312] [ DEBUG] __init__.py:166 - Prefix dict has been built successfully.
|
||||
INFO: Started server process [2298]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8092 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
## 三、修改小智的配置文件
|
||||
### 1.```main/xiaozhi-server/core/providers/tts/paddle_speech.py```
|
||||
|
||||
### 2.```main/xiaozhi-server/data/.config.yaml```
|
||||
使用单模块部署
|
||||
```yaml
|
||||
selected_module:
|
||||
TTS: PaddleSpeechTTS
|
||||
TTS:
|
||||
PaddleSpeechTTS:
|
||||
type: paddle_speech
|
||||
protocol: websocket
|
||||
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务的 URL 地址,指向本地服务器 [websocket默认ws://127.0.0.1:8092/paddlespeech/tts/streaming]
|
||||
spk_id: 0 # 发音人 ID,0 通常表示默认的发音人
|
||||
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
||||
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||
save_path: # 保存路径
|
||||
```
|
||||
### 3.启动xiaozhi服务
|
||||
```py
|
||||
python app.py
|
||||
```
|
||||
打开test目录下的test_page.html,测试连接和发送消息时paddlespeech端是否有输出日志
|
||||
|
||||
输出日志参考:
|
||||
```
|
||||
INFO: 127.0.0.1:44312 - "WebSocket /paddlespeech/tts/streaming" [accepted]
|
||||
INFO: connection open
|
||||
[2025-08-07 11:16:33,355] [ INFO] - sentence: 哈哈,怎么突然找我聊天啦?
|
||||
[2025-08-07 11:16:33,356] [ INFO] - The durations of audio is: 2.4625 s
|
||||
[2025-08-07 11:16:33,356] [ INFO] - first response time: 0.1143045425415039 s
|
||||
[2025-08-07 11:16:33,356] [ INFO] - final response time: 0.4777836799621582 s
|
||||
[2025-08-07 11:16:33,356] [ INFO] - RTF: 0.19402382942625715
|
||||
[2025-08-07 11:16:33,356] [ INFO] - Other info: front time: 0.06514096260070801 s, first am infer time: 0.008037090301513672 s, first voc infer time: 0.04112648963928223 s,
|
||||
[2025-08-07 11:16:33,356] [ INFO] - Complete the synthesis of the audio streams
|
||||
INFO: connection closed
|
||||
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# 语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南
|
||||
|
||||
1.在main/xiaozhi-server目录下创建data目录
|
||||
2.在data目录下创建.config.yaml文件
|
||||
3.在.data/config.yaml中,写入你的语音识别、大语言模型、流式语音合成、视觉模型的参数
|
||||
例如:
|
||||
```
|
||||
LLM:
|
||||
ChatGLMLLM:
|
||||
# 定义LLM API类型
|
||||
type: openai
|
||||
# glm-4-flash 是免费的,但是还是需要注册填写api_key的
|
||||
# 可在这里找到你的api key https://bigmodel.cn/usercenter/proj-mgmt/apikeys
|
||||
model_name: glm-4-flash
|
||||
url: https://open.bigmodel.cn/api/paas/v4/
|
||||
api_key: 你的chat-glm web key
|
||||
|
||||
TTS:
|
||||
|
||||
VLLM:
|
||||
|
||||
ASR:
|
||||
```
|
||||
4.在main/xiaozhi-server目录下运行performance_tester.py:
|
||||
```
|
||||
python performance_tester.py
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# 天气插件使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
天气插件 `get_weather` 是小智ESP32语音助手的核心功能之一,支持通过语音查询全国各地的天气信息。插件基于和风天气API,提供实时天气和7天天气预报功能。
|
||||
|
||||
## API Key 申请指南
|
||||
|
||||
### 1. 注册和风天气账号
|
||||
|
||||
1. 访问 [和风天气控制台](https://console.qweather.com/)
|
||||
2. 注册账号并完成邮箱验证
|
||||
3. 登录控制台
|
||||
|
||||
### 2. 创建应用获取API Key
|
||||
|
||||
1. 进入控制台后,点击右侧["项目管理"](https://console.qweather.com/project?lang=zh) → "创建项目"
|
||||
2. 填写项目信息:
|
||||
- **项目名称**:如"小智语音助手"
|
||||
3. 点击保存
|
||||
4. 项目创建完成后,在该项目中点击"创建凭据"
|
||||
5. 填写凭据信息:
|
||||
- **凭据名称**:如"小智语音助手"
|
||||
- **身份认证方式**:选择"API Key"
|
||||
6. 点击保存
|
||||
7. 在凭据中复制`API Key`,这是第一个关键的配置信息
|
||||
|
||||
### 3. 获取API Host
|
||||
|
||||
1. 在控制台中点击["设置"](https://console.qweather.com/setting?lang=zh) → "API Host"
|
||||
2. 查看分配给你的专属`API Host`地址,这个是第二个关键的配置信息
|
||||
|
||||
以上操作,会得到两个重要的配置信息:`API Key`和`API Host`
|
||||
|
||||
## 配置方式(任选一种)
|
||||
|
||||
### 方式1. 如果你使用了智控台部署(推荐)
|
||||
|
||||
1. 登录智控台
|
||||
2. 进入"角色配置"页面
|
||||
3. 选择要配置的智能体
|
||||
4. 点击"编辑功能"按钮
|
||||
5. 在右侧参数配置区域找到"天气查询"插件
|
||||
6. 勾选"天气查询"
|
||||
7. 将复制过来的第一个关键配置`API Key`,填入到`天气插件 API 密钥`里
|
||||
8. 将复制过来的第二个关键配置`API Host`,填入到`开发者 API Host`里
|
||||
9. 保存配置,再保存智能体配置
|
||||
|
||||
### 方式2. 如果你只是单模块xiaozhi-server部署
|
||||
|
||||
在 `data/.config.yaml` 中配置:
|
||||
|
||||
1. 将复制过来的第一个关键配置`API Key`,填入到`api_key`里
|
||||
2. 将复制过来的第二个关键配置`API Host`,填入到`api_host`里
|
||||
3. 将你所在的城市填入到`default_location`里,例如`广州`
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
get_weather:
|
||||
api_key: "你的和风天气API密钥"
|
||||
api_host: "你的和风天气API主机地址"
|
||||
default_location: "你的默认查询城市"
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* [3.1. `xiaozhi-server` (核心AI引擎 - Python实现)](#31-xiaozhi-server-核心ai引擎---python实现)
|
||||
* [3.2. `manager-api` (管理后端 - Java Spring Boot实现)](#32-manager-api-管理后端---java-spring-boot实现)
|
||||
* [3.3. `manager-web` (Web管理前端 - Vue.js实现)](#33-manager-web-web管理前端---vuejs实现)
|
||||
* [3.4. `manager-mobile` (移动管理端 - uni-app+Vue3实现)](#34-manager-mobile-移动管理端---uni-appvue3实现)
|
||||
4. [数据流与交互机制](#4-数据流与交互机制)
|
||||
5. [核心功能概要](#5-核心功能概要)
|
||||
6. [部署与配置概述](#6-部署与配置概述)
|
||||
@@ -58,6 +59,14 @@
|
||||
* (潜在功能)监控系统运行状态、查看日志、进行故障排查等。
|
||||
* 与 `manager-api` 提供的所有后端管理功能进行全面的交互。
|
||||
|
||||
5. **`manager-mobile` (智控台移动版 - uni-app实现):**
|
||||
这是一个基于uni-app v3 + Vue 3 + Vite的跨端移动管理端,支持App(Android & iOS)和微信小程序。其主要能力包括:
|
||||
* 提供移动设备上的便捷管理界面,与manager-web功能类似但针对移动端进行了优化。
|
||||
* 支持用户登录、设备管理、AI服务配置等核心功能。
|
||||
* 跨平台适配,一套代码可同时运行在iOS、Android和微信小程序上。
|
||||
* 基于alova + @alova/adapter-uniapp实现网络请求,与manager-api无缝集成。
|
||||
* 使用pinia进行状态管理,确保数据一致性。
|
||||
|
||||
**高层交互流程概述:**
|
||||
|
||||
* **语音交互主线:** **ESP32设备**捕捉到用户语音后,通过**WebSocket**将音频数据实时传输给**`xiaozhi-server`**。`xiaozhi-server`完成一系列AI处理(VAD、ASR、LLM交互、TTS)后,再通过WebSocket将合成的语音回复发送回ESP32设备进行播放。所有与语音直接相关的实时交互均在此链路完成。
|
||||
@@ -71,6 +80,7 @@ xiaozhi-esp32-server
|
||||
├─ xiaozhi-server 8000 端口 Python语言开发 负责与esp32通信
|
||||
├─ manager-web 8001 端口 Node.js+Vue开发 负责提供控制台的web界面
|
||||
├─ manager-api 8002 端口 Java语言开发 负责提供控制台的api
|
||||
└─ manager-mobile 跨平台移动应用 uni-app+Vue3开发 负责提供移动版智控台管理
|
||||
```
|
||||
|
||||
---
|
||||
@@ -294,6 +304,68 @@ xiaozhi-esp32-server
|
||||
|
||||
---
|
||||
|
||||
### 3.4. `manager-mobile` (智控台移动版 - uni-app实现)
|
||||
|
||||
`manager-mobile` 组件是一个基于uni-app v3 + Vue 3 + Vite的跨端移动管理端,支持App(Android & iOS)和微信小程序。它为系统管理员提供了移动端的管理界面,使得管理操作更加便捷。
|
||||
|
||||
* **核心目标:**
|
||||
* 提供移动设备上的便捷管理界面,与manager-web功能类似但针对移动端进行了优化。
|
||||
* 支持用户登录、设备管理、AI服务配置等核心功能。
|
||||
* 跨平台适配,一套代码可同时运行在iOS、Android和微信小程序上。
|
||||
* 为移动用户提供流畅、高效的管理体验。
|
||||
|
||||
* **平台兼容性:**
|
||||
|
||||
| H5 | iOS | Android | 微信小程序 |
|
||||
| -- | --- | ------- | ---------- |
|
||||
| √ | √ | √ | √ |
|
||||
|
||||
* **核心技术栈:**
|
||||
* **uni-app v3:** 一个使用Vue.js开发所有前端应用的框架,支持iOS、Android、H5、以及各种小程序。
|
||||
* **Vue 3:** 用于构建用户界面的渐进式框架,提供了更好的性能和新特性。
|
||||
* **Vite:** 下一代前端开发与构建工具,提供极速的开发体验。
|
||||
* **pnpm:** 快速、节省磁盘空间的包管理器。
|
||||
* **alova:** 轻量级、灵活的请求策略库,搭配@alova/adapter-uniapp适配uni-app环境。
|
||||
* **pinia:** Vue的状态管理库,替代Vuex,提供更简洁的API和更好的TypeScript支持。
|
||||
* **UnoCSS:** 具有高性能且极具灵活性的即时原子化CSS引擎。
|
||||
* **TypeScript:** 提供类型安全的开发体验。
|
||||
|
||||
* **关键实现细节:**
|
||||
|
||||
1. **跨平台架构:**
|
||||
* 基于uni-app框架,实现了一套代码多端运行的目标,大幅减少了开发和维护成本。
|
||||
* 针对不同平台的特性和限制,通过条件编译进行平台特定的代码处理。
|
||||
|
||||
2. **项目结构:**
|
||||
* **`src/App.vue`:** 应用的根组件,定义了全局的样式和配置。
|
||||
* **`src/main.ts`:** 应用的入口文件,负责初始化Vue实例、注册插件和路由拦截器。
|
||||
* **`src/pages/`:** 存放应用的页面组件,如登录页、设备管理页等。
|
||||
* **`src/layouts/`:** 定义应用的布局组件,如默认布局、带tabbar的布局等。
|
||||
* **`src/api/`:** 封装与后端API的通信逻辑。
|
||||
* **`src/store/`:** 使用pinia进行状态管理。
|
||||
* **`src/components/`:** 存放可复用的组件。
|
||||
* **`src/utils/`:** 提供通用的工具函数。
|
||||
|
||||
3. **网络请求:**
|
||||
* 基于alova + @alova/adapter-uniapp实现网络请求,统一处理请求头、认证、错误等。
|
||||
* 请求地址和环境配置通过.env文件管理,支持不同环境的切换。
|
||||
|
||||
4. **路由与鉴权:**
|
||||
* 使用uni-app的路由系统,结合路由拦截器实现页面的登录验证和权限控制。
|
||||
* 未登录用户访问需要认证的页面时,会被重定向到登录页。
|
||||
|
||||
5. **状态管理:**
|
||||
* 使用pinia管理应用状态,如用户信息、设备列表等。
|
||||
* 通过pinia-plugin-persistedstate插件实现状态的持久化存储。
|
||||
|
||||
6. **构建与发布:**
|
||||
* 支持多种构建命令,如构建微信小程序、Android和iOS App等。
|
||||
* 使用HBuilderX进行App的云打包,简化了打包流程。
|
||||
|
||||
`manager-mobile` 通过这些技术的应用,为用户提供了一个功能完备、体验流畅的移动端管理工具,使得管理员可以随时随地进行系统管理和配置。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据流与交互机制
|
||||
|
||||
`xiaozhi-esp32-server` 系统通过各组件间定义清晰的数据流和交互协议来协同工作。主要的通信方式依赖于针对实时交互优化的WebSocket协议和适用于客户端-服务器请求的RESTful API。
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* [3.1. `xiaozhi-server` (Core AI Engine - Python Implementation)](#31-xiaozhi-server-core-ai-engine---python-implementation)
|
||||
* [3.2. `manager-api` (Management Backend - Java Spring Boot Implementation)](#32-manager-api-management-backend---java-spring-boot-implementation)
|
||||
* [3.3. `manager-web` (Web Management Frontend - Vue.js Implementation)](#33-manager-web-web-management-frontend---vuejs-implementation)
|
||||
* [3.4. `manager-mobile` (Mobile Management Console - uni-app Implementation)](#34-manager-mobile-mobile-management-console---uniapp-implementation)
|
||||
4. [Data Flow and Interaction Mechanisms](#4-data-flow-and-interaction-mechanisms)
|
||||
5. [Key Features Summary](#5-key-features-summary)
|
||||
6. [Deployment and Configuration Overview](#6-deployment-and-configuration-overview)
|
||||
@@ -72,6 +73,7 @@ xiaozhi-esp32-server
|
||||
├─ xiaozhi-server Port 8000 Python development Responsible for ESP32 communication
|
||||
├─ manager-web Port 8001 Node.js+Vue development Responsible for providing web interface for console
|
||||
├─ manager-api Port 8002 Java development Responsible for providing console API
|
||||
└─ manager-mobile Cross-platform mobile application uni-app+Vue3 development Responsible for providing mobile console management
|
||||
```
|
||||
|
||||
---
|
||||
@@ -190,7 +192,70 @@ The `manager-web` is a Single Page Application (SPA) providing the administrativ
|
||||
4. **State Management (`src/store/index.js`):** Vuex manages global state (user info, device lists, etc.) via state, getters, mutations, and actions (often involving API calls).
|
||||
5. **API Communication (`src/apis/`):** Modularized API service files make asynchronous calls to `manager-api`.
|
||||
6. **Build Process & PWA Features:** Vue CLI (Webpack) bundles assets. Workbox enables PWA features like caching.
|
||||
7. **Environment Configuration (`.env` files):** Manages settings like the `manager-api` base URL for different environments.
|
||||
7. **Environment Configuration (`.env` files):**
|
||||
* The `.env` (and `.env.development`, `.env.production`, etc.) files in the project root directory are used to define environment variables. These variables (such as `VUE_APP_API_BASE_URL` to specify the base URL of `manager-api`) can be accessed in the application code through `process.env.VUE_APP_XXX`, allowing configuration of different parameters for different build environments (development, testing, production).
|
||||
|
||||
`manager-web` constructs a powerful, maintainable, and user-friendly management interface through the comprehensive application of these technologies, providing solid frontend support for the configuration and monitoring of the `xiaozhi-esp32-server` system.
|
||||
|
||||
### 3.4. `manager-mobile` (Mobile Management Console - uni-app Implementation)
|
||||
|
||||
The `manager-mobile` component is a cross-platform mobile management application based on uni-app v3 + Vue 3 + Vite, supporting App (Android & iOS) and WeChat Mini Program. It provides system administrators with a mobile management interface, making management operations more convenient.
|
||||
|
||||
* **Core Objectives:**
|
||||
* Provide a convenient management interface on mobile devices, similar in functionality to manager-web but optimized for mobile platforms.
|
||||
* Support core functions such as user login, device management, and AI service configuration.
|
||||
* Cross-platform adaptation, allowing a single codebase to run on iOS, Android, and WeChat Mini Programs.
|
||||
* Provide mobile users with a smooth and efficient management experience.
|
||||
|
||||
* **Platform Compatibility:**
|
||||
|
||||
| H5 | iOS | Android | WeChat Mini Program |
|
||||
| -- | --- | ------- | ------------------ |
|
||||
| × | √ | √ | √ |
|
||||
|
||||
* **Core Technologies:**
|
||||
* **uni-app v3:** A framework for developing all frontend applications using Vue.js, supporting iOS, Android, H5, and various mini-programs.
|
||||
* **Vue 3:** A progressive framework for building user interfaces, providing better performance and new features.
|
||||
* **Vite:** The next generation frontend development and build tool, offering an extremely fast development experience.
|
||||
* **pnpm:** A fast, disk space-efficient package manager.
|
||||
* **alova:** A lightweight, flexible request strategy library, paired with @alova/adapter-uniapp to adapt to the uni-app environment.
|
||||
* **pinia:** State management library for Vue, replacing Vuex, providing a simpler API and better TypeScript support.
|
||||
* **UnoCSS:** A high-performance and extremely flexible instant atomic CSS engine.
|
||||
* **TypeScript:** Provides a type-safe development experience.
|
||||
|
||||
* **Key Implementation Details:**
|
||||
|
||||
1. **Cross-Platform Architecture:**
|
||||
* Based on the uni-app framework, achieving the goal of "write once, run anywhere," significantly reducing development and maintenance costs.
|
||||
* Handling platform-specific code through conditional compilation to address the characteristics and limitations of different platforms.
|
||||
|
||||
2. **Project Structure:**
|
||||
* **`src/App.vue`:** The root component of the application, defining global styles and configurations.
|
||||
* **`src/main.ts`:** The entry file of the application, responsible for initializing the Vue instance, registering plugins, and setting up route interceptors.
|
||||
* **`src/pages/`:** Stores application page components, such as login pages, device management pages, etc.
|
||||
* **`src/layouts/`:** Defines application layout components, such as default layouts, layouts with tabbar, etc.
|
||||
* **`src/api/`:** Encapsulates communication logic with backend APIs.
|
||||
* **`src/store/`:** Uses pinia for state management.
|
||||
* **`src/components/`:** Stores reusable components.
|
||||
* **`src/utils/`:** Provides common utility functions.
|
||||
|
||||
3. **Network Requests:**
|
||||
* Implements network requests based on alova + @alova/adapter-uniapp, unified handling of request headers, authentication, errors, etc.
|
||||
* Request addresses and environment configurations are managed through .env files, supporting switching between different environments.
|
||||
|
||||
4. **Routing and Authentication:**
|
||||
* Uses uni-app's routing system, combined with route interceptors to implement page login verification and permission control.
|
||||
* When unlogged users access pages requiring authentication, they are redirected to the login page.
|
||||
|
||||
5. **State Management:**
|
||||
* Uses pinia to manage application state, such as user information, device lists, etc.
|
||||
* Implements persistent storage of state through the pinia-plugin-persistedstate plugin.
|
||||
|
||||
6. **Build and Release:**
|
||||
* Supports multiple build commands, such as building WeChat Mini Programs, Android, and iOS Apps.
|
||||
* Uses HBuilderX for cloud packaging of Apps, simplifying the packaging process.
|
||||
|
||||
`manager-mobile` provides users with a fully functional, smooth mobile management tool through the application of these technologies, allowing administrators to perform system management and configuration anytime, anywhere.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<hutool.version>5.8.24</hutool.version>
|
||||
<jsoup.version>1.19.1</jsoup.version>
|
||||
<knife4j.version>4.6.0</knife4j.version>
|
||||
<springdoc.version>2.8.8</springdoc.version>
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<shiro.version>2.0.2</shiro.version>
|
||||
<captcha.version>1.6.2</captcha.version>
|
||||
<guava.version>33.0.0-jre</guava.version>
|
||||
@@ -75,6 +77,7 @@
|
||||
<version>${captcha.version}</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
@@ -190,11 +193,30 @@
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>${jsoup.version}</version>
|
||||
</dependency>
|
||||
<!--knife4j -->
|
||||
<dependency>
|
||||
<groupId>com.github.xingfudeshi</groupId>
|
||||
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
|
||||
<version>${knife4j.version}</version>
|
||||
</dependency>
|
||||
<!-- BouncyCastle SM2加密 -->
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.78</version>
|
||||
</dependency>
|
||||
<!-- springdoc -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
<!-- 日常工具包 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>${commons-lang3.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -86,11 +86,26 @@ public interface Constant {
|
||||
*/
|
||||
String SERVER_SECRET = "server.secret";
|
||||
|
||||
/**
|
||||
* SM2公钥
|
||||
*/
|
||||
String SM2_PUBLIC_KEY = "server.public_key";
|
||||
|
||||
/**
|
||||
* SM2私钥
|
||||
*/
|
||||
String SM2_PRIVATE_KEY = "server.private_key";
|
||||
|
||||
/**
|
||||
* websocket地址
|
||||
*/
|
||||
String SERVER_WEBSOCKET = "server.websocket";
|
||||
|
||||
/**
|
||||
* mqtt gateway 配置
|
||||
*/
|
||||
String SERVER_MQTT_GATEWAY = "server.mqtt_gateway";
|
||||
|
||||
/**
|
||||
* ota地址
|
||||
*/
|
||||
@@ -121,11 +136,21 @@ public interface Constant {
|
||||
*/
|
||||
String SERVER_VOICE_PRINT = "server.voice_print";
|
||||
|
||||
/**
|
||||
* mqtt密钥
|
||||
*/
|
||||
String SERVER_MQTT_SECRET = "server.mqtt_signature_key";
|
||||
|
||||
/**
|
||||
* 无记忆
|
||||
*/
|
||||
String MEMORY_NO_MEM = "Memory_nomem";
|
||||
|
||||
/**
|
||||
* 火山引擎双声道语音克隆
|
||||
*/
|
||||
String VOICE_CLONE_HUOSHAN_DOUBLE_STREAM = "huoshan_double_stream";
|
||||
|
||||
enum SysBaseParam {
|
||||
/**
|
||||
* ICP备案号
|
||||
@@ -151,6 +176,38 @@ public interface Constant {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 训练状态
|
||||
*/
|
||||
enum TrainStatus {
|
||||
/**
|
||||
* 未训练
|
||||
*/
|
||||
NOT_TRAINED(0),
|
||||
/**
|
||||
* 训练中
|
||||
*/
|
||||
TRAINING(1),
|
||||
/**
|
||||
* 已训练
|
||||
*/
|
||||
TRAINED(2),
|
||||
/**
|
||||
* 训练失败
|
||||
*/
|
||||
TRAIN_FAILED(3);
|
||||
|
||||
private final int code;
|
||||
|
||||
TrainStatus(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统短信
|
||||
*/
|
||||
@@ -237,7 +294,7 @@ public interface Constant {
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.7.3";
|
||||
public static final String VERSION = "0.8.6";
|
||||
|
||||
/**
|
||||
* 无效固件URL
|
||||
|
||||
@@ -55,4 +55,147 @@ public interface ErrorCode {
|
||||
|
||||
int OTA_DEVICE_NOT_FOUND = 10041;
|
||||
int OTA_DEVICE_NEED_BIND = 10042;
|
||||
|
||||
// 新增错误编码
|
||||
int DELETE_DATA_FAILED = 10043;
|
||||
int USER_NOT_LOGIN = 10044;
|
||||
int WEB_SOCKET_CONNECT_FAILED = 10045;
|
||||
int VOICE_PRINT_SAVE_ERROR = 10046;
|
||||
int TODAY_SMS_LIMIT_REACHED = 10047;
|
||||
int OLD_PASSWORD_ERROR = 10048;
|
||||
int INVALID_LLM_TYPE = 10049;
|
||||
int TOKEN_GENERATE_ERROR = 10050;
|
||||
int RESOURCE_NOT_FOUND = 10051;
|
||||
|
||||
// 新增错误编码
|
||||
int DEFAULT_AGENT_NOT_FOUND = 10052;
|
||||
int AGENT_NOT_FOUND = 10053;
|
||||
int VOICEPRINT_API_NOT_CONFIGURED = 10054;
|
||||
int SMS_SEND_FAILED = 10055;
|
||||
int SMS_CONNECTION_FAILED = 10056;
|
||||
int AGENT_VOICEPRINT_CREATE_FAILED = 10057;
|
||||
int AGENT_VOICEPRINT_UPDATE_FAILED = 10058;
|
||||
int AGENT_VOICEPRINT_DELETE_FAILED = 10059;
|
||||
int SMS_SEND_TOO_FREQUENTLY = 10060;
|
||||
int ACTIVATION_CODE_EMPTY = 10061;
|
||||
int ACTIVATION_CODE_ERROR = 10062;
|
||||
int DEVICE_ALREADY_ACTIVATED = 10063;
|
||||
// 默认模型删除错误
|
||||
int DEFAULT_MODEL_DELETE_ERROR = 10064;
|
||||
// 登录相关错误码
|
||||
int ADD_DATA_FAILED = 10065; // 新增数据失败
|
||||
int UPDATE_DATA_FAILED = 10066; // 修改数据失败
|
||||
int SMS_CAPTCHA_ERROR = 10067; // 短信验证码错误
|
||||
int MOBILE_REGISTER_DISABLED = 10068; // 未开启手机注册
|
||||
int USERNAME_NOT_PHONE = 10069; // 用户名不是手机号码
|
||||
int PHONE_ALREADY_REGISTERED = 10070; // 手机号码已注册
|
||||
int PHONE_NOT_REGISTERED = 10071; // 手机号码未注册
|
||||
int USER_REGISTER_DISABLED = 10072; // 不允许用户注册
|
||||
int RETRIEVE_PASSWORD_DISABLED = 10073; // 未开启找回密码功能
|
||||
int PHONE_FORMAT_ERROR = 10074; // 手机号码格式不正确
|
||||
int SMS_CODE_ERROR = 10075; // 手机验证码错误
|
||||
|
||||
// 字典类型相关错误码
|
||||
int DICT_TYPE_NOT_EXIST = 10076; // 字典类型不存在
|
||||
int DICT_TYPE_DUPLICATE = 10077; // 字典类型编码重复
|
||||
|
||||
// 资源处理相关错误码
|
||||
int RESOURCE_READ_ERROR = 10078; // 读取资源失败
|
||||
|
||||
// 智能体相关错误码
|
||||
int LLM_INTENT_PARAMS_MISMATCH = 10079; // LLM大模型和Intent意图识别,选择参数不匹配
|
||||
|
||||
// 声纹相关错误码
|
||||
int VOICEPRINT_ALREADY_REGISTERED = 10080; // 此声音声纹已经注册
|
||||
int VOICEPRINT_DELETE_ERROR = 10081; // 删除声纹出现错误
|
||||
int VOICEPRINT_UPDATE_NOT_ALLOWED = 10082; // 声纹修改不允许,声音已注册
|
||||
int VOICEPRINT_UPDATE_ADMIN_ERROR = 10083; // 修改声纹错误,请联系管理员
|
||||
int VOICEPRINT_API_URI_ERROR = 10084; // 声纹接口地址错误
|
||||
int VOICEPRINT_AUDIO_NOT_BELONG_AGENT = 10085; // 音频数据不属于智能体
|
||||
int VOICEPRINT_AUDIO_EMPTY = 10086; // 音频数据为空
|
||||
int VOICEPRINT_REGISTER_REQUEST_ERROR = 10087; // 声纹保存请求失败
|
||||
int VOICEPRINT_REGISTER_PROCESS_ERROR = 10088; // 声纹保存处理失败
|
||||
int VOICEPRINT_UNREGISTER_REQUEST_ERROR = 10089; // 声纹注销请求失败
|
||||
int VOICEPRINT_UNREGISTER_PROCESS_ERROR = 10090; // 声纹注销处理失败
|
||||
int VOICEPRINT_IDENTIFY_REQUEST_ERROR = 10091; // 声纹识别请求失败
|
||||
|
||||
// 设备相关错误码
|
||||
int MAC_ADDRESS_ALREADY_EXISTS = 10161; // Mac地址已存在
|
||||
// 模型相关错误码
|
||||
int MODEL_PROVIDER_NOT_EXIST = 10162; // 供应器不存在
|
||||
int LLM_NOT_EXIST = 10092; // 设置的LLM不存在
|
||||
int MODEL_REFERENCED_BY_AGENT = 10093; // 该模型配置已被智能体引用,无法删除
|
||||
int LLM_REFERENCED_BY_INTENT = 10094; // 该LLM模型已被意图识别配置引用,无法删除
|
||||
|
||||
// 服务端管理相关错误码
|
||||
int INVALID_SERVER_ACTION = 10095; // 无效服务端操作
|
||||
int SERVER_WEBSOCKET_NOT_CONFIGURED = 10096; // 未配置服务端WebSocket地址
|
||||
int TARGET_WEBSOCKET_NOT_EXIST = 10097; // 目标WebSocket地址不存在
|
||||
|
||||
// 参数验证相关错误码
|
||||
int WEBSOCKET_URLS_EMPTY = 10098; // WebSocket地址列表不能为空
|
||||
int WEBSOCKET_URL_LOCALHOST = 10099; // WebSocket地址不能使用localhost或127.0.0.1
|
||||
int WEBSOCKET_URL_FORMAT_ERROR = 10100; // WebSocket地址格式不正确
|
||||
int WEBSOCKET_CONNECTION_FAILED = 10101; // WebSocket连接测试失败
|
||||
int OTA_URL_EMPTY = 10102; // OTA地址不能为空
|
||||
int OTA_URL_LOCALHOST = 10103; // OTA地址不能使用localhost或127.0.0.1
|
||||
int OTA_URL_PROTOCOL_ERROR = 10104; // OTA地址必须以http或https开头
|
||||
int OTA_URL_FORMAT_ERROR = 10105; // OTA地址必须以/ota/结尾
|
||||
int OTA_INTERFACE_ACCESS_FAILED = 10106; // OTA接口访问失败
|
||||
int OTA_INTERFACE_FORMAT_ERROR = 10107; // OTA接口返回内容格式不正确
|
||||
int OTA_INTERFACE_VALIDATION_FAILED = 10108; // OTA接口验证失败
|
||||
int MCP_URL_EMPTY = 10109; // MCP地址不能为空
|
||||
int MCP_URL_LOCALHOST = 10110; // MCP地址不能使用localhost或127.0.0.1
|
||||
int MCP_URL_INVALID = 10111; // 不是正确的MCP地址
|
||||
int MCP_INTERFACE_ACCESS_FAILED = 10112; // MCP接口访问失败
|
||||
int MCP_INTERFACE_FORMAT_ERROR = 10113; // MCP接口返回内容格式不正确
|
||||
int MCP_INTERFACE_VALIDATION_FAILED = 10114; // MCP接口验证失败
|
||||
int VOICEPRINT_URL_EMPTY = 10115; // 声纹接口地址不能为空
|
||||
int VOICEPRINT_URL_LOCALHOST = 10116; // 声纹接口地址不能使用localhost或127.0.0.1
|
||||
int VOICEPRINT_URL_INVALID = 10117; // 不是正确的声纹接口地址
|
||||
int VOICEPRINT_URL_PROTOCOL_ERROR = 10118; // 声纹接口地址必须以http或https开头
|
||||
int VOICEPRINT_INTERFACE_ACCESS_FAILED = 10119; // 声纹接口访问失败
|
||||
int VOICEPRINT_INTERFACE_FORMAT_ERROR = 10120; // 声纹接口返回内容格式不正确
|
||||
int VOICEPRINT_INTERFACE_VALIDATION_FAILED = 10121; // 声纹接口验证失败
|
||||
int MQTT_SECRET_EMPTY = 10122; // mqtt密钥不能为空
|
||||
int MQTT_SECRET_LENGTH_INSECURE = 10123; // mqtt密钥长度不安全
|
||||
int MQTT_SECRET_CHARACTER_INSECURE = 10124; // mqtt密钥必须同时包含大小写字母
|
||||
int MQTT_SECRET_WEAK_PASSWORD = 10125; // mqtt密钥包含弱密码
|
||||
int DICT_LABEL_DUPLICATE = 10128; // 字典标签重复
|
||||
int SM2_KEY_NOT_CONFIGURED = 10129; // SM2密钥未配置
|
||||
int SM2_DECRYPT_ERROR = 10130; // SM2解密失败
|
||||
int MODEL_TYPE_PROVIDE_CODE_NOT_NULL = 10131; // modelType和provideCode不能为空
|
||||
|
||||
// 聊天记录相关错误码
|
||||
int CHAT_HISTORY_NO_PERMISSION = 10132; // 没有权限查看该智能体的聊天记录
|
||||
int CHAT_HISTORY_SESSION_ID_NOT_NULL = 10133; // 会话ID不能为空
|
||||
int CHAT_HISTORY_AGENT_ID_NOT_NULL = 10134; // 智能体ID不能为空
|
||||
int CHAT_HISTORY_DOWNLOAD_FAILED = 10135; // 聊天记录下载失败
|
||||
int DOWNLOAD_LINK_EXPIRED = 10136; // 下载链接已过期或无效
|
||||
int DOWNLOAD_LINK_INVALID = 10137; // 下载链接无效
|
||||
int CHAT_ROLE_USER = 10138; // 用户角色
|
||||
int CHAT_ROLE_AGENT = 10139; // 智能体角色
|
||||
|
||||
// 声音克隆相关错误码
|
||||
int VOICE_CLONE_AUDIO_EMPTY = 10140; // 音频文件不能为空
|
||||
int VOICE_CLONE_NOT_AUDIO_FILE = 10141; // 只支持音频文件
|
||||
int VOICE_CLONE_AUDIO_TOO_LARGE = 10142; // 音频文件大小不能超过10MB
|
||||
int VOICE_CLONE_UPLOAD_FAILED = 10143; // 上传失败
|
||||
int VOICE_CLONE_RECORD_NOT_EXIST = 10144; // 声音克隆记录不存在
|
||||
int VOICE_RESOURCE_INFO_EMPTY = 10145; // 音色资源信息不能为空
|
||||
int VOICE_RESOURCE_PLATFORM_NAME_EMPTY = 10146; // 平台名称不能为空
|
||||
int VOICE_RESOURCE_ID_EMPTY = 10147; // 音色ID不能为空
|
||||
int VOICE_RESOURCE_ACCOUNT_EMPTY = 10148; // 归属账号不能为空
|
||||
int VOICE_RESOURCE_DELETE_ID_EMPTY = 10149; // 删除的音色资源ID不能为空
|
||||
int VOICE_RESOURCE_NO_PERMISSION = 10150; // 您没有权限操作该记录
|
||||
int VOICE_CLONE_AUDIO_NOT_UPLOADED = 10151; // 请先上传音频文件
|
||||
int VOICE_CLONE_MODEL_CONFIG_NOT_FOUND = 10152; // 模型配置未找到
|
||||
int VOICE_CLONE_MODEL_TYPE_NOT_FOUND = 10153; // 模型类型未找到
|
||||
int VOICE_CLONE_TRAINING_FAILED = 10154; // 训练失败
|
||||
int VOICE_CLONE_HUOSHAN_CONFIG_MISSING = 10155; // 火山引擎缺少配置
|
||||
int VOICE_CLONE_RESPONSE_FORMAT_ERROR = 10156; // 响应格式错误
|
||||
int VOICE_CLONE_REQUEST_FAILED = 10157; // 请求失败
|
||||
int VOICE_CLONE_PREFIX = 10158; // 复刻音色前缀
|
||||
int VOICE_ID_ALREADY_EXISTS = 10159; // 音色ID已存在
|
||||
int VOICE_CLONE_HUOSHAN_VOICE_ID_ERROR = 10160; // 火山引擎音色ID格式错误
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ public class RenExceptionHandler {
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public Result<Void> handleNoResourceFoundException(NoResourceFoundException ex) {
|
||||
log.warn("Resource not found: {}", ex.getMessage());
|
||||
return new Result<Void>().error(404, "资源不存在");
|
||||
return new Result<Void>().error(404, MessageUtils.getMessage(ErrorCode.RESOURCE_NOT_FOUND));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
@@ -76,7 +77,7 @@ public class RenExceptionHandler {
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse("请求参数错误!");
|
||||
.orElse(MessageUtils.getMessage(ErrorCode.PARAM_VALUE_NULL));
|
||||
|
||||
return new Result<Void>().error(ErrorCode.PARAM_VALUE_NULL, errorMsg);
|
||||
}
|
||||
|
||||
@@ -139,4 +139,17 @@ public class RedisKeys {
|
||||
return "sms:Validate:Code:" + phone + ":today_count";
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天记录UUID映射的Key
|
||||
*/
|
||||
public static String getChatHistoryKey(String uuid) {
|
||||
return "agent:chat:history:" + uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音色克隆音频ID的缓存key
|
||||
*/
|
||||
public static String getVoiceCloneAudioIdKey(String uuid) {
|
||||
return "voiceClone:audio:id:" + uuid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,15 @@ import org.springframework.context.i18n.LocaleContextHolder;
|
||||
public class MessageUtils {
|
||||
private static MessageSource messageSource;
|
||||
|
||||
static {
|
||||
messageSource = (MessageSource) SpringContextUtils.getBean("messageSource");
|
||||
}
|
||||
|
||||
public static String getMessage(int code) {
|
||||
return getMessage(code, new String[0]);
|
||||
}
|
||||
|
||||
public static String getMessage(int code, String... params) {
|
||||
if (messageSource == null) {
|
||||
// 延迟初始化,确保Spring上下文已完全初始化
|
||||
messageSource = (MessageSource) SpringContextUtils.getBean("messageSource");
|
||||
}
|
||||
return messageSource.getMessage(code + "", params, LocaleContextHolder.getLocale());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
|
||||
|
||||
import java.io.BufferedReader;
|
||||
@@ -37,7 +38,7 @@ public class ResourcesUtils {
|
||||
}
|
||||
} catch (IOException e){
|
||||
log.error("方法:loadString()读取资源失败--{}",e.getMessage());
|
||||
throw new RenException("读取资源失败");
|
||||
throw new RenException(ErrorCode.RESOURCE_READ_ERROR);
|
||||
}
|
||||
return luaScriptBuilder.toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package xiaozhi.common.utils;
|
||||
|
||||
import org.bouncycastle.asn1.gm.GMNamedCurves;
|
||||
import org.bouncycastle.asn1.x9.X9ECParameters;
|
||||
import org.bouncycastle.crypto.engines.SM2Engine;
|
||||
import org.bouncycastle.crypto.params.ECDomainParameters;
|
||||
import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
|
||||
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
|
||||
import org.bouncycastle.crypto.params.ParametersWithRandom;
|
||||
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey;
|
||||
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.math.ec.ECPoint;
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.security.*;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SM2加密工具类(采用十六进制格式,与chancheng-archive-service项目保持一致)
|
||||
*/
|
||||
public class SM2Utils {
|
||||
|
||||
/**
|
||||
* 公钥常量
|
||||
*/
|
||||
public static final String KEY_PUBLIC_KEY = "publicKey";
|
||||
/**
|
||||
* 私钥返回值常量
|
||||
*/
|
||||
public static final String KEY_PRIVATE_KEY = "privateKey";
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
/**
|
||||
* SM2加密算法
|
||||
*
|
||||
* @param publicKey 十六进制公钥
|
||||
* @param data 明文数据
|
||||
* @return 十六进制密文
|
||||
*/
|
||||
public static String encrypt(String publicKey, String data) {
|
||||
try {
|
||||
// 获取一条SM2曲线参数
|
||||
X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
|
||||
// 构造ECC算法参数,曲线方程、椭圆曲线G点、大整数N
|
||||
ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
|
||||
//提取公钥点
|
||||
ECPoint pukPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(publicKey));
|
||||
// 公钥前面的02或者03表示是压缩公钥,04表示未压缩公钥, 04的时候,可以去掉前面的04
|
||||
ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(pukPoint, domainParameters);
|
||||
|
||||
SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
|
||||
// 设置sm2为加密模式
|
||||
sm2Engine.init(true, new ParametersWithRandom(publicKeyParameters, new SecureRandom()));
|
||||
|
||||
byte[] in = data.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] arrayOfBytes = sm2Engine.processBlock(in, 0, in.length);
|
||||
return Hex.toHexString(arrayOfBytes);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("SM2加密失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SM2解密算法
|
||||
*
|
||||
* @param privateKey 十六进制私钥
|
||||
* @param cipherData 十六进制密文数据
|
||||
* @return 明文
|
||||
*/
|
||||
public static String decrypt(String privateKey, String cipherData) {
|
||||
try {
|
||||
// 使用BC库加解密时密文以04开头,传入的密文前面没有04则补上
|
||||
if (!cipherData.startsWith("04")) {
|
||||
cipherData = "04" + cipherData;
|
||||
}
|
||||
byte[] cipherDataByte = Hex.decode(cipherData);
|
||||
BigInteger privateKeyD = new BigInteger(privateKey, 16);
|
||||
//获取一条SM2曲线参数
|
||||
X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
|
||||
//构造domain参数
|
||||
ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
|
||||
ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(privateKeyD, domainParameters);
|
||||
|
||||
SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
|
||||
// 设置sm2为解密模式
|
||||
sm2Engine.init(false, privateKeyParameters);
|
||||
|
||||
byte[] arrayOfBytes = sm2Engine.processBlock(cipherDataByte, 0, cipherDataByte.length);
|
||||
return new String(arrayOfBytes, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("SM2解密失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成密钥对
|
||||
*/
|
||||
public static Map<String, String> createKey() {
|
||||
try {
|
||||
ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1");
|
||||
// 获取一个椭圆曲线类型的密钥对生成器
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", new BouncyCastleProvider());
|
||||
// 使用SM2参数初始化生成器
|
||||
kpg.initialize(sm2Spec);
|
||||
// 获取密钥对
|
||||
KeyPair keyPair = kpg.generateKeyPair();
|
||||
PublicKey publicKey = keyPair.getPublic();
|
||||
BCECPublicKey p = (BCECPublicKey) publicKey;
|
||||
PrivateKey privateKey = keyPair.getPrivate();
|
||||
BCECPrivateKey s = (BCECPrivateKey) privateKey;
|
||||
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put(KEY_PUBLIC_KEY, Hex.toHexString(p.getQ().getEncoded(false)));
|
||||
result.put(KEY_PRIVATE_KEY, Hex.toHexString(s.getD().toByteArray()));
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("生成SM2密钥对失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package xiaozhi.common.utils;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 敏感数据处理工具类
|
||||
*/
|
||||
public class SensitiveDataUtils {
|
||||
|
||||
// 敏感字段列表
|
||||
private static final Set<String> SENSITIVE_FIELDS = new HashSet<>(Arrays.asList(
|
||||
"api_key", "personal_access_token", "access_token", "token",
|
||||
"secret", "access_key_secret", "secret_key"));
|
||||
|
||||
/**
|
||||
* 检查字段是否为敏感字段
|
||||
*/
|
||||
public static boolean isSensitiveField(String fieldName) {
|
||||
return StringUtils.isNotBlank(fieldName) && SENSITIVE_FIELDS.contains(fieldName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏字符串中间部分
|
||||
*/
|
||||
public static String maskMiddle(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
int length = value.length();
|
||||
if (length <= 8) {
|
||||
// 短字符串保留前2后2
|
||||
return value.substring(0, 2) + "****" + value.substring(length - 2);
|
||||
} else {
|
||||
// 长字符串保留前4后4
|
||||
int maskLength = length - 8;
|
||||
StringBuilder maskBuilder = new StringBuilder();
|
||||
for (int i = 0; i < maskLength; i++) {
|
||||
maskBuilder.append('*');
|
||||
}
|
||||
return value.substring(0, 4) + maskBuilder.toString() + value.substring(length - 4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否是被掩码处理过的值
|
||||
*/
|
||||
public static boolean isMaskedValue(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return false;
|
||||
}
|
||||
// 掩码值至少包含4个连续的*
|
||||
return value.contains("****");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理JSONObject中的敏感字段
|
||||
*/
|
||||
public static JSONObject maskSensitiveFields(JSONObject jsonObject) {
|
||||
if (jsonObject == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
|
||||
for (String key : jsonObject.keySet()) {
|
||||
Object value = jsonObject.get(key);
|
||||
|
||||
if (SENSITIVE_FIELDS.contains(key.toLowerCase()) && value instanceof String) {
|
||||
result.put(key, maskMiddle((String) value));
|
||||
} else if (value instanceof JSONObject) {
|
||||
result.put(key, maskSensitiveFields((JSONObject) value));
|
||||
} else {
|
||||
result.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个JSONObject的敏感字段是否相同
|
||||
* 特别针对api_key等敏感字段进行单独比较
|
||||
*/
|
||||
public static boolean isSensitiveDataEqual(JSONObject original, JSONObject updated) {
|
||||
if (original == null && updated == null) {
|
||||
return true;
|
||||
}
|
||||
if (original == null || updated == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 提取并比较特定敏感字段
|
||||
return compareSpecificSensitiveFields(original, updated, "api_key") &&
|
||||
compareSpecificSensitiveFields(original, updated, "personal_access_token") &&
|
||||
compareSpecificSensitiveFields(original, updated, "access_token") &&
|
||||
compareSpecificSensitiveFields(original, updated, "token") &&
|
||||
compareSpecificSensitiveFields(original, updated, "secret") &&
|
||||
compareSpecificSensitiveFields(original, updated, "access_key_secret") &&
|
||||
compareSpecificSensitiveFields(original, updated, "secret_key");
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个JSON对象中特定敏感字段是否相同
|
||||
* 遍历整个JSON对象树,查找并比较指定敏感字段
|
||||
*/
|
||||
private static boolean compareSpecificSensitiveFields(JSONObject original, JSONObject updated, String fieldName) {
|
||||
// 提取原始对象中的指定敏感字段
|
||||
Map<String, String> originalFields = new HashMap<>();
|
||||
extractSpecificSensitiveField(original, originalFields, fieldName, "");
|
||||
|
||||
// 提取更新对象中的指定敏感字段
|
||||
Map<String, String> updatedFields = new HashMap<>();
|
||||
extractSpecificSensitiveField(updated, updatedFields, fieldName, "");
|
||||
|
||||
// 如果字段数量不同,说明有增删
|
||||
if (originalFields.size() != updatedFields.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 比较每个字段的值
|
||||
for (Map.Entry<String, String> entry : originalFields.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String originalValue = entry.getValue();
|
||||
String updatedValue = updatedFields.get(key);
|
||||
|
||||
if (updatedValue == null || !updatedValue.equals(originalValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归提取JSON对象中指定名称的敏感字段
|
||||
*/
|
||||
private static void extractSpecificSensitiveField(JSONObject jsonObject, Map<String, String> fieldsMap,
|
||||
String targetFieldName, String parentPath) {
|
||||
if (jsonObject == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (String key : jsonObject.keySet()) {
|
||||
String fullPath = parentPath.isEmpty() ? key : parentPath + "." + key;
|
||||
Object value = jsonObject.get(key);
|
||||
|
||||
if (value instanceof JSONObject) {
|
||||
// 递归处理嵌套JSON对象
|
||||
extractSpecificSensitiveField((JSONObject) value, fieldsMap, targetFieldName, fullPath);
|
||||
} else if (value instanceof String && key.equalsIgnoreCase(targetFieldName)) {
|
||||
// 找到目标敏感字段,保存其路径和值
|
||||
fieldsMap.put(fullPath, (String) value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package xiaozhi.common.utils;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.modules.security.service.CaptchaService;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
/**
|
||||
* SM2解密和验证码验证工具类
|
||||
* 封装了重复的SM2解密、验证码提取和验证逻辑
|
||||
*/
|
||||
public class Sm2DecryptUtil {
|
||||
|
||||
/**
|
||||
* 验证码长度
|
||||
*/
|
||||
private static final int CAPTCHA_LENGTH = 5;
|
||||
|
||||
/**
|
||||
* 解密SM2加密内容,提取验证码并验证
|
||||
* @param encryptedPassword SM2加密的密码字符串
|
||||
* @param captchaId 验证码ID
|
||||
* @param captchaService 验证码服务
|
||||
* @param sysParamsService 系统参数服务
|
||||
* @return 解密后的实际密码
|
||||
*/
|
||||
public static String decryptAndValidateCaptcha(String encryptedPassword, String captchaId,
|
||||
CaptchaService captchaService, SysParamsService sysParamsService) {
|
||||
// 获取SM2私钥
|
||||
String privateKeyStr = sysParamsService.getValue(Constant.SM2_PRIVATE_KEY, true);
|
||||
if (StringUtils.isBlank(privateKeyStr)) {
|
||||
throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
// 使用SM2私钥解密密码
|
||||
String decryptedContent;
|
||||
try {
|
||||
decryptedContent = SM2Utils.decrypt(privateKeyStr, encryptedPassword);
|
||||
} catch (Exception e) {
|
||||
throw new RenException(ErrorCode.SM2_DECRYPT_ERROR);
|
||||
}
|
||||
|
||||
// 分离验证码和密码:前5位是验证码,后面是密码
|
||||
if (decryptedContent.length() > CAPTCHA_LENGTH) {
|
||||
String embeddedCaptcha = decryptedContent.substring(0, CAPTCHA_LENGTH);
|
||||
String actualPassword = decryptedContent.substring(CAPTCHA_LENGTH);
|
||||
|
||||
// 验证嵌入的验证码是否正确
|
||||
boolean embeddedCaptchaValid = captchaService.validate(captchaId, embeddedCaptcha, true);
|
||||
if (!embeddedCaptchaValid) {
|
||||
throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR);
|
||||
}
|
||||
|
||||
return actualPassword;
|
||||
} else {
|
||||
throw new RenException(ErrorCode.SM2_DECRYPT_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
package xiaozhi.modules.agent.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -7,11 +21,26 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.DateUtils;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
|
||||
@Tag(name = "智能体聊天历史管理")
|
||||
@RequiredArgsConstructor
|
||||
@@ -19,6 +48,9 @@ import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
|
||||
@RequestMapping("/agent/chat-history")
|
||||
public class AgentChatHistoryController {
|
||||
private final AgentChatHistoryBizService agentChatHistoryBizService;
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentService agentService;
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
/**
|
||||
* 小智服务聊天上报请求
|
||||
@@ -33,4 +65,182 @@ public class AgentChatHistoryController {
|
||||
Boolean result = agentChatHistoryBizService.report(request);
|
||||
return new Result<Boolean>().ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天记录下载链接
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @param sessionId 会话ID
|
||||
* @return UUID作为下载标识
|
||||
*/
|
||||
@Operation(summary = "获取聊天记录下载链接")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
@PostMapping("/getDownloadUrl/{agentId}/{sessionId}")
|
||||
public Result<String> getDownloadUrl(@PathVariable("agentId") String agentId,
|
||||
@PathVariable("sessionId") String sessionId) {
|
||||
// 获取当前用户
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
// 检查权限
|
||||
if (!agentService.checkAgentPermission(agentId, user.getId())) {
|
||||
throw new RenException(ErrorCode.CHAT_HISTORY_NO_PERMISSION);
|
||||
}
|
||||
|
||||
// 生成UUID
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
// 存储agentId和sessionId到Redis,格式为agentId:sessionId
|
||||
redisUtils.set(RedisKeys.getChatHistoryKey(uuid), agentId + ":" + sessionId);
|
||||
|
||||
return new Result<String>().ok(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载本会话聊天记录
|
||||
*
|
||||
* @param uuid 下载标识
|
||||
* @param response HTTP响应
|
||||
*/
|
||||
@Operation(summary = "下载本会话聊天记录")
|
||||
@GetMapping("/download/{uuid}/current")
|
||||
public void downloadCurrentSession(@PathVariable("uuid") String uuid,
|
||||
HttpServletResponse response) {
|
||||
// 从Redis获取agentId和sessionId
|
||||
String agentSessionInfo = (String) redisUtils.get(RedisKeys.getChatHistoryKey(uuid));
|
||||
if (StringUtils.isBlank(agentSessionInfo)) {
|
||||
throw new RenException(ErrorCode.DOWNLOAD_LINK_EXPIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析agentId和sessionId
|
||||
String[] parts = agentSessionInfo.split(":");
|
||||
if (parts.length != 2) {
|
||||
throw new RenException(ErrorCode.DOWNLOAD_LINK_INVALID);
|
||||
}
|
||||
String agentId = parts[0];
|
||||
String sessionId = parts[1];
|
||||
|
||||
// 执行下载
|
||||
downloadChatHistory(agentId, List.of(sessionId), response);
|
||||
} finally {
|
||||
// 下载完成后删除UUID,防止盗刷
|
||||
redisUtils.delete(RedisKeys.getChatHistoryKey(uuid));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载本会话及前20条会话聊天记录
|
||||
*
|
||||
* @param uuid 下载标识
|
||||
* @param response HTTP响应
|
||||
*/
|
||||
@Operation(summary = "下载本会话及前20条会话聊天记录")
|
||||
@GetMapping("/download/{uuid}/previous")
|
||||
public void downloadCurrentSessionWithPrevious(@PathVariable("uuid") String uuid,
|
||||
HttpServletResponse response) {
|
||||
// 从Redis获取agentId和sessionId
|
||||
String agentSessionInfo = (String) redisUtils.get(RedisKeys.getChatHistoryKey(uuid));
|
||||
if (StringUtils.isBlank(agentSessionInfo)) {
|
||||
throw new RenException("下载链接已过期或无效");
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析agentId和sessionId
|
||||
String[] parts = agentSessionInfo.split(":");
|
||||
if (parts.length != 2) {
|
||||
throw new RenException("下载链接无效");
|
||||
}
|
||||
String agentId = parts[0];
|
||||
String sessionId = parts[1];
|
||||
|
||||
// 获取所有会话列表
|
||||
Map<String, Object> params = Map.of(
|
||||
"agentId", agentId,
|
||||
Constant.PAGE, 1,
|
||||
Constant.LIMIT, 1000 // 获取足够多的会话
|
||||
);
|
||||
PageData<AgentChatSessionDTO> sessionPage = agentChatHistoryService.getSessionListByAgentId(params);
|
||||
List<AgentChatSessionDTO> allSessions = sessionPage.getList();
|
||||
|
||||
// 查找当前会话在列表中的位置
|
||||
int currentIndex = -1;
|
||||
for (int i = 0; i < allSessions.size(); i++) {
|
||||
if (allSessions.get(i).getSessionId().equals(sessionId)) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了当前会话,收集当前会话及前20条会话ID
|
||||
List<String> sessionIdsToDownload = new ArrayList<>();
|
||||
if (currentIndex != -1) {
|
||||
// 从当前会话开始,向后(数组后面)取最多20条会话(包括当前会话)
|
||||
int endIndex = Math.min(allSessions.size() - 1, currentIndex + 20); // 确保不越界
|
||||
for (int i = currentIndex; i <= endIndex; i++) {
|
||||
sessionIdsToDownload.add(allSessions.get(i).getSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到当前会话,至少下载当前会话
|
||||
if (sessionIdsToDownload.isEmpty()) {
|
||||
sessionIdsToDownload.add(sessionId);
|
||||
}
|
||||
downloadChatHistory(agentId, sessionIdsToDownload, response);
|
||||
} finally {
|
||||
// 下载完成后删除UUID,防止盗刷
|
||||
redisUtils.delete(RedisKeys.getChatHistoryKey(uuid));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载指定会话的聊天记录
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @param sessionIds 会话ID列表
|
||||
* @param response HTTP响应
|
||||
*/
|
||||
private void downloadChatHistory(String agentId, List<String> sessionIds, HttpServletResponse response) {
|
||||
try {
|
||||
// 设置响应头
|
||||
response.setContentType("text/plain;charset=UTF-8");
|
||||
String fileName = URLEncoder.encode("history.txt", StandardCharsets.UTF_8.toString());
|
||||
response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
|
||||
|
||||
// 获取聊天记录并写入响应流
|
||||
try (OutputStream out = response.getOutputStream()) {
|
||||
// 为每个会话生成聊天记录
|
||||
for (String sessionId : sessionIds) {
|
||||
// 获取该会话的所有聊天记录
|
||||
List<AgentChatHistoryDTO> chatHistoryList = agentChatHistoryService
|
||||
.getChatHistoryBySessionId(agentId, sessionId);
|
||||
|
||||
// 从聊天记录中获取第一条消息的创建时间作为会话时间
|
||||
if (!chatHistoryList.isEmpty()) {
|
||||
Date firstMessageTime = chatHistoryList.get(0).getCreatedAt();
|
||||
String sessionTimeStr = DateUtils.format(firstMessageTime, DateUtils.DATE_TIME_PATTERN);
|
||||
out.write((sessionTimeStr + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
for (AgentChatHistoryDTO message : chatHistoryList) {
|
||||
String role = message.getChatType() == 1 ? MessageUtils.getMessage(ErrorCode.CHAT_ROLE_USER)
|
||||
: MessageUtils.getMessage(ErrorCode.CHAT_ROLE_AGENT);
|
||||
String direction = message.getChatType() == 1 ? ">>" : "<<";
|
||||
Date messageTime = message.getCreatedAt();
|
||||
String messageTimeStr = DateUtils.format(messageTime, DateUtils.DATE_TIME_PATTERN);
|
||||
String content = message.getContent();
|
||||
|
||||
String line = "[" + role + "]-[" + messageTimeStr + "]" + direction + ":" + content + "\n";
|
||||
out.write(line.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// 会话之间添加空行分隔
|
||||
if (sessionIds.indexOf(sessionId) < sessionIds.size() - 1) {
|
||||
out.write("\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
out.flush();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package xiaozhi.modules.agent.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.utils.ResultUtils;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.agent.vo.AgentTemplateVO;
|
||||
|
||||
@Tag(name = "智能体模板管理")
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/agent/template")
|
||||
public class AgentTemplateController {
|
||||
|
||||
private final AgentTemplateService agentTemplateService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获取模板分页列表")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
@Parameters({
|
||||
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
|
||||
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true),
|
||||
@Parameter(name = "agentName", description = "模板名称,模糊查询")
|
||||
})
|
||||
public Result<PageData<AgentTemplateVO>> getAgentTemplatesPage(
|
||||
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||
|
||||
// 创建分页对象
|
||||
int page = Integer.parseInt(params.getOrDefault(Constant.PAGE, "1").toString());
|
||||
int limit = Integer.parseInt(params.getOrDefault(Constant.LIMIT, "10").toString());
|
||||
Page<AgentTemplateEntity> pageInfo = new Page<>(page, limit);
|
||||
|
||||
// 创建查询条件
|
||||
QueryWrapper<AgentTemplateEntity> wrapper = new QueryWrapper<>();
|
||||
String agentName = (String) params.get("agentName");
|
||||
if (agentName != null && !agentName.isEmpty()) {
|
||||
wrapper.like("agent_name", agentName);
|
||||
}
|
||||
wrapper.orderByAsc("sort");
|
||||
|
||||
// 执行分页查询
|
||||
IPage<AgentTemplateEntity> pageResult = agentTemplateService.page(pageInfo, wrapper);
|
||||
|
||||
// 使用ConvertUtils转换为VO列表
|
||||
List<AgentTemplateVO> voList = ConvertUtils.sourceToTarget(pageResult.getRecords(), AgentTemplateVO.class);
|
||||
|
||||
// 修复:使用构造函数创建PageData对象,而不是无参构造+setter
|
||||
PageData<AgentTemplateVO> pageData = new PageData<>(voList, pageResult.getTotal());
|
||||
|
||||
return new Result<PageData<AgentTemplateVO>>().ok(pageData);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "获取模板详情")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<AgentTemplateVO> getAgentTemplateById(@PathVariable("id") String id) {
|
||||
AgentTemplateEntity template = agentTemplateService.getById(id);
|
||||
if (template == null) {
|
||||
return ResultUtils.error("模板不存在");
|
||||
}
|
||||
|
||||
// 使用ConvertUtils转换为VO
|
||||
AgentTemplateVO vo = ConvertUtils.sourceToTarget(template, AgentTemplateVO.class);
|
||||
|
||||
return ResultUtils.success(vo);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "创建模板")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<AgentTemplateEntity> createAgentTemplate(@Valid @RequestBody AgentTemplateEntity template) {
|
||||
// 设置排序值为下一个可用的序号
|
||||
template.setSort(agentTemplateService.getNextAvailableSort());
|
||||
|
||||
boolean saved = agentTemplateService.save(template);
|
||||
if (saved) {
|
||||
return ResultUtils.success(template);
|
||||
} else {
|
||||
return ResultUtils.error("创建模板失败");
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@Operation(summary = "更新模板")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<AgentTemplateEntity> updateAgentTemplate(@Valid @RequestBody AgentTemplateEntity template) {
|
||||
boolean updated = agentTemplateService.updateById(template);
|
||||
if (updated) {
|
||||
return ResultUtils.success(template);
|
||||
} else {
|
||||
return ResultUtils.error("更新模板失败");
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除模板")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<String> deleteAgentTemplate(@PathVariable("id") String id) {
|
||||
// 先查询要删除的模板信息,获取其排序值
|
||||
AgentTemplateEntity template = agentTemplateService.getById(id);
|
||||
if (template == null) {
|
||||
return ResultUtils.error("模板不存在");
|
||||
}
|
||||
|
||||
Integer deletedSort = template.getSort();
|
||||
|
||||
// 执行删除操作
|
||||
boolean deleted = agentTemplateService.removeById(id);
|
||||
if (deleted) {
|
||||
// 删除成功后,重新排序剩余模板
|
||||
agentTemplateService.reorderTemplatesAfterDelete(deletedSort);
|
||||
return ResultUtils.success("删除模板成功");
|
||||
} else {
|
||||
return ResultUtils.error("删除模板失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 添加新的批量删除方法,使用不同的URL
|
||||
@PostMapping("/batch-remove")
|
||||
@Operation(summary = "批量删除模板")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<String> batchRemoveAgentTemplates(@RequestBody List<String> ids) {
|
||||
boolean deleted = agentTemplateService.removeByIds(ids);
|
||||
if (deleted) {
|
||||
return ResultUtils.success("批量删除成功");
|
||||
} else {
|
||||
return ResultUtils.error("批量删除模板失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.agent.dto.AgentVoicePrintSaveDTO;
|
||||
@@ -42,7 +43,7 @@ public class AgentVoicePrintController {
|
||||
if (b) {
|
||||
return new Result<>();
|
||||
}
|
||||
return new Result<Void>().error("智能体的声纹创建失败");
|
||||
return new Result<Void>().error(ErrorCode.AGENT_VOICEPRINT_CREATE_FAILED);
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@@ -54,7 +55,7 @@ public class AgentVoicePrintController {
|
||||
if (b) {
|
||||
return new Result<>();
|
||||
}
|
||||
return new Result<Void>().error("智能体的对应声纹更新失败");
|
||||
return new Result<Void>().error(ErrorCode.AGENT_VOICEPRINT_UPDATE_FAILED);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -67,7 +68,7 @@ public class AgentVoicePrintController {
|
||||
if (delete) {
|
||||
return new Result<>();
|
||||
}
|
||||
return new Result<Void>().error("智能体的对应声纹删除失败");
|
||||
return new Result<Void>().error(ErrorCode.AGENT_VOICEPRINT_DELETE_FAILED);
|
||||
}
|
||||
|
||||
@GetMapping("/list/{id}")
|
||||
@@ -76,7 +77,7 @@ public class AgentVoicePrintController {
|
||||
public Result<List<AgentVoicePrintVO>> list(@PathVariable String id) {
|
||||
String voiceprintUrl = sysParamsService.getValue("server.voice_print", true);
|
||||
if (StringUtils.isBlank(voiceprintUrl) || "null".equals(voiceprintUrl)) {
|
||||
throw new RenException("声纹接口未配置,请先在参数配置中配置声纹接口地址(server.voice_print)");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_API_NOT_CONFIGURED);
|
||||
}
|
||||
Long userId = SecurityUser.getUserId();
|
||||
List<AgentVoicePrintVO> list = agentVoicePrintService.list(userId, id);
|
||||
|
||||
@@ -25,4 +25,18 @@ public interface AgentTemplateService extends IService<AgentTemplateEntity> {
|
||||
* @param modelId 模型ID
|
||||
*/
|
||||
void updateDefaultTemplateModelId(String modelType, String modelId);
|
||||
|
||||
/**
|
||||
* 删除模板后重新排序剩余模板
|
||||
*
|
||||
* @param deletedSort 被删除模板的排序值
|
||||
*/
|
||||
void reorderTemplatesAfterDelete(Integer deletedSort);
|
||||
|
||||
/**
|
||||
* 获取下一个可用的排序序号(寻找最小的未使用序号)
|
||||
*
|
||||
* @return 下一个可用的排序序号
|
||||
*/
|
||||
Integer getNextAvailableSort();
|
||||
}
|
||||
|
||||
@@ -103,7 +103,11 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
wrapper.select(AgentChatHistoryEntity::getContent, AgentChatHistoryEntity::getAudioId)
|
||||
.eq(AgentChatHistoryEntity::getAgentId, agentId)
|
||||
.eq(AgentChatHistoryEntity::getChatType, AgentChatHistoryType.USER.getValue())
|
||||
.isNotNull(AgentChatHistoryEntity::getAudioId);
|
||||
.isNotNull(AgentChatHistoryEntity::getAudioId)
|
||||
// 添加此行,确保查询结果按照创建时间降序排列
|
||||
// 使用id的原因:数据形式,id越大的创建时间就越晚,所以使用id的结果和创建时间降序排列结果一样
|
||||
// id作为降序排列的优势,性能高,有主键索引,不用在排序的时候重新进行排除扫描比较
|
||||
.orderByDesc(AgentChatHistoryEntity::getId);
|
||||
|
||||
// 构建分页查询,查询前50页数据
|
||||
Page<AgentChatHistoryEntity> pageParam = new Page<>(0, 50);
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
@@ -41,6 +42,7 @@ import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.model.dto.ModelProviderDTO;
|
||||
import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
@@ -74,7 +76,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
AgentInfoVO agent = agentDao.selectAgentInfoById(id);
|
||||
|
||||
if (agent == null) {
|
||||
throw new RenException("智能体不存在");
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
|
||||
@@ -182,6 +184,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
|
||||
@Override
|
||||
public boolean checkAgentPermission(String agentId, Long userId) {
|
||||
if (SecurityUser.getUser() == null || SecurityUser.getUser().getId() == null) {
|
||||
return false;
|
||||
}
|
||||
// 获取智能体信息
|
||||
AgentEntity agent = getAgentById(agentId);
|
||||
if (agent == null) {
|
||||
@@ -204,7 +209,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
// 先查询现有实体
|
||||
AgentEntity existingEntity = this.getAgentById(agentId);
|
||||
if (existingEntity == null) {
|
||||
throw new RuntimeException("智能体不存在");
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 只更新提供的非空字段
|
||||
@@ -328,7 +333,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
|
||||
boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId());
|
||||
if (!b) {
|
||||
throw new RenException("LLM大模型和Intent意图识别,选择参数不匹配");
|
||||
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
|
||||
}
|
||||
this.updateById(existingEntity);
|
||||
}
|
||||
@@ -341,6 +346,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
* @return T 匹配 : F 不匹配
|
||||
*/
|
||||
private boolean validateLLMIntentParams(String llmModelId, String intentModelId) {
|
||||
if (StringUtils.isBlank(llmModelId)) {
|
||||
return true;
|
||||
}
|
||||
ModelConfigEntity llmModelData = modelConfigService.selectById(llmModelId);
|
||||
String type = llmModelData.getConfigJson().get("type").toString();
|
||||
// 如果查询大语言模型是openai或者ollama,意图识别选参数都可以
|
||||
@@ -366,6 +374,22 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
entity.setLlmModelId(template.getLlmModelId());
|
||||
entity.setVllmModelId(template.getVllmModelId());
|
||||
entity.setTtsModelId(template.getTtsModelId());
|
||||
|
||||
if (template.getTtsVoiceId() == null && template.getTtsModelId() != null) {
|
||||
ModelConfigEntity ttsModel = modelConfigService.selectById(template.getTtsModelId());
|
||||
if (ttsModel != null && ttsModel.getConfigJson() != null) {
|
||||
Map<String, Object> config = ttsModel.getConfigJson();
|
||||
String voice = (String) config.get("voice");
|
||||
if (StringUtils.isBlank(voice)) {
|
||||
voice = (String) config.get("speaker");
|
||||
}
|
||||
VoiceDTO timbre = timbreModelService.getByVoiceCode(template.getTtsModelId(), voice);
|
||||
if (timbre != null) {
|
||||
template.setTtsVoiceId(timbre.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
|
||||
@@ -10,6 +10,11 @@ import xiaozhi.modules.agent.dao.AgentTemplateDao;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
|
||||
/**
|
||||
* @author chenerlei
|
||||
* @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service实现
|
||||
@@ -69,4 +74,48 @@ public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, Agen
|
||||
wrapper.ge("sort", 0);
|
||||
update(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reorderTemplatesAfterDelete(Integer deletedSort) {
|
||||
if (deletedSort == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询所有排序值大于被删除模板的记录
|
||||
UpdateWrapper<AgentTemplateEntity> updateWrapper = new UpdateWrapper<>();
|
||||
updateWrapper.gt("sort", deletedSort)
|
||||
.setSql("sort = sort - 1");
|
||||
|
||||
// 执行批量更新,将这些记录的排序值减1
|
||||
this.update(updateWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getNextAvailableSort() {
|
||||
// 查询所有已存在的排序值并按升序排序
|
||||
List<Integer> sortValues = baseMapper.selectList(new QueryWrapper<AgentTemplateEntity>())
|
||||
.stream()
|
||||
.map(AgentTemplateEntity::getSort)
|
||||
.filter(Objects::nonNull)
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 如果没有排序值,返回1
|
||||
if (sortValues.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 寻找最小的未使用序号
|
||||
int expectedSort = 1;
|
||||
for (Integer sort : sortValues) {
|
||||
if (sort > expectedSort) {
|
||||
// 找到空缺的序号
|
||||
return expectedSort;
|
||||
}
|
||||
expectedSort = sort + 1;
|
||||
}
|
||||
|
||||
// 如果没有空缺,返回最大序号+1
|
||||
return expectedSort;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
@@ -78,7 +79,7 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
// 根据识别出的声纹ID查询对应的用户信息
|
||||
AgentVoicePrintEntity existingVoicePrint = baseMapper.selectById(response.getSpeakerId());
|
||||
String existingUserName = existingVoicePrint != null ? existingVoicePrint.getSourceName() : "未知用户";
|
||||
throw new RenException("此声音声纹对应的人(" + existingUserName + ")已经注册,请选择其他声音注册");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_ALREADY_REGISTERED, existingUserName);
|
||||
}
|
||||
AgentVoicePrintEntity entity = ConvertUtils.sourceToTarget(dto, AgentVoicePrintEntity.class);
|
||||
// 开启事务
|
||||
@@ -100,7 +101,7 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
} catch (Exception e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
log.error("保存声纹错误原因:{}", e.getMessage());
|
||||
throw new RenException("保存声纹错误,请联系管理员");
|
||||
throw new RenException(ErrorCode.VOICE_PRINT_SAVE_ERROR);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -123,7 +124,7 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
} catch (Exception e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
log.error("删除声纹存在错误原因:{}", e.getMessage());
|
||||
throw new RenException("删除声纹出现了错误");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_DELETE_ERROR);
|
||||
}
|
||||
}));
|
||||
// 数据库声纹数据删除成功才继续执行删除声纹服务的数据
|
||||
@@ -179,7 +180,7 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
// 根据识别出的声纹ID查询对应的用户信息
|
||||
AgentVoicePrintEntity existingVoicePrint = baseMapper.selectById(response.getSpeakerId());
|
||||
String existingUserName = existingVoicePrint != null ? existingVoicePrint.getSourceName() : "未知用户";
|
||||
throw new RenException("此次修改不允许,此声音已经注册为声纹了(" + existingUserName + ")");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_UPDATE_NOT_ALLOWED, existingUserName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -208,7 +209,7 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
} catch (Exception e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
log.error("修改声纹错误原因:{}", e.getMessage());
|
||||
throw new RenException("修改声纹错误,请联系管理员");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_UPDATE_ADMIN_ERROR);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -225,7 +226,7 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
return new URI(voicePrint);
|
||||
} catch (URISyntaxException e) {
|
||||
log.error("路径格式不正确路径:{},\n错误信息:{}", voicePrint, e.getMessage());
|
||||
throw new RuntimeException("声纹接口的地址存在错误,请进入参数管理修改声纹接口地址");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_API_URI_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,13 +271,13 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
// 判断这个音频是否属于当前智能体
|
||||
boolean b = agentChatHistoryService.isAudioOwnedByAgent(audioId, agentId);
|
||||
if (!b) {
|
||||
throw new RenException("音频数据不属于这个智能体");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_AUDIO_NOT_BELONG_AGENT);
|
||||
}
|
||||
// 获取到音频数据
|
||||
byte[] audio = agentChatAudioService.getAudio(audioId);
|
||||
// 如果音频数据为空的直接报错不进行下去
|
||||
if (audio == null || audio.length == 0) {
|
||||
throw new RenException("音频数据是空的请检查上传数据");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_AUDIO_EMPTY);
|
||||
}
|
||||
// 将字节数组包装为资源,返回
|
||||
return new ByteArrayResource(audio) {
|
||||
@@ -314,13 +315,13 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
log.error("声纹注册失败,请求路径:{}", requestUrl);
|
||||
throw new RenException("声纹保存失败,请求不成功");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_REGISTER_REQUEST_ERROR);
|
||||
}
|
||||
// 检查响应内容
|
||||
String responseBody = response.getBody();
|
||||
if (responseBody == null || !responseBody.contains("true")) {
|
||||
log.error("声纹注册失败,请求处理失败内容:{}", responseBody == null ? "空内容" : responseBody);
|
||||
throw new RenException("声纹保存失败,请求处理失败");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_REGISTER_PROCESS_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,13 +345,13 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
String.class);
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
log.error("声纹注销失败,请求路径:{}", requestUrl);
|
||||
throw new RenException("声纹注销失败,请求不成功");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_UNREGISTER_REQUEST_ERROR);
|
||||
}
|
||||
// 检查响应内容
|
||||
String responseBody = response.getBody();
|
||||
if (responseBody == null || !responseBody.contains("true")) {
|
||||
log.error("声纹注销失败,请求处理失败内容:{}", responseBody == null ? "空内容" : responseBody);
|
||||
throw new RenException("声纹注销失败,请求处理失败");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_UNREGISTER_PROCESS_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +399,7 @@ public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao,
|
||||
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
log.error("声纹识别请求失败,请求路径:{}", requestUrl);
|
||||
throw new RenException("声纹识别失败,请求不成功");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_IDENTIFY_REQUEST_ERROR);
|
||||
}
|
||||
// 检查响应内容
|
||||
String responseBody = response.getBody();
|
||||
|
||||
@@ -38,6 +38,8 @@ import xiaozhi.modules.sys.dto.SysParamsDTO;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@@ -51,6 +53,7 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
private final TimbreService timbreService;
|
||||
private final AgentPluginMappingService agentPluginMappingService;
|
||||
private final AgentMcpAccessPointService agentMcpAccessPointService;
|
||||
private final VoiceCloneService cloneVoiceService;
|
||||
private final AgentVoicePrintDao agentVoicePrintDao;
|
||||
|
||||
@Override
|
||||
@@ -124,6 +127,11 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
voice = timbre.getTtsVoice();
|
||||
referenceAudio = timbre.getReferenceAudio();
|
||||
referenceText = timbre.getReferenceText();
|
||||
} else {
|
||||
VoiceCloneEntity voice_print = cloneVoiceService.selectById(agent.getTtsVoiceId());
|
||||
if (voice_print != null) {
|
||||
voice = voice_print.getVoiceId();
|
||||
}
|
||||
}
|
||||
// 构建返回数据
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
@@ -299,6 +307,20 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
voiceprintConfig.put("url", voiceprintUrl);
|
||||
voiceprintConfig.put("speakers", speakers);
|
||||
|
||||
// 获取声纹识别相似度阈值,默认0.4
|
||||
String thresholdStr = sysParamsService.getValue("server.voiceprint_similarity_threshold", true);
|
||||
if (StringUtils.isNotBlank(thresholdStr) && !"null".equals(thresholdStr)) {
|
||||
try {
|
||||
double threshold = Double.parseDouble(thresholdStr);
|
||||
voiceprintConfig.put("similarity_threshold", threshold);
|
||||
} catch (NumberFormatException e) {
|
||||
// 如果解析失败,使用默认值0.4
|
||||
voiceprintConfig.put("similarity_threshold", 0.4);
|
||||
}
|
||||
} else {
|
||||
voiceprintConfig.put("similarity_threshold", 0.4);
|
||||
}
|
||||
|
||||
result.put("voiceprint", voiceprintConfig);
|
||||
} catch (Exception e) {
|
||||
// 声纹配置获取失败时不影响其他功能
|
||||
@@ -362,7 +384,8 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
if (modelIds[i] == null) {
|
||||
continue;
|
||||
}
|
||||
ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache);
|
||||
// 关键:第三个参数传false,确保获取原始密钥
|
||||
ModelConfigEntity model = modelConfigService.getModelByIdFromCache(modelIds[i]);
|
||||
if (model == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -377,6 +400,15 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
((Map<String, Object>) model.getConfigJson()).put("ref_audio", referenceAudio);
|
||||
if (referenceText != null)
|
||||
((Map<String, Object>) model.getConfigJson()).put("ref_text", referenceText);
|
||||
|
||||
// 火山引擎声音克隆需要替换resource_id
|
||||
Map<String, Object> map = (Map<String, Object>) model.getConfigJson();
|
||||
if (Constant.VOICE_CLONE_HUOSHAN_DOUBLE_STREAM.equals(map.get("type"))) {
|
||||
// 如果voice是”S_“开头的,使用seed-icl-1.0
|
||||
if (voice != null && voice.startsWith("S_")) {
|
||||
map.put("resource_id", "seed-icl-1.0");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果是Intent类型,且type=intent_llm,则给他添加附加模型
|
||||
if ("Intent".equals(modelTypes[i])) {
|
||||
@@ -410,14 +442,16 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
if ("LLM".equals(modelTypes[i])) {
|
||||
if (StringUtils.isNotBlank(intentLLMModelId)) {
|
||||
if (!typeConfig.containsKey(intentLLMModelId)) {
|
||||
ModelConfigEntity intentLLM = modelConfigService.getModelById(intentLLMModelId, isCache);
|
||||
// 修改这里:添加isMaskSensitive=false参数
|
||||
ModelConfigEntity intentLLM = modelConfigService.getModelByIdFromCache(intentLLMModelId);
|
||||
typeConfig.put(intentLLM.getId(), intentLLM.getConfigJson());
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(memLocalShortLLMModelId)) {
|
||||
if (!typeConfig.containsKey(memLocalShortLLMModelId)) {
|
||||
// 修改这里:添加isMaskSensitive=false参数
|
||||
ModelConfigEntity memLocalShortLLM = modelConfigService
|
||||
.getModelById(memLocalShortLLMModelId, isCache);
|
||||
.getModelByIdFromCache(memLocalShortLLMModelId);
|
||||
typeConfig.put(memLocalShortLLM.getId(), memLocalShortLLM.getConfigJson());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -12,32 +16,45 @@ import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceRegisterDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceUnBindDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceUpdateDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
@Tag(name = "设备管理")
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/device")
|
||||
public class DeviceController {
|
||||
private final DeviceService deviceService;
|
||||
|
||||
private final RedisUtils redisUtils;
|
||||
private final SysParamsService sysParamsService;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public DeviceController(DeviceService deviceService, RedisUtils redisUtils, SysParamsService sysParamsService,
|
||||
RestTemplate restTemplate, ObjectMapper objectMapper) {
|
||||
this.deviceService = deviceService;
|
||||
this.redisUtils = redisUtils;
|
||||
this.sysParamsService = sysParamsService;
|
||||
this.restTemplate = restTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping("/bind/{agentId}/{deviceCode}")
|
||||
@Operation(summary = "绑定设备")
|
||||
@@ -75,6 +92,88 @@ public class DeviceController {
|
||||
return new Result<List<DeviceEntity>>().ok(devices);
|
||||
}
|
||||
|
||||
@PostMapping("/bind/{agentId}")
|
||||
@Operation(summary = "设备在线接口")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> forwardToMqttGateway(@PathVariable String agentId, @RequestBody String requestBody) {
|
||||
try {
|
||||
// 从系统参数中获取MQTT网关地址
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
// 获取当前用户的设备列表
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
List<DeviceEntity> devices = deviceService.getUserDevices(user.getId(), agentId);
|
||||
|
||||
// 构建deviceIds数组
|
||||
java.util.List<String> deviceIds = new java.util.ArrayList<>();
|
||||
for (DeviceEntity device : devices) {
|
||||
String macAddress = device.getMacAddress() != null ? device.getMacAddress() : "unknown";
|
||||
String groupId = device.getBoard() != null ? device.getBoard() : "GID_default";
|
||||
|
||||
// 替换冒号为下划线
|
||||
groupId = groupId.replace(":", "_");
|
||||
macAddress = macAddress.replace(":", "_");
|
||||
|
||||
// 构建mqtt客户端ID格式:groupId@@@macAddress@@@macAddress
|
||||
String mqttClientId = groupId + "@@@" + macAddress + "@@@" + macAddress;
|
||||
deviceIds.add(mqttClientId);
|
||||
}
|
||||
|
||||
// 构建完整的URL
|
||||
String url = "http://" + mqttGatewayUrl + "/api/devices/status";
|
||||
|
||||
// 设置请求头
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
// 生成Bearer令牌
|
||||
String token = generateBearerToken();
|
||||
if (token == null) {
|
||||
return new Result<String>().error("令牌生成失败");
|
||||
}
|
||||
headers.set("Authorization", "Bearer " + token);
|
||||
|
||||
// 构建请求体JSON
|
||||
String jsonBody = "{\"clientIds\":" + objectMapper.writeValueAsString(deviceIds) + "}";
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(jsonBody, headers);
|
||||
|
||||
// 发送POST请求
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
|
||||
|
||||
// 返回响应
|
||||
return new Result<String>().ok(response.getBody());
|
||||
} catch (Exception e) {
|
||||
return new Result<String>().error("转发请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String generateBearerToken() {
|
||||
try {
|
||||
// 获取当前日期,格式为yyyy-MM-dd
|
||||
String dateStr = java.time.LocalDate.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
|
||||
// 获取MQTT签名密钥
|
||||
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false);
|
||||
if (StringUtils.isBlank(signatureKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将日期字符串与MQTT_SIGNATURE_KEY连接
|
||||
String tokenContent = dateStr + signatureKey;
|
||||
|
||||
// 对连接后的字符串进行SHA256哈希计算
|
||||
String token = org.apache.commons.codec.digest.DigestUtils.sha256Hex(tokenContent);
|
||||
|
||||
return token;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/unbind")
|
||||
@Operation(summary = "解绑设备")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
|
||||
@@ -77,6 +77,10 @@ public class OTAController {
|
||||
@GetMapping
|
||||
@Hidden
|
||||
public ResponseEntity<String> getOTA() {
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
|
||||
if (StringUtils.isBlank(mqttUdpConfig)) {
|
||||
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址,请登录智控台,在参数管理找到【server.mqtt_gateway】配置");
|
||||
}
|
||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置");
|
||||
|
||||
@@ -19,10 +19,13 @@ public class DeviceReportRespDTO {
|
||||
|
||||
@Schema(description = "固件版本信息")
|
||||
private Firmware firmware;
|
||||
|
||||
|
||||
@Schema(description = "WebSocket配置")
|
||||
private Websocket websocket;
|
||||
|
||||
@Schema(description = "MQTT Gateway配置")
|
||||
private MQTT mqtt;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Firmware {
|
||||
@@ -63,11 +66,30 @@ public class DeviceReportRespDTO {
|
||||
@Schema(description = "时区偏移量,单位为分钟")
|
||||
private Integer timezone_offset;
|
||||
}
|
||||
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Websocket {
|
||||
@Schema(description = "WebSocket服务器地址")
|
||||
private String url;
|
||||
@Schema(description = "WebSocket 认证 token")
|
||||
private String token;
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class MQTT {
|
||||
@Schema(description = "MQTT 配置网址")
|
||||
private String endpoint;
|
||||
@Schema(description = "MQTT 客户端唯一标识符")
|
||||
private String client_id;
|
||||
@Schema(description = "MQTT 认证用户名")
|
||||
private String username;
|
||||
@Schema(description = "MQTT 认证密码")
|
||||
private String password;
|
||||
@Schema(description = "ESP32 发布消息的主题")
|
||||
private String publish_topic;
|
||||
@Schema(description = "ESP32 订阅的主题")
|
||||
private String subscribe_topic;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -8,6 +10,9 @@ import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.aop.framework.AopContext;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
@@ -24,6 +29,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
@@ -33,6 +39,7 @@ import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.DateUtils;
|
||||
import xiaozhi.modules.device.dao.DeviceDao;
|
||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||
import xiaozhi.modules.device.dto.DevicePageUserDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
|
||||
@@ -44,7 +51,6 @@ import xiaozhi.modules.device.vo.UserShowDeviceListVO;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
import xiaozhi.modules.sys.service.SysUserUtilService;
|
||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -78,27 +84,27 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
@Override
|
||||
public Boolean deviceActivation(String agentId, String activationCode) {
|
||||
if (StringUtils.isBlank(activationCode)) {
|
||||
throw new RenException("激活码不能为空");
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_EMPTY);
|
||||
}
|
||||
String deviceKey = "ota:activation:code:" + activationCode;
|
||||
Object cacheDeviceId = redisUtils.get(deviceKey);
|
||||
if (cacheDeviceId == null) {
|
||||
throw new RenException("激活码错误");
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||
}
|
||||
String deviceId = (String) cacheDeviceId;
|
||||
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
||||
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
|
||||
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey);
|
||||
if (cacheMap == null) {
|
||||
throw new RenException("激活码错误");
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||
}
|
||||
String cachedCode = (String) cacheMap.get("activation_code");
|
||||
if (!activationCode.equals(cachedCode)) {
|
||||
throw new RenException("激活码错误");
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||
}
|
||||
// 检查设备有没有被激活
|
||||
if (selectById(deviceId) != null) {
|
||||
throw new RenException("设备已激活");
|
||||
throw new RenException(ErrorCode.DEVICE_ALREADY_ACTIVATED);
|
||||
}
|
||||
|
||||
String macAddress = (String) cacheMap.get("mac_address");
|
||||
@@ -106,7 +112,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
String appVersion = (String) cacheMap.get("app_version");
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
if (user.getId() == null) {
|
||||
throw new RenException("用户未登录");
|
||||
throw new RenException(ErrorCode.USER_NOT_LOGIN);
|
||||
}
|
||||
|
||||
Date currentTime = new Date();
|
||||
@@ -128,6 +134,10 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
// 清理redis缓存
|
||||
redisUtils.delete(cacheDeviceKey);
|
||||
redisUtils.delete(deviceKey);
|
||||
|
||||
// 添加:清除智能体设备数量缓存
|
||||
redisUtils.delete(RedisKeys.getAgentDeviceCountById(agentId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -159,6 +169,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
|
||||
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
|
||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||
websocket.setToken("");
|
||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
|
||||
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
|
||||
@@ -176,6 +187,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
response.setWebsocket(websocket);
|
||||
|
||||
// 添加MQTT UDP配置
|
||||
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
|
||||
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
|
||||
try {
|
||||
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
|
||||
: "GID_default";
|
||||
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
|
||||
if (mqtt != null) {
|
||||
mqtt.setEndpoint(mqttUdpConfig);
|
||||
response.setMqtt(mqtt);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("生成MQTT配置失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (deviceById != null) {
|
||||
// 如果设备存在,则异步更新上次连接时间和版本信息
|
||||
String appVersion = deviceReport.getApplication() != null ? deviceReport.getApplication().getVersion()
|
||||
@@ -202,6 +230,16 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
@Override
|
||||
public void unbindDevice(Long userId, String deviceId) {
|
||||
// 先查询设备信息,获取agentId
|
||||
DeviceEntity device = baseDao.selectById(deviceId);
|
||||
if (device == null) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isNotBlank(device.getAgentId())) {
|
||||
// 清除智能体设备数量缓存
|
||||
redisUtils.delete(RedisKeys.getAgentDeviceCountById(device.getAgentId()));
|
||||
}
|
||||
|
||||
UpdateWrapper<DeviceEntity> wrapper = new UpdateWrapper<>();
|
||||
wrapper.eq("user_id", userId);
|
||||
wrapper.eq("id", deviceId);
|
||||
@@ -419,7 +457,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
wrapper.eq("mac_address", dto.getMacAddress());
|
||||
DeviceEntity exist = baseDao.selectOne(wrapper);
|
||||
if (exist != null) {
|
||||
throw new RenException("该Mac地址已存在");
|
||||
throw new RenException(ErrorCode.MAC_ADDRESS_ALREADY_EXISTS);
|
||||
}
|
||||
Date now = new Date();
|
||||
DeviceEntity entity = new DeviceEntity();
|
||||
@@ -436,5 +474,77 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
entity.setUpdater(userId);
|
||||
entity.setAutoUpdate(1);
|
||||
baseDao.insert(entity);
|
||||
|
||||
// 添加:清除智能体设备数量缓存
|
||||
redisUtils.delete(RedisKeys.getAgentDeviceCountById(dto.getAgentId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成MQTT密码签名
|
||||
*
|
||||
* @param content 签名内容 (clientId + '|' + username)
|
||||
* @param secretKey 密钥
|
||||
* @return Base64编码的HMAC-SHA256签名
|
||||
*/
|
||||
private String generatePasswordSignature(String content, String secretKey) throws Exception {
|
||||
Mac hmac = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
hmac.init(keySpec);
|
||||
byte[] signature = hmac.doFinal(content.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建MQTT配置信息
|
||||
*
|
||||
* @param macAddress MAC地址
|
||||
* @param groupId 分组ID
|
||||
* @return MQTT配置对象
|
||||
*/
|
||||
private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId)
|
||||
throws Exception {
|
||||
// 从环境变量或系统参数获取签名密钥
|
||||
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false);
|
||||
if (StringUtils.isBlank(signatureKey)) {
|
||||
log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建客户端ID格式:groupId@@@macAddress@@@uuid
|
||||
String groupIdSafeStr = groupId.replace(":", "_");
|
||||
String deviceIdSafeStr = macAddress.replace(":", "_");
|
||||
String mqttClientId = String.format("%s@@@%s@@@%s", groupIdSafeStr, deviceIdSafeStr, deviceIdSafeStr);
|
||||
|
||||
// 构建用户数据(包含IP等信息)
|
||||
Map<String, String> userData = new HashMap<>();
|
||||
// 尝试获取客户端IP
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
|
||||
.getRequestAttributes();
|
||||
if (attributes != null) {
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String clientIp = request.getRemoteAddr();
|
||||
userData.put("ip", clientIp);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
userData.put("ip", "unknown");
|
||||
}
|
||||
|
||||
// 将用户数据编码为Base64 JSON
|
||||
String userDataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(userData);
|
||||
String username = Base64.getEncoder().encodeToString(userDataJson.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 生成密码签名
|
||||
String password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
|
||||
|
||||
// 构建MQTT配置
|
||||
DeviceReportRespDTO.MQTT mqtt = new DeviceReportRespDTO.MQTT();
|
||||
mqtt.setClient_id(mqttClientId);
|
||||
mqtt.setUsername(username);
|
||||
mqtt.setPassword(password);
|
||||
mqtt.setPublish_topic("device-server");
|
||||
mqtt.setSubscribe_topic("devices/p2p/" + deviceIdSafeStr);
|
||||
|
||||
return mqtt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,12 @@ import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.config.service.ConfigService;
|
||||
import xiaozhi.modules.model.dto.*;
|
||||
import xiaozhi.modules.model.dto.LlmModelBasicInfoDTO;
|
||||
import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
|
||||
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
||||
import xiaozhi.modules.model.dto.ModelConfigDTO;
|
||||
import xiaozhi.modules.model.dto.ModelProviderDTO;
|
||||
import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
@@ -124,6 +129,8 @@ public class ModelController {
|
||||
if (entity == null) {
|
||||
return new Result<Void>().error("模型配置不存在");
|
||||
}
|
||||
// 不更新ConfigJson字段
|
||||
entity.setConfigJson(null);
|
||||
entity.setIsEnabled(status);
|
||||
modelConfigService.updateById(entity);
|
||||
return new Result<Void>();
|
||||
@@ -141,6 +148,8 @@ public class ModelController {
|
||||
modelConfigService.setDefaultModel(entity.getModelType(), 0);
|
||||
entity.setIsEnabled(1);
|
||||
entity.setIsDefault(1);
|
||||
// 不更新ConfigJson字段
|
||||
entity.setConfigJson(null);
|
||||
modelConfigService.updateById(entity);
|
||||
|
||||
// 更新模板表中对应的模型ID
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package xiaozhi.modules.model.dao;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
@@ -15,4 +16,9 @@ public interface ModelConfigDao extends BaseDao<ModelConfigEntity> {
|
||||
* get model_code list
|
||||
*/
|
||||
List<String> getModelCodeList(@Param("modelType") String modelType, @Param("modelName") String modelName);
|
||||
|
||||
/**
|
||||
* 获取符合条件的TTS平台列表(id和modelName)
|
||||
*/
|
||||
List<Map<String, Object>> getTtsPlatformList();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package xiaozhi.modules.model.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.BaseService;
|
||||
@@ -35,17 +36,23 @@ public interface ModelConfigService extends BaseService<ModelConfigEntity> {
|
||||
/**
|
||||
* 根据ID获取模型配置
|
||||
*
|
||||
* @param id 模型ID
|
||||
* @param isCache 是否缓存
|
||||
* @param id 模型ID
|
||||
* @return 模型配置实体
|
||||
*/
|
||||
ModelConfigEntity getModelById(String id, boolean isCache);
|
||||
ModelConfigEntity getModelByIdFromCache(String id);
|
||||
|
||||
/**
|
||||
* 设置默认模型
|
||||
*
|
||||
*
|
||||
* @param modelType 模型类型
|
||||
* @param isDefault 是否默认
|
||||
* @param isDefault 是否默认(1:是,0:否)
|
||||
*/
|
||||
void setDefaultModel(String modelType, int isDefault);
|
||||
|
||||
/**
|
||||
* 获取符合条件的TTS平台列表
|
||||
*
|
||||
* @return TTS平台列表(id和modelName)
|
||||
*/
|
||||
List<Map<String, Object>> getTtsPlatformList();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package xiaozhi.modules.model.service.impl;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -11,16 +12,21 @@ import org.springframework.stereotype.Service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.OrderItem;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.SensitiveDataUtils;
|
||||
import xiaozhi.modules.agent.dao.AgentDao;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.model.dao.ModelConfigDao;
|
||||
@@ -62,7 +68,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
.eq("is_enabled", 1)
|
||||
.like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%")
|
||||
.select("id", "model_name", "config_json"));
|
||||
// 处理获取到的内容
|
||||
|
||||
return entities.stream().map(item -> {
|
||||
LlmModelBasicInfoDTO dto = new LlmModelBasicInfoDTO();
|
||||
dto.setId(item.getId());
|
||||
@@ -75,90 +81,371 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
|
||||
@Override
|
||||
public PageData<ModelConfigDTO> getPageList(String modelType, String modelName, String page, String limit) {
|
||||
Map<String, Object> params = new HashMap<String, Object>();
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put(Constant.PAGE, page);
|
||||
params.put(Constant.LIMIT, limit);
|
||||
|
||||
long curPage = Long.parseLong(page);
|
||||
long pageSize = Long.parseLong(limit);
|
||||
Page<ModelConfigEntity> pageInfo = new Page<>(curPage, pageSize);
|
||||
|
||||
// 添加排序规则:先按is_enabled降序,再按sort升序
|
||||
pageInfo.addOrder(OrderItem.desc("is_enabled"));
|
||||
pageInfo.addOrder(OrderItem.asc("sort"));
|
||||
|
||||
IPage<ModelConfigEntity> modelConfigEntityIPage = modelConfigDao.selectPage(
|
||||
getPage(params, "sort", true),
|
||||
pageInfo,
|
||||
new QueryWrapper<ModelConfigEntity>()
|
||||
.eq("model_type", modelType)
|
||||
.like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%"));
|
||||
|
||||
return getPageData(modelConfigEntityIPage, ModelConfigDTO.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelConfigDTO add(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
// 先验证有没有供应器
|
||||
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) {
|
||||
throw new RenException("modelType和provideCode不能为空");
|
||||
}
|
||||
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
|
||||
if (CollectionUtil.isEmpty(providerList)) {
|
||||
throw new RenException("供应器不存在");
|
||||
}
|
||||
public ModelConfigDTO edit(String modelType, String provideCode, String id, ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
// 1. 参数验证
|
||||
validateEditParameters(modelType, provideCode, id, modelConfigBodyDTO);
|
||||
|
||||
// 再保存供应器提供的模型
|
||||
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
|
||||
modelConfigEntity.setModelType(modelType);
|
||||
modelConfigEntity.setIsDefault(0);
|
||||
modelConfigDao.insert(modelConfigEntity);
|
||||
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
|
||||
// 2. 验证模型提供者
|
||||
validateModelProvider(modelType, provideCode);
|
||||
|
||||
// 3. 获取原始配置(不经过敏感数据处理)
|
||||
ModelConfigEntity originalEntity = getOriginalConfigFromDb(id);
|
||||
|
||||
// 4. 验证LLM配置
|
||||
validateLlmConfiguration(modelConfigBodyDTO);
|
||||
|
||||
// 5. 准备更新实体并处理敏感数据
|
||||
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, modelType, id);
|
||||
|
||||
// 6. 执行数据库更新
|
||||
modelConfigDao.updateById(modelConfigEntity);
|
||||
|
||||
// 7. 清除缓存
|
||||
clearModelCache(id);
|
||||
|
||||
// 8. 返回处理后的数据(包含敏感数据掩码)
|
||||
return buildResponseDTO(modelConfigEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelConfigDTO edit(String modelType, String provideCode, String id, ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
// 先验证有没有供应器
|
||||
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) {
|
||||
throw new RenException("modelType和provideCode不能为空");
|
||||
}
|
||||
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
|
||||
if (CollectionUtil.isEmpty(providerList)) {
|
||||
throw new RenException("供应器不存在");
|
||||
}
|
||||
if (modelConfigBodyDTO.getConfigJson().containsKey("llm")) {
|
||||
String llm = modelConfigBodyDTO.getConfigJson().get("llm").toString();
|
||||
ModelConfigEntity modelConfigEntity = modelConfigDao.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getId, llm));
|
||||
String selectModelType = (modelConfigEntity == null || modelConfigEntity.getModelType() == null) ? null
|
||||
: modelConfigEntity.getModelType().toUpperCase();
|
||||
if (modelConfigEntity == null || !"LLM".equals(selectModelType)) {
|
||||
throw new RenException("设置的LLM不存在");
|
||||
}
|
||||
String type = modelConfigEntity.getConfigJson().get("type").toString();
|
||||
// 如果查询大语言模型是openai或者ollama,意图识别选参数都可以
|
||||
if (!"openai".equals(type) && !"ollama".equals(type)) {
|
||||
throw new RenException("设置的LLM不是openai和ollama");
|
||||
}
|
||||
}
|
||||
public ModelConfigDTO add(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
validateAddParameters(modelType, provideCode, modelConfigBodyDTO);
|
||||
|
||||
// 再更新供应器提供的模型
|
||||
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
|
||||
modelConfigEntity.setId(id);
|
||||
modelConfigEntity.setModelType(modelType);
|
||||
modelConfigDao.updateById(modelConfigEntity);
|
||||
// 清除缓存
|
||||
redisUtils.delete(RedisKeys.getModelConfigById(modelConfigEntity.getId()));
|
||||
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
|
||||
validateModelProvider(modelType, provideCode);
|
||||
|
||||
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, modelType);
|
||||
|
||||
modelConfigDao.insert(modelConfigEntity);
|
||||
|
||||
return buildResponseDTO(modelConfigEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
// 查看是否是默认
|
||||
if (StringUtils.isBlank(id)) {
|
||||
throw new RenException(ErrorCode.IDENTIFIER_NOT_NULL);
|
||||
}
|
||||
|
||||
ModelConfigEntity modelConfig = modelConfigDao.selectById(id);
|
||||
if (modelConfig != null && modelConfig.getIsDefault() == 1) {
|
||||
throw new RenException("该模型为默认模型,请先设置其他模型为默认模型");
|
||||
throw new RenException(ErrorCode.DEFAULT_MODEL_DELETE_ERROR);
|
||||
}
|
||||
// 验证是否有引用
|
||||
|
||||
checkAgentReference(id);
|
||||
checkIntentConfigReference(id);
|
||||
|
||||
modelConfigDao.deleteById(id);
|
||||
|
||||
clearModelCache(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModelNameById(String id) {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String cacheKey = RedisKeys.getModelNameById(id);
|
||||
String cachedName = (String) redisUtils.get(cacheKey);
|
||||
if (StringUtils.isNotBlank(cachedName)) {
|
||||
return cachedName;
|
||||
}
|
||||
|
||||
ModelConfigEntity entity = modelConfigDao.selectById(id);
|
||||
if (entity != null) {
|
||||
String modelName = entity.getModelName();
|
||||
if (StringUtils.isNotBlank(modelName)) {
|
||||
redisUtils.set(cacheKey, modelName);
|
||||
}
|
||||
return modelName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelConfigEntity selectById(Serializable id) {
|
||||
ModelConfigEntity entity = super.selectById(id);
|
||||
if (entity != null && entity.getConfigJson() != null) {
|
||||
entity.setConfigJson(maskSensitiveFields(entity.getConfigJson()));
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected <D> PageData<D> getPageData(IPage<?> page, Class<D> target) {
|
||||
List<?> records = page.getRecords();
|
||||
if (records != null && !records.isEmpty()) {
|
||||
for (Object record : records) {
|
||||
if (record instanceof ModelConfigEntity) {
|
||||
ModelConfigEntity entity = (ModelConfigEntity) record;
|
||||
if (entity.getConfigJson() != null) {
|
||||
entity.setConfigJson(maskSensitiveFields(entity.getConfigJson()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.getPageData(page, target);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelConfigEntity getModelByIdFromCache(String id) {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return null;
|
||||
}
|
||||
String cacheKey = RedisKeys.getModelConfigById(id);
|
||||
ModelConfigEntity entity = (ModelConfigEntity) redisUtils.get(cacheKey);
|
||||
if (entity == null) {
|
||||
entity = modelConfigDao.selectById(id);
|
||||
if (entity != null) {
|
||||
redisUtils.set(cacheKey, entity);
|
||||
}
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证编辑参数
|
||||
*/
|
||||
private void validateEditParameters(String modelType, String provideCode, String id,
|
||||
ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) {
|
||||
throw new RenException(ErrorCode.MODEL_TYPE_PROVIDE_CODE_NOT_NULL);
|
||||
}
|
||||
if (StringUtils.isBlank(id)) {
|
||||
throw new RenException(ErrorCode.IDENTIFIER_NOT_NULL);
|
||||
}
|
||||
if (modelConfigBodyDTO == null) {
|
||||
throw new RenException(ErrorCode.PARAMS_GET_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证添加参数
|
||||
*/
|
||||
private void validateAddParameters(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) {
|
||||
throw new RenException(ErrorCode.MODEL_TYPE_PROVIDE_CODE_NOT_NULL);
|
||||
}
|
||||
if (modelConfigBodyDTO == null) {
|
||||
throw new RenException(ErrorCode.PARAMS_GET_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认模型
|
||||
*/
|
||||
@Override
|
||||
public void setDefaultModel(String modelType, int isDefault) {
|
||||
// 参数验证
|
||||
if (StringUtils.isBlank(modelType)) {
|
||||
throw new RenException(ErrorCode.MODEL_TYPE_PROVIDE_CODE_NOT_NULL);
|
||||
}
|
||||
|
||||
ModelConfigEntity entity = new ModelConfigEntity();
|
||||
entity.setIsDefault(isDefault);
|
||||
modelConfigDao.update(entity, new QueryWrapper<ModelConfigEntity>()
|
||||
.eq("model_type", modelType));
|
||||
|
||||
// 清除相关缓存
|
||||
clearModelCacheByType(modelType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型提供者
|
||||
*/
|
||||
private void validateModelProvider(String modelType, String provideCode) {
|
||||
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
|
||||
if (CollectionUtil.isEmpty(providerList)) {
|
||||
throw new RenException(ErrorCode.MODEL_PROVIDER_NOT_EXIST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库获取原始配置(不经过敏感数据处理)
|
||||
*/
|
||||
private ModelConfigEntity getOriginalConfigFromDb(String id) {
|
||||
ModelConfigEntity originalEntity = modelConfigDao.selectById(id);
|
||||
if (originalEntity == null) {
|
||||
throw new RenException(ErrorCode.RESOURCE_NOT_FOUND);
|
||||
}
|
||||
return originalEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证LLM配置
|
||||
*/
|
||||
private void validateLlmConfiguration(ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
if (modelConfigBodyDTO.getConfigJson() != null && modelConfigBodyDTO.getConfigJson().containsKey("llm")) {
|
||||
String llm = modelConfigBodyDTO.getConfigJson().get("llm").toString();
|
||||
ModelConfigEntity modelConfigEntity = modelConfigDao.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getId, llm));
|
||||
|
||||
if (modelConfigEntity == null) {
|
||||
throw new RenException(ErrorCode.LLM_NOT_EXIST);
|
||||
}
|
||||
|
||||
String modelType = modelConfigEntity.getModelType();
|
||||
if (modelType == null || !"LLM".equals(modelType.toUpperCase())) {
|
||||
throw new RenException(ErrorCode.LLM_NOT_EXIST);
|
||||
}
|
||||
|
||||
// 验证LLM类型
|
||||
JSONObject configJson = modelConfigEntity.getConfigJson();
|
||||
if (configJson != null && configJson.containsKey("type")) {
|
||||
String type = configJson.get("type").toString();
|
||||
if (!"openai".equals(type) && !"ollama".equals(type)) {
|
||||
throw new RenException(ErrorCode.INVALID_LLM_TYPE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备更新实体,处理敏感数据
|
||||
*/
|
||||
private ModelConfigEntity prepareUpdateEntity(ModelConfigBodyDTO modelConfigBodyDTO,
|
||||
ModelConfigEntity originalEntity,
|
||||
String modelType,
|
||||
String id) {
|
||||
// 1. 复制原始实体,保留所有原始数据(包括敏感信息)
|
||||
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(originalEntity, ModelConfigEntity.class);
|
||||
modelConfigEntity.setId(id);
|
||||
modelConfigEntity.setModelType(modelType);
|
||||
|
||||
// 2. 只更新非敏感字段
|
||||
modelConfigEntity.setModelName(modelConfigBodyDTO.getModelName());
|
||||
modelConfigEntity.setSort(modelConfigBodyDTO.getSort());
|
||||
modelConfigEntity.setIsEnabled(modelConfigBodyDTO.getIsEnabled());
|
||||
// 3. 处理配置JSON,仅更新非敏感字段和明确修改的敏感字段
|
||||
if (modelConfigBodyDTO.getConfigJson() != null && originalEntity.getConfigJson() != null) {
|
||||
JSONObject originalJson = originalEntity.getConfigJson();
|
||||
JSONObject updatedJson = new JSONObject(originalJson); // 基于原始JSON进行修改
|
||||
|
||||
// 遍历更新的JSON,只更新非敏感字段或确实被修改的敏感字段
|
||||
for (String key : modelConfigBodyDTO.getConfigJson().keySet()) {
|
||||
Object value = modelConfigBodyDTO.getConfigJson().get(key);
|
||||
|
||||
// 如果是敏感字段,需要确认是否真的被修改(前端传入的可能是掩码后的值)
|
||||
if (SensitiveDataUtils.isSensitiveField(key)) {
|
||||
|
||||
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
|
||||
updatedJson.put(key, value);
|
||||
}
|
||||
} else if (value instanceof JSONObject) {
|
||||
// 递归处理嵌套JSON
|
||||
mergeJson(updatedJson, key, (JSONObject) value);
|
||||
} else {
|
||||
// 非敏感字段直接更新
|
||||
updatedJson.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
modelConfigEntity.setConfigJson(updatedJson);
|
||||
}
|
||||
|
||||
return modelConfigEntity;
|
||||
}
|
||||
|
||||
// 辅助方法:判断值是否是掩码格式
|
||||
private boolean isMaskedValue(String value) {
|
||||
if (value == null)
|
||||
return false;
|
||||
// 简单判断是否包含掩码的特征(***)
|
||||
return value.contains("***");
|
||||
}
|
||||
|
||||
// 辅助方法:递归合并JSON,保留原始敏感字段
|
||||
private void mergeJson(JSONObject original, String key, JSONObject updated) {
|
||||
if (!original.containsKey(key)) {
|
||||
original.put(key, new JSONObject());
|
||||
}
|
||||
JSONObject originalChild = original.getJSONObject(key);
|
||||
|
||||
for (String childKey : updated.keySet()) {
|
||||
Object childValue = updated.get(childKey);
|
||||
if (childValue instanceof JSONObject) {
|
||||
mergeJson(originalChild, childKey, (JSONObject) childValue);
|
||||
} else {
|
||||
if (!SensitiveDataUtils.isSensitiveField(childKey) ||
|
||||
(childValue instanceof String && !isMaskedValue((String) childValue))) {
|
||||
originalChild.put(childKey, childValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备新增实体
|
||||
*/
|
||||
private ModelConfigEntity prepareAddEntity(ModelConfigBodyDTO modelConfigBodyDTO, String modelType) {
|
||||
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
|
||||
modelConfigEntity.setModelType(modelType);
|
||||
modelConfigEntity.setIsDefault(0);
|
||||
return modelConfigEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建返回的DTO,处理敏感数据
|
||||
*/
|
||||
private ModelConfigDTO buildResponseDTO(ModelConfigEntity entity) {
|
||||
ModelConfigDTO dto = ConvertUtils.sourceToTarget(entity, ModelConfigDTO.class);
|
||||
if (dto.getConfigJson() != null) {
|
||||
dto.setConfigJson(maskSensitiveFields(dto.getConfigJson()));
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理敏感字段
|
||||
*/
|
||||
private JSONObject maskSensitiveFields(JSONObject configJson) {
|
||||
return SensitiveDataUtils.maskSensitiveFields(configJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除模型缓存
|
||||
*/
|
||||
private void clearModelCache(String id) {
|
||||
redisUtils.delete(RedisKeys.getModelConfigById(id));
|
||||
redisUtils.delete(RedisKeys.getModelNameById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模型类型清除缓存
|
||||
*/
|
||||
private void clearModelCacheByType(String modelType) {
|
||||
List<ModelConfigEntity> entities = modelConfigDao.selectList(
|
||||
new QueryWrapper<ModelConfigEntity>().eq("model_type", modelType));
|
||||
for (ModelConfigEntity entity : entities) {
|
||||
clearModelCache(entity.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查智能体配置是否有引用
|
||||
*
|
||||
* @param modelId 模型ID
|
||||
*/
|
||||
private void checkAgentReference(String modelId) {
|
||||
List<AgentEntity> agents = agentDao.selectList(
|
||||
@@ -180,14 +467,12 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
String agentNames = agents.stream()
|
||||
.map(AgentEntity::getAgentName)
|
||||
.collect(Collectors.joining("、"));
|
||||
throw new RenException(String.format("该模型配置已被智能体[%s]引用,无法删除", agentNames));
|
||||
throw new RenException(ErrorCode.MODEL_REFERENCED_BY_AGENT, agentNames);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查意图识别配置是否有引用
|
||||
*
|
||||
* @param modelId 模型ID
|
||||
*/
|
||||
private void checkIntentConfigReference(String modelId) {
|
||||
ModelConfigEntity modelConfig = modelConfigDao.selectById(modelId);
|
||||
@@ -198,58 +483,16 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
.eq("model_type", "Intent")
|
||||
.like("config_json", "%" + modelId + "%"));
|
||||
if (!intentConfigs.isEmpty()) {
|
||||
throw new RenException("该LLM模型已被意图识别配置引用,无法删除");
|
||||
throw new RenException(ErrorCode.LLM_REFERENCED_BY_INTENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取符合条件的TTS平台列表
|
||||
*/
|
||||
@Override
|
||||
public String getModelNameById(String id) {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String cachedName = (String) redisUtils.get(RedisKeys.getModelNameById(id));
|
||||
|
||||
if (StringUtils.isNotBlank(cachedName)) {
|
||||
return cachedName;
|
||||
}
|
||||
|
||||
ModelConfigEntity entity = modelConfigDao.selectById(id);
|
||||
if (entity != null) {
|
||||
String modelName = entity.getModelName();
|
||||
if (StringUtils.isNotBlank(modelName)) {
|
||||
redisUtils.set(RedisKeys.getModelNameById(id), modelName);
|
||||
}
|
||||
return modelName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelConfigEntity getModelById(String id, boolean isCache) {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return null;
|
||||
}
|
||||
if (isCache) {
|
||||
ModelConfigEntity cachedConfig = (ModelConfigEntity) redisUtils.get(RedisKeys.getModelConfigById(id));
|
||||
if (cachedConfig != null) {
|
||||
return ConvertUtils.sourceToTarget(cachedConfig, ModelConfigEntity.class);
|
||||
}
|
||||
}
|
||||
ModelConfigEntity entity = modelConfigDao.selectById(id);
|
||||
if (entity != null) {
|
||||
redisUtils.set(RedisKeys.getModelConfigById(id), entity);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDefaultModel(String modelType, int isDefault) {
|
||||
ModelConfigEntity entity = new ModelConfigEntity();
|
||||
entity.setIsDefault(isDefault);
|
||||
modelConfigDao.update(entity, new QueryWrapper<ModelConfigEntity>()
|
||||
.eq("model_type", modelType));
|
||||
public List<Map<String, Object>> getTtsPlatformList() {
|
||||
return modelConfigDao.getTtsPlatformList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
@@ -109,7 +110,7 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
|
||||
modelProviderDTO.setFields(modelProviderDTO.getFields());
|
||||
ModelProviderEntity entity = ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class);
|
||||
if (modelProviderDao.insert(entity) == 0) {
|
||||
throw new RenException("新增数据失败");
|
||||
throw new RenException(ErrorCode.ADD_DATA_FAILED);
|
||||
}
|
||||
|
||||
return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
|
||||
@@ -122,7 +123,7 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
|
||||
modelProviderDTO.setUpdateDate(new Date());
|
||||
if (modelProviderDao
|
||||
.updateById(ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class)) == 0) {
|
||||
throw new RenException("修改数据失败");
|
||||
throw new RenException(ErrorCode.UPDATE_DATA_FAILED);
|
||||
}
|
||||
return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
|
||||
}
|
||||
@@ -130,14 +131,14 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
if (modelProviderDao.deleteById(id) == 0) {
|
||||
throw new RenException("删除数据失败");
|
||||
throw new RenException(ErrorCode.DELETE_DATA_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(List<String> ids) {
|
||||
if (modelProviderDao.deleteBatchIds(ids) == 0) {
|
||||
throw new RenException("删除数据失败");
|
||||
throw new RenException(ErrorCode.DELETE_DATA_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,10 @@ public class ShiroConfig {
|
||||
// 将config路径使用server服务过滤器
|
||||
filterMap.put("/config/**", "server");
|
||||
filterMap.put("/agent/chat-history/report", "server");
|
||||
filterMap.put("/agent/chat-history/download/**", "anon");
|
||||
filterMap.put("/agent/saveMemory/**", "server");
|
||||
filterMap.put("/agent/play/**", "anon");
|
||||
filterMap.put("/voiceClone/play/**", "anon");
|
||||
filterMap.put("/**", "oauth2");
|
||||
shiroFilter.setFilterChainDefinitionMap(filterMap);
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ package xiaozhi.modules.security.config;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
|
||||
@@ -12,8 +15,10 @@ import org.springframework.http.converter.ResourceHttpMessageConverter;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
|
||||
import org.springframework.web.servlet.LocaleResolver;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -95,5 +100,47 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
converter.setObjectMapper(mapper);
|
||||
return converter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 国际化配置 - 根据请求头中的Accept-Language设置语言环境
|
||||
*/
|
||||
@Bean
|
||||
public LocaleResolver localeResolver() {
|
||||
return new AcceptHeaderLocaleResolver() {
|
||||
@Override
|
||||
public Locale resolveLocale(HttpServletRequest request) {
|
||||
String acceptLanguage = request.getHeader("Accept-Language");
|
||||
if (acceptLanguage == null || acceptLanguage.isEmpty()) {
|
||||
return Locale.getDefault();
|
||||
}
|
||||
|
||||
// 解析Accept-Language请求头中的首选语言
|
||||
String[] languages = acceptLanguage.split(",");
|
||||
if (languages.length > 0) {
|
||||
// 提取第一个语言代码,去除可能的质量值(q=...)
|
||||
String[] parts = languages[0].split(";" + "\\s*");
|
||||
String primaryLanguage = parts[0].trim();
|
||||
|
||||
// 根据前端发送的语言代码直接创建Locale对象
|
||||
if (primaryLanguage.equals("zh-CN")) {
|
||||
return Locale.SIMPLIFIED_CHINESE;
|
||||
} else if (primaryLanguage.equals("zh-TW")) {
|
||||
return Locale.TRADITIONAL_CHINESE;
|
||||
} else if (primaryLanguage.equals("en-US")) {
|
||||
return Locale.US;
|
||||
} else if (primaryLanguage.startsWith("zh")) {
|
||||
// 对于其他中文变体,默认使用简体中文
|
||||
return Locale.SIMPLIFIED_CHINESE;
|
||||
} else if (primaryLanguage.startsWith("en")) {
|
||||
// 对于其他英文变体,默认使用美式英语
|
||||
return Locale.US;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配的语言,使用默认语言
|
||||
return Locale.getDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
@@ -31,6 +32,8 @@ import xiaozhi.modules.security.password.PasswordUtils;
|
||||
import xiaozhi.modules.security.service.CaptchaService;
|
||||
import xiaozhi.modules.security.service.SysUserTokenService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.common.utils.Sm2DecryptUtil;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import xiaozhi.modules.sys.dto.PasswordDTO;
|
||||
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
|
||||
import xiaozhi.modules.sys.dto.SysUserDTO;
|
||||
@@ -42,6 +45,7 @@ import xiaozhi.modules.sys.vo.SysDictDataItem;
|
||||
/**
|
||||
* 登录控制层
|
||||
*/
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/user")
|
||||
@@ -66,14 +70,15 @@ public class LoginController {
|
||||
@Operation(summary = "短信验证码")
|
||||
public Result<Void> smsVerification(@RequestBody SmsVerificationDTO dto) {
|
||||
// 验证图形验证码
|
||||
boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), true);
|
||||
boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), false);
|
||||
if (!validate) {
|
||||
throw new RenException("图形验证码错误");
|
||||
throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR);
|
||||
}
|
||||
|
||||
Boolean isMobileRegister = sysParamsService
|
||||
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
|
||||
if (!isMobileRegister) {
|
||||
throw new RenException("没有开启手机注册,没法使用短信验证码功能");
|
||||
throw new RenException(ErrorCode.MOBILE_REGISTER_DISABLED);
|
||||
}
|
||||
// 发送短信验证码
|
||||
captchaService.sendSMSValidateCode(dto.getPhone());
|
||||
@@ -83,30 +88,44 @@ public class LoginController {
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "登录")
|
||||
public Result<TokenDTO> login(@RequestBody LoginDTO login) {
|
||||
// 验证是否正确输入验证码
|
||||
boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
|
||||
if (!validate) {
|
||||
throw new RenException("图形验证码错误,请重新获取");
|
||||
}
|
||||
String password = login.getPassword();
|
||||
|
||||
// 使用工具类解密并验证验证码
|
||||
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
|
||||
password, login.getCaptchaId(), captchaService, sysParamsService);
|
||||
|
||||
login.setPassword(actualPassword);
|
||||
|
||||
// 按照用户名获取用户
|
||||
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
|
||||
// 判断用户是否存在
|
||||
if (userDTO == null) {
|
||||
throw new RenException("请检测用户和密码是否输入错误");
|
||||
throw new RenException(ErrorCode.ACCOUNT_PASSWORD_ERROR);
|
||||
}
|
||||
// 判断密码是否正确,不一样则进入if
|
||||
if (!PasswordUtils.matches(login.getPassword(), userDTO.getPassword())) {
|
||||
throw new RenException("请检测用户和密码是否输入错误");
|
||||
throw new RenException(ErrorCode.ACCOUNT_PASSWORD_ERROR);
|
||||
}
|
||||
return sysUserTokenService.createToken(userDTO.getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "注册")
|
||||
public Result<Void> register(@RequestBody LoginDTO login) {
|
||||
if (!sysUserService.getAllowUserRegister()) {
|
||||
throw new RenException("当前不允许普通用户注册");
|
||||
throw new RenException(ErrorCode.USER_REGISTER_DISABLED);
|
||||
}
|
||||
|
||||
String password = login.getPassword();
|
||||
|
||||
// 使用工具类解密并验证验证码
|
||||
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
|
||||
password, login.getCaptchaId(), captchaService, sysParamsService);
|
||||
|
||||
login.setPassword(actualPassword);
|
||||
|
||||
// 是否开启手机注册
|
||||
Boolean isMobileRegister = sysParamsService
|
||||
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
|
||||
@@ -115,25 +134,19 @@ public class LoginController {
|
||||
// 验证用户是否是手机号码
|
||||
boolean validPhone = ValidatorUtils.isValidPhone(login.getUsername());
|
||||
if (!validPhone) {
|
||||
throw new RenException("用户名不是手机号码,请重新输入");
|
||||
throw new RenException(ErrorCode.USERNAME_NOT_PHONE);
|
||||
}
|
||||
// 验证短信验证码是否正常
|
||||
validate = captchaService.validateSMSValidateCode(login.getUsername(), login.getMobileCaptcha(), false);
|
||||
if (!validate) {
|
||||
throw new RenException("手机验证码错误,请重新获取");
|
||||
}
|
||||
} else {
|
||||
// 验证是否正确输入验证码
|
||||
validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
|
||||
if (!validate) {
|
||||
throw new RenException("图形验证码错误,请重新获取");
|
||||
throw new RenException(ErrorCode.SMS_CODE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// 按照用户名获取用户
|
||||
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
|
||||
if (userDTO != null) {
|
||||
throw new RenException("此手机号码已经注册过");
|
||||
throw new RenException(ErrorCode.PHONE_ALREADY_REGISTERED);
|
||||
}
|
||||
userDTO = new SysUserDTO();
|
||||
userDTO.setUsername(login.getUsername());
|
||||
@@ -168,28 +181,36 @@ public class LoginController {
|
||||
Boolean isMobileRegister = sysParamsService
|
||||
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
|
||||
if (!isMobileRegister) {
|
||||
throw new RenException("没有开启手机注册,没法使用找回密码功能");
|
||||
throw new RenException(ErrorCode.RETRIEVE_PASSWORD_DISABLED);
|
||||
}
|
||||
// 判断非空
|
||||
ValidatorUtils.validateEntity(dto);
|
||||
// 验证用户是否是手机号码
|
||||
boolean validPhone = ValidatorUtils.isValidPhone(dto.getPhone());
|
||||
if (!validPhone) {
|
||||
throw new RenException("输入的手机号码格式不正确");
|
||||
throw new RenException(ErrorCode.PHONE_FORMAT_ERROR);
|
||||
}
|
||||
|
||||
// 按照用户名获取用户
|
||||
SysUserDTO userDTO = sysUserService.getByUsername(dto.getPhone());
|
||||
if (userDTO == null) {
|
||||
throw new RenException("输入的手机号码未注册");
|
||||
throw new RenException(ErrorCode.PHONE_NOT_REGISTERED);
|
||||
}
|
||||
// 验证短信验证码是否正常
|
||||
boolean validate = captchaService.validateSMSValidateCode(dto.getPhone(), dto.getCode(), false);
|
||||
// 判断是否通过验证
|
||||
if (!validate) {
|
||||
throw new RenException("输入的手机验证码错误");
|
||||
throw new RenException(ErrorCode.SMS_CODE_ERROR);
|
||||
}
|
||||
|
||||
String password = dto.getPassword();
|
||||
|
||||
// 使用工具类解密并验证验证码
|
||||
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
|
||||
password, dto.getCaptchaId(), captchaService, sysParamsService);
|
||||
|
||||
dto.setPassword(actualPassword);
|
||||
|
||||
sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword());
|
||||
return new Result<>();
|
||||
}
|
||||
@@ -208,6 +229,13 @@ public class LoginController {
|
||||
config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true));
|
||||
config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true));
|
||||
config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
|
||||
|
||||
// SM2公钥
|
||||
String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true);
|
||||
if (StringUtils.isBlank(publicKey)) {
|
||||
throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED);
|
||||
}
|
||||
config.put("sm2PublicKey", publicKey);
|
||||
|
||||
return new Result<Map<String, Object>>().ok(config);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,6 @@ public class LoginDTO implements Serializable {
|
||||
@NotBlank(message = "{sysuser.password.require}")
|
||||
private String password;
|
||||
|
||||
@Schema(description = "验证码")
|
||||
@NotBlank(message = "{sysuser.captcha.require}")
|
||||
private String captcha;
|
||||
|
||||
@Schema(description = "手机验证码")
|
||||
private String mobileCaptcha;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.utils.HttpContextUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
|
||||
/**
|
||||
@@ -82,8 +83,8 @@ public class Oauth2Filter extends AuthenticatingFilter {
|
||||
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
|
||||
try {
|
||||
Throwable throwable = e.getCause() == null ? e : e.getCause();
|
||||
Result<Void> r = new Result<Void>().error(ErrorCode.UNAUTHORIZED, throwable.getMessage());
|
||||
// 使用国际化消息替代直接使用异常消息
|
||||
Result<Void> r = new Result<Void>().error(ErrorCode.UNAUTHORIZED);
|
||||
|
||||
String json = JsonUtils.toJsonString(r);
|
||||
httpResponse.getWriter().print(json);
|
||||
|
||||
@@ -3,6 +3,7 @@ package xiaozhi.modules.security.oauth2;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.UUID;
|
||||
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
|
||||
/**
|
||||
@@ -38,7 +39,7 @@ public class TokenGenerator {
|
||||
byte[] messageDigest = algorithm.digest();
|
||||
return toHexString(messageDigest);
|
||||
} catch (Exception e) {
|
||||
throw new RenException("token invalid", e);
|
||||
throw new RenException(ErrorCode.TOKEN_GENERATE_ERROR, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.wf.captcha.base.Captcha;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
@@ -88,7 +89,7 @@ public class CaptchaServiceImpl implements CaptchaService {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long timeDiff = currentTime - lastSendTimeLong;
|
||||
if (timeDiff < 60000) {
|
||||
throw new RenException("发送太频繁,请" + (60000 - timeDiff) / 1000 + "秒后再试");
|
||||
throw new RenException(ErrorCode.SMS_SEND_TOO_FREQUENTLY, String.valueOf((60000 - timeDiff) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ public class CaptchaServiceImpl implements CaptchaService {
|
||||
}
|
||||
|
||||
if (todayCount >= maxSendCount) {
|
||||
throw new RenException("今日发送次数已达上限");
|
||||
throw new RenException(ErrorCode.TODAY_SMS_LIMIT_REACHED);
|
||||
}
|
||||
|
||||
String key = RedisKeys.getSMSValidateCodeKey(phone);
|
||||
|
||||
@@ -9,6 +9,7 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
@@ -45,7 +46,7 @@ public class ALiYunSmsService implements SmsService {
|
||||
redisUtils.delete(todayCountKey);
|
||||
// 错误 message
|
||||
log.error(e.getMessage());
|
||||
throw new RenException("短信发送失败");
|
||||
throw new RenException(ErrorCode.SMS_SEND_FAILED);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -70,7 +71,7 @@ public class ALiYunSmsService implements SmsService {
|
||||
}catch (Exception e){
|
||||
// 错误 message
|
||||
log.error(e.getMessage());
|
||||
throw new RenException("短信连接建立失败");
|
||||
throw new RenException(ErrorCode.SMS_CONNECTION_FAILED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.annotation.LogOperation;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.sys.dto.EmitSeverActionDTO;
|
||||
@@ -64,17 +65,17 @@ public class ServerSideManageController {
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<Boolean> emitServerAction(@RequestBody @Valid EmitSeverActionDTO emitSeverActionDTO) {
|
||||
if (emitSeverActionDTO.getAction() == null) {
|
||||
throw new RenException("无效服务端操作");
|
||||
throw new RenException(ErrorCode.INVALID_SERVER_ACTION);
|
||||
}
|
||||
String wsText = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||
if (StringUtils.isBlank(wsText)) {
|
||||
throw new RenException("未配置服务端WebSocket地址");
|
||||
throw new RenException(ErrorCode.SERVER_WEBSOCKET_NOT_CONFIGURED);
|
||||
}
|
||||
String targetWs = emitSeverActionDTO.getTargetWs();
|
||||
String[] wsList = wsText.split(";");
|
||||
// 找到需要发起的
|
||||
if (StringUtils.isBlank(targetWs) || !Arrays.asList(wsList).contains(targetWs)) {
|
||||
throw new RenException("目标WebSocket地址不存在");
|
||||
throw new RenException(ErrorCode.TARGET_WEBSOCKET_NOT_EXIST);
|
||||
}
|
||||
return new Result<Boolean>().ok(emitServerActionByWs(targetWs, emitSeverActionDTO.getAction()));
|
||||
}
|
||||
@@ -114,7 +115,7 @@ public class ServerSideManageController {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// 捕获全部错误,由全局异常处理器返回
|
||||
throw new RenException("WebSocket连接失败或连接超时");
|
||||
throw new RenException(ErrorCode.WEB_SOCKET_CONNECT_FAILED);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.annotation.LogOperation;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.validator.AssertUtils;
|
||||
@@ -107,9 +108,12 @@ public class SysParamsController {
|
||||
// 验证MCP地址
|
||||
validateMcpUrl(dto.getParamCode(), dto.getParamValue());
|
||||
|
||||
//
|
||||
// 验证声纹地址
|
||||
validateVoicePrint(dto.getParamCode(), dto.getParamValue());
|
||||
|
||||
// 校验mqtt密钥长度
|
||||
validateMqttSecretLength(dto.getParamCode(), dto.getParamValue());
|
||||
|
||||
sysParamsService.update(dto);
|
||||
configService.getConfig(false);
|
||||
return new Result<Void>();
|
||||
@@ -127,23 +131,23 @@ public class SysParamsController {
|
||||
}
|
||||
String[] wsUrls = urls.split("\\;");
|
||||
if (wsUrls.length == 0) {
|
||||
throw new RenException("WebSocket地址列表不能为空");
|
||||
throw new RenException(ErrorCode.WEBSOCKET_URLS_EMPTY);
|
||||
}
|
||||
for (String url : wsUrls) {
|
||||
if (StringUtils.isNotBlank(url)) {
|
||||
// 检查是否包含localhost或127.0.0.1
|
||||
if (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||
throw new RenException("WebSocket地址不能使用localhost或127.0.0.1");
|
||||
throw new RenException(ErrorCode.WEBSOCKET_URL_LOCALHOST);
|
||||
}
|
||||
|
||||
// 验证WebSocket地址格式
|
||||
if (!WebSocketValidator.validateUrlFormat(url)) {
|
||||
throw new RenException("WebSocket地址格式不正确: " + url);
|
||||
throw new RenException(ErrorCode.WEBSOCKET_URL_FORMAT_ERROR);
|
||||
}
|
||||
|
||||
// 测试WebSocket连接
|
||||
if (!WebSocketValidator.testConnection(url)) {
|
||||
throw new RenException("WebSocket连接测试失败: " + url);
|
||||
throw new RenException(ErrorCode.WEBSOCKET_CONNECTION_FAILED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,35 +174,35 @@ public class SysParamsController {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(url) || url.equals("null")) {
|
||||
throw new RenException("OTA地址不能为空");
|
||||
throw new RenException(ErrorCode.OTA_URL_EMPTY);
|
||||
}
|
||||
|
||||
// 检查是否包含localhost或127.0.0.1
|
||||
if (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||
throw new RenException("OTA地址不能使用localhost或127.0.0.1");
|
||||
throw new RenException(ErrorCode.OTA_URL_LOCALHOST);
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
if (!url.toLowerCase().startsWith("http")) {
|
||||
throw new RenException("OTA地址必须以http或https开头");
|
||||
throw new RenException(ErrorCode.OTA_URL_PROTOCOL_ERROR);
|
||||
}
|
||||
if (!url.endsWith("/ota/")) {
|
||||
throw new RenException("OTA地址必须以/ota/结尾");
|
||||
throw new RenException(ErrorCode.OTA_URL_FORMAT_ERROR);
|
||||
}
|
||||
|
||||
try {
|
||||
// 发送GET请求
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
throw new RenException("OTA接口访问失败,状态码:" + response.getStatusCode());
|
||||
throw new RenException(ErrorCode.OTA_INTERFACE_ACCESS_FAILED);
|
||||
}
|
||||
// 检查响应内容是否包含OTA相关信息
|
||||
String body = response.getBody();
|
||||
if (body == null || !body.contains("OTA")) {
|
||||
throw new RenException("OTA接口返回内容格式不正确,可能不是一个真实的OTA接口");
|
||||
throw new RenException(ErrorCode.OTA_INTERFACE_FORMAT_ERROR);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RenException("OTA接口验证失败:" + e.getMessage());
|
||||
throw new RenException(ErrorCode.OTA_INTERFACE_VALIDATION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,61 +211,86 @@ public class SysParamsController {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(url) || url.equals("null")) {
|
||||
throw new RenException("MCP地址不能为空");
|
||||
throw new RenException(ErrorCode.MCP_URL_EMPTY);
|
||||
}
|
||||
if (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||
throw new RenException("MCP地址不能使用localhost或127.0.0.1");
|
||||
throw new RenException(ErrorCode.MCP_URL_LOCALHOST);
|
||||
}
|
||||
if (!url.toLowerCase().contains("key")) {
|
||||
throw new RenException("不是正确的MCP地址");
|
||||
throw new RenException(ErrorCode.MCP_URL_INVALID);
|
||||
}
|
||||
|
||||
try {
|
||||
// 发送GET请求
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
throw new RenException("MCP接口访问失败,状态码:" + response.getStatusCode());
|
||||
throw new RenException(ErrorCode.MCP_INTERFACE_ACCESS_FAILED);
|
||||
}
|
||||
// 检查响应内容是否包含mcp相关信息
|
||||
String body = response.getBody();
|
||||
if (body == null || !body.contains("success")) {
|
||||
throw new RenException("MCP接口返回内容格式不正确,可能不是一个真实的MCP接口");
|
||||
throw new RenException(ErrorCode.MCP_INTERFACE_FORMAT_ERROR);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RenException("MCP接口验证失败:" + e.getMessage());
|
||||
throw new RenException(ErrorCode.MCP_INTERFACE_VALIDATION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证声纹接口地址是否正常
|
||||
private void validateVoicePrint(String paramCode, String url) {
|
||||
if (!paramCode.equals(Constant.SERVER_VOICE_PRINT)) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(url) || url.equals("null")) {
|
||||
throw new RenException("声纹接口地址不能为空");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_URL_EMPTY);
|
||||
}
|
||||
if (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||
throw new RenException("声纹接口地址不能使用localhost或127.0.0.1");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_URL_LOCALHOST);
|
||||
}
|
||||
if (!url.toLowerCase().contains("key")) {
|
||||
throw new RenException("不是正确的声纹接口地址");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_URL_INVALID);
|
||||
}
|
||||
// 验证URL格式
|
||||
if (!url.toLowerCase().startsWith("http")) {
|
||||
throw new RenException("声纹接口地址必须以http或https开头");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_URL_PROTOCOL_ERROR);
|
||||
}
|
||||
try {
|
||||
// 发送GET请求
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
throw new RenException("声纹接口访问失败,状态码:" + response.getStatusCode());
|
||||
throw new RenException(ErrorCode.VOICEPRINT_INTERFACE_ACCESS_FAILED);
|
||||
}
|
||||
// 检查响应内容
|
||||
String body = response.getBody();
|
||||
if (body == null || !body.contains("healthy")) {
|
||||
throw new RenException("声纹接口返回内容格式不正确,可能不是一个真实的MCP接口");
|
||||
throw new RenException(ErrorCode.VOICEPRINT_INTERFACE_FORMAT_ERROR);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RenException("声纹接口验证失败:" + e.getMessage());
|
||||
throw new RenException(ErrorCode.VOICEPRINT_INTERFACE_VALIDATION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验mqtt密钥长度和复杂度
|
||||
private void validateMqttSecretLength(String paramCode, String secret) {
|
||||
if (!paramCode.equals(Constant.SERVER_MQTT_SECRET)) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(secret) || secret.equals("null")) {
|
||||
throw new RenException(ErrorCode.MQTT_SECRET_EMPTY);
|
||||
}
|
||||
if (secret.length() < 8) {
|
||||
throw new RenException(ErrorCode.MQTT_SECRET_LENGTH_INSECURE);
|
||||
}
|
||||
// 检查是否同时包含大小写字母
|
||||
if (!secret.matches(".*[a-z].*") || !secret.matches(".*[A-Z].*")) {
|
||||
throw new RenException(ErrorCode.MQTT_SECRET_CHARACTER_INSECURE);
|
||||
}
|
||||
// 不允许包含弱密码
|
||||
String[] weakPasswords = { "test", "1234", "admin", "password", "qwerty", "xiaozhi" };
|
||||
for (String weakPassword : weakPasswords) {
|
||||
if (secret.toLowerCase().contains(weakPassword)) {
|
||||
throw new RenException(ErrorCode.MQTT_SECRET_WEAK_PASSWORD);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ public class RetrievePasswordDTO implements Serializable {
|
||||
@NotBlank(message = "{sysuser.password.require}")
|
||||
private String password;
|
||||
|
||||
@Schema(description = "图形验证码ID")
|
||||
@NotBlank(message = "{sysuser.uuid.require}")
|
||||
private String captchaId;
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
@@ -153,7 +154,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
||||
}
|
||||
boolean exists = baseDao.exists(queryWrapper);
|
||||
if (exists) {
|
||||
throw new RenException("字典标签重复");
|
||||
throw new RenException(ErrorCode.DICT_LABEL_DUPLICATE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
@@ -66,7 +67,7 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
|
||||
public SysDictTypeVO get(Long id) {
|
||||
SysDictTypeEntity entity = baseDao.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new RenException("字典类型不存在");
|
||||
throw new RenException(ErrorCode.DICT_TYPE_NOT_EXIST);
|
||||
}
|
||||
|
||||
return ConvertUtils.sourceToTarget(entity, SysDictTypeVO.class);
|
||||
@@ -147,7 +148,7 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
|
||||
}
|
||||
boolean exists = baseDao.exists(queryWrapper);
|
||||
if (exists) {
|
||||
throw new RenException("字典类型编码重复");
|
||||
throw new RenException(ErrorCode.DICT_TYPE_DUPLICATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.SM2Utils;
|
||||
import xiaozhi.modules.sys.dao.SysParamsDao;
|
||||
import xiaozhi.modules.sys.dto.SysParamsDTO;
|
||||
import xiaozhi.modules.sys.entity.SysParamsEntity;
|
||||
@@ -206,6 +207,31 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
||||
String newSecret = UUID.randomUUID().toString();
|
||||
updateValueByCode(Constant.SERVER_SECRET, newSecret);
|
||||
}
|
||||
|
||||
// 初始化SM2密钥对
|
||||
initSM2KeyPair();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化SM2密钥对
|
||||
*/
|
||||
private void initSM2KeyPair() {
|
||||
// 获取SM2公钥
|
||||
String publicKey = getValue(Constant.SM2_PUBLIC_KEY, false);
|
||||
// 获取SM2私钥
|
||||
String privateKey = getValue(Constant.SM2_PRIVATE_KEY, false);
|
||||
|
||||
// 如果公钥或私钥为空,则生成新的密钥对
|
||||
if (StringUtils.isBlank(publicKey) || StringUtils.isBlank(privateKey) ||
|
||||
"null".equals(publicKey) || "null".equals(privateKey)) {
|
||||
Map<String, String> keyPair = SM2Utils.createKey();
|
||||
String newPublicKey = keyPair.get(SM2Utils.KEY_PUBLIC_KEY);
|
||||
String newPrivateKey = keyPair.get(SM2Utils.KEY_PRIVATE_KEY);
|
||||
|
||||
// 更新数据库中的密钥对
|
||||
updateValueByCode(Constant.SM2_PUBLIC_KEY, newPublicKey);
|
||||
updateValueByCode(Constant.SM2_PRIVATE_KEY, newPrivateKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,7 +115,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
|
||||
|
||||
// 判断旧密码是否正确
|
||||
if (!PasswordUtils.matches(passwordDTO.getPassword(), sysUserEntity.getPassword())) {
|
||||
throw new RenException("旧密码输入错误");
|
||||
throw new RenException(ErrorCode.OLD_PASSWORD_ERROR);
|
||||
}
|
||||
|
||||
// 新密码强度
|
||||
@@ -197,10 +197,31 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
|
||||
*/
|
||||
private String generatePassword() {
|
||||
StringBuilder password = new StringBuilder();
|
||||
for (int i = 0; i < 12; i++) {
|
||||
|
||||
// 确保包含至少一个数字
|
||||
password.append("0123456789".charAt(random.nextInt(10)));
|
||||
// 确保包含至少一个小写字母
|
||||
password.append("abcdefghijklmnopqrstuvwxyz".charAt(random.nextInt(26)));
|
||||
// 确保包含至少一个大写字母
|
||||
password.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ".charAt(random.nextInt(26)));
|
||||
// 确保包含至少一个特殊符号
|
||||
password.append("!@#$%^&*()".charAt(random.nextInt(10)));
|
||||
|
||||
// 生成剩余的8个字符
|
||||
for (int i = 4; i < 12; i++) {
|
||||
password.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
|
||||
}
|
||||
return password.toString();
|
||||
|
||||
// 打乱密码中字符的顺序
|
||||
char[] passwordChars = password.toString().toCharArray();
|
||||
for (int i = 0; i < passwordChars.length; i++) {
|
||||
int randomIndex = random.nextInt(passwordChars.length);
|
||||
char temp = passwordChars[i];
|
||||
passwordChars[i] = passwordChars[randomIndex];
|
||||
passwordChars[randomIndex] = temp;
|
||||
}
|
||||
|
||||
return new String(passwordChars);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -64,4 +64,13 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
* @return 音色名称
|
||||
*/
|
||||
String getTimbreNameById(String id);
|
||||
|
||||
/**
|
||||
* 根据音色编码获取音色信息
|
||||
*
|
||||
* @param ttsModelId 音色模型ID
|
||||
* @param voiceCode 音色编码
|
||||
* @return 音色信息
|
||||
*/
|
||||
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -15,18 +17,23 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.timbre.dao.TimbreDao;
|
||||
import xiaozhi.modules.timbre.dto.TimbreDataDTO;
|
||||
import xiaozhi.modules.timbre.dto.TimbrePageDTO;
|
||||
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
/**
|
||||
* 音色的业务层的实现
|
||||
@@ -39,6 +46,7 @@ import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
|
||||
|
||||
private final TimbreDao timbreDao;
|
||||
private final VoiceCloneDao voiceCloneDao;
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
@Override
|
||||
@@ -121,11 +129,30 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
queryWrapper.like("name", voiceName);
|
||||
}
|
||||
List<TimbreEntity> timbreEntities = timbreDao.selectList(queryWrapper);
|
||||
if (CollectionUtil.isEmpty(timbreEntities)) {
|
||||
return null;
|
||||
if (timbreEntities == null) {
|
||||
timbreEntities = new ArrayList<>();
|
||||
}
|
||||
List<VoiceDTO> voiceDTOs = timbreEntities.stream()
|
||||
.map(entity -> new VoiceDTO(entity.getId(), entity.getName()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 获取当前登录用户ID
|
||||
Long currentUserId = SecurityUser.getUser().getId();
|
||||
if (currentUserId != null) {
|
||||
// 查询用户的所有克隆音色记录
|
||||
List<VoiceDTO> cloneEntities = voiceCloneDao.getTrainSuccess(ttsModelId, currentUserId);
|
||||
for (VoiceDTO entity : cloneEntities) {
|
||||
// 只添加训练成功的克隆音色,且模型ID匹配
|
||||
VoiceDTO voiceDTO = new VoiceDTO();
|
||||
voiceDTO.setId(entity.getId());
|
||||
voiceDTO.setName(MessageUtils.getMessage(ErrorCode.VOICE_CLONE_PREFIX) + entity.getName());
|
||||
redisUtils.set(RedisKeys.getTimbreNameById(voiceDTO.getId()), voiceDTO.getName(),
|
||||
RedisUtils.NOT_EXPIRE);
|
||||
voiceDTOs.add(0, voiceDTO);
|
||||
}
|
||||
}
|
||||
|
||||
return ConvertUtils.sourceToTarget(timbreEntities, VoiceDTO.class);
|
||||
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,8 +181,30 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
redisUtils.set(RedisKeys.getTimbreNameById(id), name);
|
||||
}
|
||||
return name;
|
||||
} else {
|
||||
VoiceCloneEntity cloneEntity = voiceCloneDao.selectById(id);
|
||||
if (cloneEntity != null) {
|
||||
String name = MessageUtils.getMessage(ErrorCode.VOICE_CLONE_PREFIX) + cloneEntity.getName();
|
||||
redisUtils.set(RedisKeys.getTimbreNameById(id), name);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode) {
|
||||
if (StringUtils.isBlank(voiceCode)) {
|
||||
return null;
|
||||
}
|
||||
QueryWrapper<TimbreEntity> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("tts_model_id", ttsModelId);
|
||||
queryWrapper.eq("tts_voice", voiceCode);
|
||||
List<TimbreEntity> list = timbreDao.selectList(queryWrapper);
|
||||
if (list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new VoiceDTO(list.get(0).getId(), list.get(0).getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package xiaozhi.modules.voiceclone.controller;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.validator.ValidatorUtils;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.voiceclone.dto.VoiceCloneResponseDTO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
|
||||
|
||||
@Tag(name = "音色资源管理", description = "音色资源开通相关接口")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/voiceClone")
|
||||
public class VoiceCloneController {
|
||||
|
||||
private final VoiceCloneService voiceCloneService;
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询音色资源")
|
||||
@Parameters({
|
||||
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
|
||||
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true)
|
||||
})
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<PageData<VoiceCloneResponseDTO>> page(
|
||||
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||
ValidatorUtils.validateEntity(params);
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
params.put("userId", user.getId().toString());
|
||||
PageData<VoiceCloneResponseDTO> page = voiceCloneService.pageWithNames(params);
|
||||
return new Result<PageData<VoiceCloneResponseDTO>>().ok(page);
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上传音频进行声音克隆")
|
||||
@Parameters({
|
||||
@Parameter(name = "id", description = "声音克隆记录ID", required = true),
|
||||
@Parameter(name = "voiceFile", description = "音频文件", required = true)
|
||||
})
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> uploadVoice(
|
||||
@RequestParam("id") String id,
|
||||
@RequestParam("voiceFile") MultipartFile voiceFile) {
|
||||
try {
|
||||
// 验证文件
|
||||
if (voiceFile == null || voiceFile.isEmpty()) {
|
||||
return new Result<String>().error(ErrorCode.VOICE_CLONE_AUDIO_EMPTY);
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
String contentType = voiceFile.getContentType();
|
||||
if (contentType == null || !contentType.startsWith("audio/")) {
|
||||
return new Result<String>().error(ErrorCode.VOICE_CLONE_NOT_AUDIO_FILE);
|
||||
}
|
||||
|
||||
// 验证文件大小 (最大10MB)
|
||||
if (voiceFile.getSize() > 10 * 1024 * 1024) {
|
||||
return new Result<String>().error(ErrorCode.VOICE_CLONE_AUDIO_TOO_LARGE);
|
||||
}
|
||||
// 检查权限
|
||||
checkPermission(id);
|
||||
// 调用服务层处理
|
||||
voiceCloneService.uploadVoice(id, voiceFile);
|
||||
|
||||
return new Result<String>();
|
||||
} catch (Exception e) {
|
||||
return new Result<String>().error(ErrorCode.VOICE_CLONE_UPLOAD_FAILED, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/updateName")
|
||||
@Operation(summary = "更新声音克隆名称")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> updateName(@RequestBody Map<String, String> params) {
|
||||
try {
|
||||
String id = params.get("id");
|
||||
String name = params.get("name");
|
||||
|
||||
if (id == null || id.isEmpty()) {
|
||||
return new Result<String>().error(ErrorCode.IDENTIFIER_NOT_NULL, "唯一标识不能为空");
|
||||
}
|
||||
if (name == null) {
|
||||
return new Result<String>().error(ErrorCode.NOT_NULL, "名称不能为空");
|
||||
}
|
||||
// 检查权限
|
||||
checkPermission(id);
|
||||
|
||||
voiceCloneService.updateName(id, name);
|
||||
redisUtils.delete(RedisKeys.getTimbreNameById(id));
|
||||
return new Result<String>();
|
||||
} catch (Exception e) {
|
||||
return new Result<String>().error(ErrorCode.UPDATE_DATA_FAILED, "更新失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/audio/{id}")
|
||||
@Operation(summary = "获取音频下载ID")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> getAudioId(@PathVariable("id") String id) {
|
||||
// 检查权限
|
||||
checkPermission(id);
|
||||
byte[] audioData = voiceCloneService.getVoiceData(id);
|
||||
if (audioData == null) {
|
||||
return new Result<String>().error(ErrorCode.RESOURCE_NOT_FOUND, "音频不存在");
|
||||
}
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
redisUtils.set(RedisKeys.getVoiceCloneAudioIdKey(uuid), id);
|
||||
return new Result<String>().ok(uuid);
|
||||
}
|
||||
|
||||
@GetMapping("/play/{uuid}")
|
||||
@Operation(summary = "播放音频")
|
||||
public void playVoice(@PathVariable("uuid") String uuid, HttpServletResponse response) {
|
||||
try {
|
||||
String id = (String) redisUtils.get(RedisKeys.getVoiceCloneAudioIdKey(uuid));
|
||||
redisUtils.delete(RedisKeys.getVoiceCloneAudioIdKey(uuid));
|
||||
if (StringUtils.isBlank(id)) {
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
// 获取音频数据
|
||||
byte[] voiceData = voiceCloneService.getVoiceData(id);
|
||||
|
||||
if (voiceData == null || voiceData.length == 0) {
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
response.setContentType("audio/wav");
|
||||
response.setContentLength(voiceData.length);
|
||||
response.setHeader("Content-Disposition", "inline; filename=voice.wav");
|
||||
|
||||
// 写入音频数据
|
||||
response.getOutputStream().write(voiceData);
|
||||
response.getOutputStream().flush();
|
||||
} catch (Exception e) {
|
||||
log.error("播放音频失败", e);
|
||||
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/cloneAudio")
|
||||
@Operation(summary = "复刻音频")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> cloneAudio(@RequestBody Map<String, String> params) {
|
||||
String cloneId = params.get("cloneId");
|
||||
checkPermission(cloneId);
|
||||
// 调用服务层进行语音克隆训练
|
||||
voiceCloneService.cloneAudio(cloneId);
|
||||
return new Result<String>();
|
||||
}
|
||||
|
||||
private void checkPermission(String id) {
|
||||
VoiceCloneEntity voiceClone = voiceCloneService.selectById(id);
|
||||
if (voiceClone == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_RECORD_NOT_EXIST);
|
||||
}
|
||||
if (!voiceClone.getUserId().equals(SecurityUser.getUser().getId())) {
|
||||
throw new RenException(ErrorCode.VOICE_RESOURCE_NO_PERMISSION);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package xiaozhi.modules.voiceclone.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.validator.ValidatorUtils;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.voiceclone.dto.VoiceCloneDTO;
|
||||
import xiaozhi.modules.voiceclone.dto.VoiceCloneResponseDTO;
|
||||
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
|
||||
|
||||
@Tag(name = "音色资源管理", description = "音色资源开通相关接口")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/voiceResource")
|
||||
public class VoiceResourceController {
|
||||
|
||||
private final VoiceCloneService voiceCloneService;
|
||||
private final ModelConfigService modelConfigService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询音色资源")
|
||||
@Parameters({
|
||||
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
|
||||
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true)
|
||||
})
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<PageData<VoiceCloneResponseDTO>> page(
|
||||
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||
ValidatorUtils.validateEntity(params);
|
||||
PageData<VoiceCloneResponseDTO> page = voiceCloneService.pageWithNames(params);
|
||||
return new Result<PageData<VoiceCloneResponseDTO>>().ok(page);
|
||||
}
|
||||
|
||||
@GetMapping("{id}")
|
||||
@Operation(summary = "获取音色资源详情")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<VoiceCloneResponseDTO> get(@PathVariable("id") String id) {
|
||||
VoiceCloneResponseDTO data = voiceCloneService.getByIdWithNames(id);
|
||||
return new Result<VoiceCloneResponseDTO>().ok(data);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "新增音色资源")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<Void> save(@RequestBody VoiceCloneDTO dto) {
|
||||
if (dto == null) {
|
||||
return new Result<Void>().error(ErrorCode.VOICE_RESOURCE_INFO_EMPTY);
|
||||
}
|
||||
if (dto.getModelId() == null || dto.getModelId().isEmpty()) {
|
||||
return new Result<Void>().error(ErrorCode.VOICE_RESOURCE_PLATFORM_NAME_EMPTY);
|
||||
}
|
||||
if (dto.getVoiceIds() == null || dto.getVoiceIds().isEmpty()) {
|
||||
return new Result<Void>().error(ErrorCode.VOICE_RESOURCE_ID_EMPTY);
|
||||
}
|
||||
if (dto.getUserId() == null) {
|
||||
return new Result<Void>().error(ErrorCode.VOICE_RESOURCE_ACCOUNT_EMPTY);
|
||||
}
|
||||
try {
|
||||
voiceCloneService.save(dto);
|
||||
return new Result<Void>();
|
||||
} catch (xiaozhi.common.exception.RenException e) {
|
||||
return new Result<Void>().error(e.getCode(), e.getMsg());
|
||||
} catch (RuntimeException e) {
|
||||
return new Result<Void>().error(ErrorCode.ADD_DATA_FAILED, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除音色资源")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<Void> delete(@PathVariable("id") String[] ids) {
|
||||
if (ids == null || ids.length == 0) {
|
||||
return new Result<Void>().error(ErrorCode.VOICE_RESOURCE_DELETE_ID_EMPTY);
|
||||
}
|
||||
voiceCloneService.delete(ids);
|
||||
return new Result<Void>();
|
||||
}
|
||||
|
||||
@GetMapping("/user/{userId}")
|
||||
@Operation(summary = "根据用户ID获取音色资源列表")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<List<VoiceCloneResponseDTO>> getByUserId(@PathVariable("userId") Long userId) {
|
||||
List<VoiceCloneResponseDTO> list = voiceCloneService.getByUserIdWithNames(userId);
|
||||
return new Result<List<VoiceCloneResponseDTO>>().ok(list);
|
||||
}
|
||||
|
||||
@GetMapping("/ttsPlatforms")
|
||||
@Operation(summary = "获取TTS平台列表")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<List<Map<String, Object>>> getTtsPlatformList() {
|
||||
List<Map<String, Object>> list = modelConfigService.getTtsPlatformList();
|
||||
return new Result<List<Map<String, Object>>>().ok(list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package xiaozhi.modules.voiceclone.dao;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
/**
|
||||
* 声音克隆
|
||||
*/
|
||||
@Mapper
|
||||
public interface VoiceCloneDao extends BaseMapper<VoiceCloneEntity> {
|
||||
/**
|
||||
* 获取用户训练成功的音色列表
|
||||
*
|
||||
* @param modelId 模型ID
|
||||
* @param userId 用户ID
|
||||
* @return 训练成功的音色列表
|
||||
*/
|
||||
List<VoiceDTO> getTrainSuccess(String modelId, Long userId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package xiaozhi.modules.voiceclone.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "声音克隆DTO")
|
||||
public class VoiceCloneDTO {
|
||||
|
||||
@Schema(description = "模型ID")
|
||||
private String modelId;
|
||||
|
||||
@Schema(description = "音色ID列表")
|
||||
private List<String> voiceIds;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package xiaozhi.modules.voiceclone.dto;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 声音克隆响应DTO
|
||||
* 用于向前端展示声音克隆信息,包含模型名称和用户名称
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "声音克隆响应DTO")
|
||||
public class VoiceCloneResponseDTO {
|
||||
|
||||
@Schema(description = "唯一标识")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "声音名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "模型id")
|
||||
private String modelId;
|
||||
|
||||
@Schema(description = "模型名称")
|
||||
private String modelName;
|
||||
|
||||
@Schema(description = "声音id")
|
||||
private String voiceId;
|
||||
|
||||
@Schema(description = "用户ID(关联用户表)")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "用户名称")
|
||||
private String userName;
|
||||
|
||||
@Schema(description = "训练状态:0待训练 1训练中 2训练成功 3训练失败")
|
||||
private Integer trainStatus;
|
||||
|
||||
@Schema(description = "训练错误原因")
|
||||
private String trainError;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date createDate;
|
||||
|
||||
@Schema(description = "是否有音频数据")
|
||||
private Boolean hasVoice;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package xiaozhi.modules.voiceclone.entity;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("ai_voice_clone")
|
||||
@Schema(description = "声音克隆")
|
||||
public class VoiceCloneEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
@Schema(description = "唯一标识")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "声音名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "模型id")
|
||||
private String modelId;
|
||||
|
||||
@Schema(description = "声音id")
|
||||
private String voiceId;
|
||||
|
||||
@Schema(description = "用户 ID(关联用户表)")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "声音")
|
||||
private byte[] voice;
|
||||
|
||||
@Schema(description = "训练状态:0待训练 1训练中 2训练成功 3训练失败")
|
||||
private Integer trainStatus;
|
||||
|
||||
@Schema(description = "训练错误原因")
|
||||
private String trainError;
|
||||
|
||||
@Schema(description = "创建者")
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Long creator;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Date createDate;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package xiaozhi.modules.voiceclone.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.BaseService;
|
||||
import xiaozhi.modules.voiceclone.dto.VoiceCloneDTO;
|
||||
import xiaozhi.modules.voiceclone.dto.VoiceCloneResponseDTO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
/**
|
||||
* 声音克隆管理
|
||||
*/
|
||||
public interface VoiceCloneService extends BaseService<VoiceCloneEntity> {
|
||||
|
||||
/**
|
||||
* 分页查询
|
||||
*/
|
||||
PageData<VoiceCloneEntity> page(Map<String, Object> params);
|
||||
|
||||
/**
|
||||
* 保存声音克隆
|
||||
*/
|
||||
void save(VoiceCloneDTO dto);
|
||||
|
||||
/**
|
||||
* 批量删除
|
||||
*/
|
||||
void delete(String[] ids);
|
||||
|
||||
/**
|
||||
* 根据用户ID查询声音克隆列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 声音克隆列表
|
||||
*/
|
||||
List<VoiceCloneEntity> getByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 分页查询带模型名称和用户名称的声音克隆列表
|
||||
*/
|
||||
PageData<VoiceCloneResponseDTO> pageWithNames(Map<String, Object> params);
|
||||
|
||||
/**
|
||||
* 根据ID查询带模型名称和用户名称的声音克隆信息
|
||||
*/
|
||||
VoiceCloneResponseDTO getByIdWithNames(String id);
|
||||
|
||||
/**
|
||||
* 根据用户ID查询带模型名称的声音克隆列表
|
||||
*/
|
||||
List<VoiceCloneResponseDTO> getByUserIdWithNames(Long userId);
|
||||
|
||||
/**
|
||||
* 上传音频文件
|
||||
*/
|
||||
void uploadVoice(String id, MultipartFile voiceFile) throws Exception;
|
||||
|
||||
/**
|
||||
* 更新声音克隆名称
|
||||
*/
|
||||
void updateName(String id, String name);
|
||||
|
||||
/**
|
||||
* 获取音频数据
|
||||
*/
|
||||
byte[] getVoiceData(String id);
|
||||
|
||||
/**
|
||||
* 克隆音频,调用火山引擎进行语音复刻训练
|
||||
*
|
||||
* @param cloneId 语音克隆记录ID
|
||||
*/
|
||||
void cloneAudio(String cloneId);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package xiaozhi.modules.voiceclone.service.impl;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.DateUtils;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.sys.service.SysUserService;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
import xiaozhi.modules.voiceclone.dto.VoiceCloneDTO;
|
||||
import xiaozhi.modules.voiceclone.dto.VoiceCloneResponseDTO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceCloneEntity>
|
||||
implements VoiceCloneService {
|
||||
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final SysUserService sysUserService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public PageData<VoiceCloneEntity> page(Map<String, Object> params) {
|
||||
IPage<VoiceCloneEntity> page = baseDao.selectPage(
|
||||
getPage(params, "create_date", true),
|
||||
getWrapper(params));
|
||||
|
||||
return new PageData<>(page.getRecords(), page.getTotal());
|
||||
}
|
||||
|
||||
private QueryWrapper<VoiceCloneEntity> getWrapper(Map<String, Object> params) {
|
||||
String name = (String) params.get("name");
|
||||
String userId = (String) params.get("userId");
|
||||
|
||||
QueryWrapper<VoiceCloneEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq(StringUtils.isNotBlank(userId), "user_id", userId);
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
wrapper.and(w -> w.like("name", name)
|
||||
.or().eq("voice_id", name));
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void save(VoiceCloneDTO dto) {
|
||||
ModelConfigEntity modelConfig = modelConfigService.getModelByIdFromCache(dto.getModelId());
|
||||
if (modelConfig == null || modelConfig.getConfigJson() == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_CONFIG_NOT_FOUND);
|
||||
}
|
||||
Map<String, Object> config = modelConfig.getConfigJson();
|
||||
String type = (String) config.get("type");
|
||||
if (StringUtils.isBlank(type)) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_TYPE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 检查Voice ID是否已经被使用
|
||||
for (String voiceId : dto.getVoiceIds()) {
|
||||
if (StringUtils.isBlank(voiceId)) {
|
||||
continue;
|
||||
}
|
||||
if (Constant.VOICE_CLONE_HUOSHAN_DOUBLE_STREAM.equals(type)) {
|
||||
if (voiceId.indexOf("S_") == -1) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_HUOSHAN_VOICE_ID_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
QueryWrapper<VoiceCloneEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("voice_id", voiceId);
|
||||
wrapper.eq("model_id", dto.getModelId());
|
||||
Long count = baseDao.selectCount(wrapper);
|
||||
if (count > 0) {
|
||||
throw new RenException(ErrorCode.VOICE_ID_ALREADY_EXISTS, "音色ID " + voiceId + " 已存在");
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历选择的音色ID,为每个音色ID创建一条记录
|
||||
int index = 0;
|
||||
String namePrefix = DateUtils.format(new java.util.Date(), "MMddHHmm");
|
||||
for (String voiceId : dto.getVoiceIds()) {
|
||||
index++;
|
||||
VoiceCloneEntity entity = new VoiceCloneEntity();
|
||||
entity.setModelId(dto.getModelId());
|
||||
entity.setVoiceId(voiceId);
|
||||
entity.setName(namePrefix + "_" + index);
|
||||
entity.setUserId(dto.getUserId());
|
||||
entity.setTrainStatus(0); // 默认训练中
|
||||
|
||||
baseDao.insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String[] ids) {
|
||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<VoiceCloneEntity> getByUserId(Long userId) {
|
||||
QueryWrapper<VoiceCloneEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("user_id", userId);
|
||||
wrapper.orderByDesc("created_at");
|
||||
return baseDao.selectList(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageData<VoiceCloneResponseDTO> pageWithNames(Map<String, Object> params) {
|
||||
// 先查询分页数据
|
||||
IPage<VoiceCloneEntity> page = baseDao.selectPage(
|
||||
getPage(params, "create_date", true),
|
||||
getWrapper(params));
|
||||
|
||||
// 将实体列表转换为DTO列表
|
||||
List<VoiceCloneResponseDTO> dtoList = convertToResponseDTOList(page.getRecords());
|
||||
|
||||
return new PageData<>(dtoList, page.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public VoiceCloneResponseDTO getByIdWithNames(String id) {
|
||||
VoiceCloneEntity entity = baseDao.selectById(id);
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
VoiceCloneResponseDTO dto = ConvertUtils.sourceToTarget(entity, VoiceCloneResponseDTO.class);
|
||||
|
||||
// 设置模型名称
|
||||
if (StringUtils.isNotBlank(entity.getModelId())) {
|
||||
dto.setModelName(modelConfigService.getModelNameById(entity.getModelId()));
|
||||
}
|
||||
|
||||
// 设置用户名称
|
||||
if (entity.getUserId() != null) {
|
||||
dto.setUserName(sysUserService.getByUserId(entity.getUserId()).getUsername());
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<VoiceCloneResponseDTO> getByUserIdWithNames(Long userId) {
|
||||
List<VoiceCloneEntity> entityList = getByUserId(userId);
|
||||
return convertToResponseDTOList(entityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将VoiceCloneEntity列表转换为VoiceCloneResponseDTO列表
|
||||
*/
|
||||
private List<VoiceCloneResponseDTO> convertToResponseDTOList(List<VoiceCloneEntity> entityList) {
|
||||
if (entityList == null || entityList.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<VoiceCloneResponseDTO> dtoList = new ArrayList<>(entityList.size());
|
||||
|
||||
// 转换每个实体为DTO
|
||||
for (VoiceCloneEntity entity : entityList) {
|
||||
VoiceCloneResponseDTO dto = ConvertUtils.sourceToTarget(entity, VoiceCloneResponseDTO.class);
|
||||
|
||||
// 设置模型名称
|
||||
if (StringUtils.isNotBlank(entity.getModelId())) {
|
||||
dto.setModelName(modelConfigService.getModelNameById(entity.getModelId()));
|
||||
}
|
||||
|
||||
// 设置用户名称
|
||||
if (entity.getUserId() != null) {
|
||||
dto.setUserName(sysUserService.getByUserId(entity.getUserId()).getUsername());
|
||||
}
|
||||
|
||||
// 设置是否有音频数据
|
||||
dto.setHasVoice(entity.getVoice() != null);
|
||||
|
||||
dtoList.add(dto);
|
||||
}
|
||||
|
||||
return dtoList;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void uploadVoice(String id, MultipartFile voiceFile) throws Exception {
|
||||
// 查询声音克隆记录
|
||||
VoiceCloneEntity entity = baseDao.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_RECORD_NOT_EXIST);
|
||||
}
|
||||
|
||||
// 读取音频文件并转为字节数组
|
||||
byte[] voiceData = voiceFile.getBytes();
|
||||
|
||||
// 更新voice字段
|
||||
entity.setVoice(voiceData);
|
||||
// 更新训练状态为待训练
|
||||
entity.setTrainStatus(0);
|
||||
|
||||
// 保存到数据库
|
||||
baseDao.updateById(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateName(String id, String name) {
|
||||
// 查询声音克隆记录
|
||||
VoiceCloneEntity entity = baseDao.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_RECORD_NOT_EXIST);
|
||||
}
|
||||
|
||||
// 更新名称
|
||||
entity.setName(name);
|
||||
baseDao.updateById(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getVoiceData(String id) {
|
||||
VoiceCloneEntity entity = baseDao.selectById(id);
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
return entity.getVoice();
|
||||
}
|
||||
|
||||
@Override
|
||||
// @Transactional(rollbackFor = Exception.class)
|
||||
public void cloneAudio(String cloneId) {
|
||||
VoiceCloneEntity entity = baseDao.selectById(cloneId);
|
||||
if (entity == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_RECORD_NOT_EXIST);
|
||||
}
|
||||
if (entity.getVoice() == null || entity.getVoice().length == 0) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_AUDIO_NOT_UPLOADED);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
ModelConfigEntity modelConfig = modelConfigService.getModelByIdFromCache(entity.getModelId());
|
||||
if (modelConfig == null || modelConfig.getConfigJson() == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_CONFIG_NOT_FOUND);
|
||||
}
|
||||
Map<String, Object> config = modelConfig.getConfigJson();
|
||||
String type = (String) config.get("type");
|
||||
if (StringUtils.isBlank(type)) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_TYPE_NOT_FOUND);
|
||||
}
|
||||
if (Constant.VOICE_CLONE_HUOSHAN_DOUBLE_STREAM.equals(type)) {
|
||||
huoshanClone(config, entity);
|
||||
}
|
||||
} catch (RenException re) {
|
||||
entity.setTrainStatus(3);
|
||||
entity.setTrainError(re.getMsg());
|
||||
baseDao.updateById(entity);
|
||||
throw re;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
entity.setTrainStatus(3);
|
||||
entity.setTrainError(e.getMessage());
|
||||
baseDao.updateById(entity);
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_TRAINING_FAILED, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用火山引擎进行语音复刻训练
|
||||
*
|
||||
* @param config 模型配置
|
||||
* @param entity 语音克隆记录实体
|
||||
* @throws Exception
|
||||
*/
|
||||
private void huoshanClone(Map<String, Object> config, VoiceCloneEntity entity) throws Exception {
|
||||
String appid = (String) config.get("appid");
|
||||
String accessToken = (String) config.get("access_token");
|
||||
|
||||
if (StringUtils.isAnyBlank(appid, accessToken)) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_HUOSHAN_CONFIG_MISSING);
|
||||
}
|
||||
|
||||
String audioBase64 = Base64.getEncoder().encodeToString(entity.getVoice());
|
||||
Map<String, Object> reqBody = new HashMap<>();
|
||||
reqBody.put("appid", appid);
|
||||
List<Map<String, String>> audios = new ArrayList<>();
|
||||
Map<String, String> audioMap = new HashMap<>();
|
||||
audioMap.put("audio_bytes", audioBase64);
|
||||
audioMap.put("audio_format", "wav");
|
||||
audios.add(audioMap);
|
||||
reqBody.put("audios", audios);
|
||||
reqBody.put("source", 2);
|
||||
reqBody.put("language", 0);
|
||||
reqBody.put("model_type", 1);
|
||||
reqBody.put("speaker_id", entity.getVoiceId());
|
||||
|
||||
String apiUrl = "https://openspeech.bytedance.com/api/v1/mega_tts/audio/upload";
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(apiUrl))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer;" + accessToken)
|
||||
.header("Resource-Id", "seed-icl-1.0")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(reqBody)))
|
||||
.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
System.out.println(">>> HTTP status = " + response.statusCode());
|
||||
System.out.println(">>> response body = " + response.body());
|
||||
|
||||
Map<String, Object> rsp = objectMapper.readValue(response.body(),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
|
||||
// 获取BaseResp对象
|
||||
Map<String, Object> baseResp = objectMapper.convertValue(rsp.get("BaseResp"),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
if (baseResp != null) {
|
||||
Integer statusCode = objectMapper.convertValue(baseResp.get("StatusCode"), Integer.class);
|
||||
String statusMessage = objectMapper.convertValue(baseResp.getOrDefault("StatusMessage", ""),
|
||||
String.class);
|
||||
|
||||
// 获取speaker_id
|
||||
String speakerId = objectMapper.convertValue(rsp.get("speaker_id"), String.class);
|
||||
|
||||
// StatusCode == 0 表示成功
|
||||
if (statusCode != null && statusCode == 0 && StringUtils.isNotBlank(speakerId)) {
|
||||
entity.setTrainStatus(2);
|
||||
entity.setVoiceId(speakerId);
|
||||
entity.setTrainError("");
|
||||
baseDao.updateById(entity);
|
||||
} else {
|
||||
// 失败时使用StatusMessage作为错误信息
|
||||
String errorMsg = StringUtils.isNotBlank(statusMessage) ? statusMessage : "训练失败";
|
||||
throw new RenException(errorMsg);
|
||||
}
|
||||
} else {
|
||||
String errorMsg = objectMapper.convertValue(rsp.get("message"),
|
||||
new TypeReference<String>() {
|
||||
});
|
||||
if (StringUtils.isNotBlank(errorMsg)) {
|
||||
throw new RenException(errorMsg);
|
||||
}
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_RESPONSE_FORMAT_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 添加Index-TTS-vLLM流式TTS供应器
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_TTS_IndexStreamTTS';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||
('SYSTEM_TTS_IndexStreamTTS', 'TTS', 'index_stream', 'Index-TTS-vLLM流式语音合成', '[{"key":"api_url","label":"API服务地址","type":"string"},{"key":"voice","label":"默认音色","type":"string"},{"key":"audio_format","label":"音频格式","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 16, 1, NOW(), 1, NOW());
|
||||
|
||||
-- 添加Index-TTS-vLLM流式TTS模型配置
|
||||
delete from `ai_model_config` where id = 'TTS_IndexStreamTTS';
|
||||
INSERT INTO `ai_model_config` VALUES ('TTS_IndexStreamTTS', 'TTS', 'IndexStreamTTS', 'Index-TTS-vLLM流式语音合成', 0, 1, '{\"type\": \"index_stream\", \"api_url\": \"http://127.0.0.1:11996/tts\", \"voice\": \"jay_klee\", \"audio_format\": \"pcm\", \"output_dir\": \"tmp/\"}', NULL, NULL, 19, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 更新Index-TTS-vLLM流式TTS配置说明
|
||||
UPDATE `ai_model_config` SET
|
||||
`doc_link` = 'https://github.com/Ksuriuri/index-tts-vllm',
|
||||
`remark` = 'Index-TTS-vLLM流式TTS配置说明:
|
||||
1. Index-TTS-vLLM是基于Index-TTS项目的vLLM推理服务,提供流式语音合成功能
|
||||
2. 支持多种音色,音质自然,适合各种语音交互场景
|
||||
3. 需要先部署Index-TTS-vLLM服务,然后配置API地址
|
||||
4. 支持实时流式合成,具有较低的延迟
|
||||
5. 支持自定义音色,可在项目assets文件夹下注册新音色
|
||||
部署步骤:
|
||||
1. 克隆项目:git clone https://github.com/Ksuriuri/index-tts-vllm.git
|
||||
2. 安装依赖:pip install -r requirements.txt
|
||||
3. 启动服务:python app.py
|
||||
4. 服务默认运行在 http://127.0.0.1:11996
|
||||
5. 如需其他音色,可到项目assets文件夹下注册
|
||||
6. 支持多种音频格式:pcm、wav、mp3等
|
||||
如需了解更多配置,请参考:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md
|
||||
' WHERE `id` = 'TTS_IndexStreamTTS';
|
||||
|
||||
-- 添加Index-TTS-vLLM流式TTS音色
|
||||
delete from `ai_tts_voice` where tts_model_id = 'TTS_IndexStreamTTS';
|
||||
-- 默认音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_IndexStreamTTS_0001', 'TTS_IndexStreamTTS', 'Jay Klee', 'jay_klee', '中文及中英文混合', NULL, NULL, NULL, NULL, 1, NULL, NULL, NULL, NULL);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 更新HuoshanDoubleStreamTTS供应器增加语速,音调等配置
|
||||
UPDATE `ai_model_provider`
|
||||
SET fields = '[{"key": "ws_url", "type": "string", "label": "WebSocket地址"}, {"key": "appid", "type": "string", "label": "应用ID"}, {"key": "access_token", "type": "string", "label": "访问令牌"}, {"key": "resource_id", "type": "string", "label": "资源ID"}, {"key": "speaker", "type": "string", "label": "默认音色"}, {"key": "speech_rate", "type": "number", "label": "语速(-50~100)"}, {"key": "loudness_rate", "type": "number", "label": "音量(-50~100)"}, {"key": "pitch", "type": "number", "label": "音高(-12~12)"}]'
|
||||
WHERE id = 'SYSTEM_TTS_HSDSTTS';
|
||||
|
||||
UPDATE `ai_model_config` SET
|
||||
`doc_link` = 'https://console.volcengine.com/speech/service/10007',
|
||||
`remark` = '火山引擎语音合成服务配置说明:
|
||||
1. 访问 https://www.volcengine.com/ 注册并开通火山引擎账号
|
||||
2. 访问 https://console.volcengine.com/speech/service/10007 开通语音合成大模型,购买音色
|
||||
3. 在页面底部获取appid和access_token
|
||||
5. 资源ID固定为:volc.service_type.10029(大模型语音合成及混音)
|
||||
6. 语速:-50~100,可不填,正常默认值0,可填-50~100
|
||||
7. 音量:-50~100,可不填,正常默认值0,可填-50~100
|
||||
8. 音高:-12~12,可不填,正常默认值0,可填-12~12
|
||||
9. 填入配置文件中' WHERE `id` = 'TTS_HuoshanDoubleStreamTTS';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 添加 paddle_speech 流式 TTS 供应器
|
||||
DELETE FROM `ai_model_provider` WHERE id = 'SYSTEM_TTS_PaddleSpeechTTS';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`)
|
||||
VALUES ('SYSTEM_TTS_PaddleSpeechTTS', 'TTS', 'paddle_speech', 'PaddleSpeechTTS',
|
||||
'[{"key":"protocol","label":"协议类型","type":"string","options":["websocket","http"]},{"key":"url","label":"服务地址","type":"string"},{"key":"spk_id","label":"音色","type":"int"},{"key":"sample_rate","label":"采样率","type":"float"},{"key":"speed","label":"语速","type":"float"},{"key":"volume","label":"音量","type":"float"},{"key":"save_path","label":"保存路径","type":"string"}]',
|
||||
17, 1, NOW(), 1, NOW());
|
||||
|
||||
-- 添加 paddle_speech 流式 TTS 模型配置
|
||||
DELETE FROM `ai_model_config` WHERE id = 'TTS_PaddleSpeechTTS';
|
||||
INSERT INTO `ai_model_config` VALUES ('TTS_PaddleSpeechTTS', 'TTS', 'PaddleSpeechTTS', 'PaddleSpeechTTS', 0, 1,
|
||||
'{"type": "paddle_speech", "protocol": "websocket", "url": "ws://127.0.0.1:8092/paddlespeech/tts/streaming", "spk_id": "0", "sample_rate": 24000, "speed": 1.0, "volume": 1.0, "save_path": "./streaming_tts.wav"}',
|
||||
NULL, NULL, 20, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 更新 PaddleSpeechTTS 配置说明
|
||||
UPDATE `ai_model_config` SET
|
||||
`doc_link` = 'https://github.com/PaddlePaddle/PaddleSpeech',
|
||||
`remark` = 'PaddleSpeechTTS 配置说明:
|
||||
1. PaddleSpeech 是百度飞桨开源的语音合成工具,支持本地离线部署和模型训练。paddlepaddle百度飞浆框架地址:https://www.paddlepaddle.org.cn/
|
||||
2. 支持 WebSocket 和 HTTP 协议,默认使用 WebSocket 进行流式传输(参考部署文档:https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/paddlespeech-deploy.md)。
|
||||
3. 使用前要在本地部署 paddlespeech 服务,服务默认运行在 ws://127.0.0.1:8092/paddlespeech/tts/streaming
|
||||
4. 支持自定义发音人、语速、音量和采样率。
|
||||
' WHERE `id` = 'TTS_PaddleSpeechTTS';
|
||||
|
||||
-- 删除旧音色并添加默认音色
|
||||
DELETE FROM `ai_tts_voice` WHERE tts_model_id = 'TTS_PaddleSpeechTTS';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_PaddleSpeechTTS_0000', 'TTS_PaddleSpeechTTS', '默认', '0', '中文', NULL, NULL, NULL, NULL, 1, NULL, NULL, NULL, NULL);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- VOSK ASR模型供应器
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_ASR_VoskASR';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||
('SYSTEM_ASR_VoskASR', 'ASR', 'vosk', 'VOSK离线语音识别', '[{"key": "model_path", "type": "string", "label": "模型路径"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]', 11, 1, NOW(), 1, NOW());
|
||||
|
||||
-- VOSK ASR模型配置
|
||||
delete from `ai_model_config` where id = 'ASR_VoskASR';
|
||||
INSERT INTO `ai_model_config` VALUES ('ASR_VoskASR', 'ASR', 'VoskASR', 'VOSK离线语音识别', 0, 1, '{\"type\": \"vosk\", \"model_path\": \"\", \"output_dir\": \"tmp/\"}', NULL, NULL, 11, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 更新VOSK ASR配置说明
|
||||
UPDATE `ai_model_config` SET
|
||||
`doc_link` = 'https://alphacephei.com/vosk/',
|
||||
`remark` = 'VOSK ASR配置说明:
|
||||
1. VOSK是一个离线语音识别库,支持多种语言
|
||||
2. 需要先下载模型文件:https://alphacephei.com/vosk/models
|
||||
3. 中文模型推荐使用vosk-model-small-cn-0.22或vosk-model-cn-0.22
|
||||
4. 完全离线运行,无需网络连接
|
||||
5. 输出文件保存在tmp/目录
|
||||
使用步骤:
|
||||
1. 访问 https://alphacephei.com/vosk/models 下载中文模型
|
||||
2. 解压模型文件到项目目录下的models/vosk/文件夹
|
||||
3. 在配置中指定正确的模型路径
|
||||
4. 注意:VOSK中文模型输出不带标点符号,词与词之间会有空格
|
||||
' WHERE `id` = 'ASR_VoskASR';
|
||||
@@ -0,0 +1,45 @@
|
||||
-- 添加 MinimaxHTTPStream 流式 TTS 供应器
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_TTS_MinimaxStreamTTS';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||
('SYSTEM_TTS_MinimaxStreamTTS', 'TTS', 'minimax_httpstream', 'Minimax流式语音合成', '[{"key":"group_id","label":"组ID","type":"string"},{"key":"api_key","label":"API密钥","type":"string"},{"key":"model","label":"模型","type":"string"},{"key":"voice_id","label":"音色ID","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"voice_setting","label":"音色设置","type":"dict","dict_name":"voice_setting"},{"key":"pronunciation_dict","label":"发音字典","type":"dict","dict_name":"pronunciation_dict"},{"key":"audio_setting","label":"音频设置","type":"dict","dict_name":"audio_setting"},{"key":"timber_weights","label":"音色权重","type":"string"}]', 18, 1, NOW(), 1, NOW());
|
||||
|
||||
-- 添加Minimax流式TTS模型配置
|
||||
delete from `ai_model_config` where id = 'TTS_MinimaxStreamTTS';
|
||||
INSERT INTO `ai_model_config` VALUES ('TTS_MinimaxStreamTTS', 'TTS', 'MinimaxStreamTTS', 'Minimax流式语音合成', 0, 1, '{"type": "minimax_httpstream", "group_id": "", "api_key": "", "model": "speech-01-turbo", "voice_id": "female-shaonv", "output_dir": "tmp/", "voice_setting": {"speed": 1, "vol": 1, "pitch": 0, "emotion": "happy"}, "pronunciation_dict": {"tone": ["处理/(chu3)(li3)", "危险/dangerous"]}, "audio_setting": {"sample_rate": 24000, "bitrate": 128000, "format": "pcm", "channel": 1}}', NULL, NULL, 21, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 更新Minimax流式TTS配置说明
|
||||
UPDATE `ai_model_config` SET
|
||||
`doc_link` = 'https://platform.minimaxi.com/',
|
||||
`remark` = 'Minimax流式TTS配置说明:
|
||||
1. 需要先申请Minimax API Key
|
||||
2. 需要填写Group ID
|
||||
3. 支持多种音色设置和音频参数调整
|
||||
4. 支持实时流式合成,具有较低的延迟
|
||||
5. 支持自定义发音字典和音色权重
|
||||
6. 隐藏参数配置:声音设定(voice_setting)、发音字典(pronunciation_dict)、音色权重(timber_weights)
|
||||
- 语速(speed): 范围[0.5,2],默认1.0,取值越大语速越快
|
||||
- 音量(vol): 范围(0,10],默认1.0,取值越大音量越高
|
||||
- 音调(pitch): 范围[-12,12],默认0,取值需为整数
|
||||
- 情绪(emotion): 控制合成语音的情绪,支持7种值:["happy", "sad", "angry", "fearful", "disgusted", "surprised", "calm"],该参数仅对 speech-2.5-hd-preview、speech-2.5-turbo-preview、speech-02-hd、speech-02-turbo、speech-01-turbo、speech-01-hd 生效
|
||||
- timbre_weights与voice_id二选一必填
|
||||
- voice_id(请求的音色id,须和weight参数同步填写)
|
||||
- weight(权重,最多支持4种音色混合。范围[1,100])
|
||||
' WHERE `id` = 'TTS_MinimaxStreamTTS';
|
||||
|
||||
-- 添加Minimax流式TTS音色
|
||||
delete from `ai_tts_voice` where tts_model_id = 'TTS_MinimaxStreamTTS';
|
||||
|
||||
-- 默认音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0001', 'TTS_MinimaxStreamTTS', '少女音', 'female-shaonv', '中文', NULL, NULL, NULL, NULL, 1, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0002', 'TTS_MinimaxStreamTTS', '成熟女声', 'female-chengshu', '中文', NULL, NULL, NULL, NULL, 2, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0003', 'TTS_MinimaxStreamTTS', '霸道少爷', 'badao_shaoye', '中文', NULL, NULL, NULL, NULL, 3, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0004', 'TTS_MinimaxStreamTTS', '病娇弟弟', 'bingjiao_didi', '中文', NULL, NULL, NULL, NULL, 4, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0005', 'TTS_MinimaxStreamTTS', '纯真学弟', 'chunzhen_xuedi', '中文', NULL, NULL, NULL, NULL, 5, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0006', 'TTS_MinimaxStreamTTS', '冷淡学长', 'lengdan_xiongzhang', '中文', NULL, NULL, NULL, NULL, 6, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0007', 'TTS_MinimaxStreamTTS', '甜美小玲', 'tianxin_xiaoling', '中文', NULL, NULL, NULL, NULL, 7, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0008', 'TTS_MinimaxStreamTTS', '俏皮萌妹', 'qiaopi_mengmei', '中文', NULL, NULL, NULL, NULL, 8, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0009', 'TTS_MinimaxStreamTTS', '妩媚御姐', 'wumei_yujie', '中文', NULL, NULL, NULL, NULL, 9, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0010', 'TTS_MinimaxStreamTTS', '嗲嗲学妹', 'diadia_xuemei', '中文', NULL, NULL, NULL, NULL, 7, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0011', 'TTS_MinimaxStreamTTS', '淡雅学姐', 'danya_xuejie', '中文', NULL, NULL, NULL, NULL, 8, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0012', 'TTS_MinimaxStreamTTS', 'Santa Claus', 'Santa_Claus', '中文', NULL, NULL, NULL, NULL, 9, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxStreamTTS_0013', 'TTS_MinimaxStreamTTS', 'Grinch', 'Grinch', '中文', NULL, NULL, NULL, NULL, 10, NULL, NULL, NULL, NULL);
|
||||
@@ -0,0 +1,8 @@
|
||||
delete from `sys_params` where param_code = 'server.mqtt_gateway';
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (116, 'server.mqtt_gateway', 'null', 'string', 1, 'mqtt gateway 配置');
|
||||
|
||||
delete from `sys_params` where param_code = 'server.mqtt_signature_key';
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (117, 'server.mqtt_signature_key', 'null', 'string', 1, 'mqtt 密钥 配置');
|
||||
|
||||
delete from `sys_params` where param_code = 'server.udp_gateway';
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (118, 'server.udp_gateway', 'null', 'string', 1, 'udp gateway 配置');
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 添加声纹识别相似度阈值参数配置
|
||||
delete from `sys_params` where id = 115;
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
|
||||
VALUES (115, 'server.voiceprint_similarity_threshold', '0.4', 'string', 1, '声纹识别相似度阈值,范围0.0-1.0,默认0.4,数值越高越严格');
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 删除非流式MiniMax TTS配置,保留流式版本
|
||||
|
||||
-- 删除旧的非流式MiniMax TTS模型配置
|
||||
DELETE FROM `ai_model_config` WHERE `id` = 'TTS_MinimaxTTS';
|
||||
|
||||
-- 删除旧的非流式MiniMax TTS供应器配置
|
||||
DELETE FROM `ai_model_provider` WHERE `id` = 'SYSTEM_TTS_minimax';
|
||||
|
||||
-- 删除旧的非流式MiniMax TTS音色配置
|
||||
DELETE FROM `ai_tts_voice` WHERE `tts_model_id` = 'TTS_MinimaxTTS';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 添加通义千问Qwen3-ASR-Flash语音识别服务配置
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_ASR_Qwen3Flash';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||
('SYSTEM_ASR_Qwen3Flash', 'ASR', 'qwen3_asr_flash', 'Qwen3-ASR-Flash语音识别', '[{"key":"api_key","label":"API密钥","type":"password"},{"key":"base_url","label":"服务地址","type":"string"},{"key":"model_name","label":"模型名称","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 17, 1, NOW(), 1, NOW());
|
||||
|
||||
delete from `ai_model_config` where id = 'ASR_Qwen3Flash';
|
||||
INSERT INTO `ai_model_config` VALUES ('ASR_Qwen3Flash', 'ASR', 'Qwen3-ASR-Flash', '通义千问语音识别服务', 0, 1, '{"type": "qwen3_asr_flash", "api_key": "", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "model_name": "qwen3-asr-flash", "output_dir": "tmp/", "enable_lid": true, "enable_itn": true}', 'https://help.aliyun.com/zh/bailian/', '支持多语言识别、歌唱识别、噪声拒识功能', 20, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 更新Qwen3-ASR-Flash模型配置的说明文档
|
||||
UPDATE `ai_model_config` SET
|
||||
`doc_link` = 'https://bailian.console.aliyun.com/?apiKey=1&tab=doc#/doc/?type=model&url=2979031',
|
||||
`remark` = '通义千问Qwen3-ASR-Flash配置说明:
|
||||
1. 登录阿里云百炼平台https://bailian.console.aliyun.com/
|
||||
2. 创建API-KEY https://bailian.console.aliyun.com/#/api-key
|
||||
3.Qwen3-ASR-Flash基于通义千问多模态基座,支持多语言识别、歌唱识别、噪声拒识等功能
|
||||
' WHERE `id` = 'ASR_Qwen3Flash';
|
||||