feat: 新增 streamResponse 流式响应控制开关

This commit is contained in:
WJG 2024-06-05 01:57:40 +08:00
parent 38cfbd096a
commit c0da2698b6
No known key found for this signature in database
GPG Key ID: 258474EF8590014A
8 changed files with 83 additions and 29 deletions

View File

@ -50,6 +50,8 @@ export default {
ttsCommand: [5, 1], ttsCommand: [5, 1],
// 设备唤醒指令,请到 https://home.miot-spec.com 查询具体指令 // 设备唤醒指令,请到 https://home.miot-spec.com 查询具体指令
wakeUpCommand: [5, 3], wakeUpCommand: [5, 3],
// 是否启用流式响应,部分小爱音箱型号不支持查询播放状态,需要关闭流式响应
streamResponse: true,
// 查询是否在播放中指令,请到 https://home.miot-spec.com 查询具体指令 // 查询是否在播放中指令,请到 https://home.miot-spec.com 查询具体指令
// playingCommand: [3, 1, 1], // playingCommand: [3, 1, 1],
}, },

View File

@ -1,10 +1,25 @@
# v3.0.0
## ✨ 新功能 & 优化
- 新增 `streamResponse` 流式响应控制开关,确保小爱的回复是完整的句子([issue#20](https://github.com/idootop/mi-gpt/issues/20)
- 添加其他 LLM 的配置教程比如通义千问moonshot 等)([issue#11](https://github.com/idootop/mi-gpt/issues/11)
- 添加对支持小爱音箱型号的说明([issue#14](https://github.com/idootop/mi-gpt/issues/14)
- 优化配置文件示例和使用教程([issue#22](https://github.com/idootop/mi-gpt/issues/22)
## 🐛 修复
- 修复 AI 响应异常时未播放提示语/音的问题
- 修复提示音链接为空时自动播放音乐的问题
# v2.1.2 # v2.1.2
- 修复小爱回复无法被终止的问题 - 修复小爱回复无法被终止的问题[issue#5](https://github.com/idootop/mi-gpt/issues/5)
# v2.1.1 # v2.1.1
- 修复 DB 初始化失败的问题 - 修复 DB 初始化失败的问题[issue#17](https://github.com/idootop/mi-gpt/issues/17)
- 优化版本号读取方式import - 优化版本号读取方式import
# v2.1.0 # v2.1.0
@ -15,8 +30,8 @@
# v2.0.1 # v2.0.1
- 新增 ARMv7 Docker 镜像 - 新增 ARMv7 Docker 镜像[issue#15](https://github.com/idootop/mi-gpt/issues/15)
- 新增 debug 开关,用于调试 [issue#14](https://github.com/idootop/mi-gpt/issues/14) - 新增 debug 开关,用于调试[issue#14](https://github.com/idootop/mi-gpt/issues/14)
# v2.0.0 # v2.0.0

View File

@ -225,6 +225,9 @@ export class MyBot {
if (answer) { if (answer) {
stream.finish(answer); stream.finish(answer);
options.onFinished?.(answer); options.onFinished?.(answer);
} else {
stream.finish(answer);
stream.cancel();
} }
}); });
return stream; return stream;

View File

@ -80,7 +80,7 @@ class OpenAIClient {
}, },
{ signal } { signal }
).catch((e) => { ).catch((e) => {
this._logger.error("openai chat failed", e); this._logger.error("LLM 响应异常", e);
return null; return null;
}); });
if (requestId) { if (requestId) {
@ -124,7 +124,7 @@ class OpenAIClient {
messages: [...systemMsg, { role: "user", content: user }], messages: [...systemMsg, { role: "user", content: user }],
response_format: jsonMode ? { type: "json_object" } : undefined, response_format: jsonMode ? { type: "json_object" } : undefined,
}).catch((e) => { }).catch((e) => {
this._logger.error("❌ openai chat failed", e); this._logger.error("LLM 响应异常", e);
return null; return null;
}); });
if (!stream) { if (!stream) {

View File

@ -125,7 +125,6 @@ export class AISpeaker extends Speaker {
this.askAI = askAI; this.askAI = askAI;
this.name = name; this.name = name;
this.callAIKeywords = callAIKeywords; this.callAIKeywords = callAIKeywords;
this.wakeUpKeywords = wakeUpKeywords; this.wakeUpKeywords = wakeUpKeywords;
this.exitKeywords = exitKeywords; this.exitKeywords = exitKeywords;
this.onEnterAI = onEnterAI; this.onEnterAI = onEnterAI;
@ -140,6 +139,10 @@ export class AISpeaker extends Speaker {
} }
async enterKeepAlive() { async enterKeepAlive() {
if (!this.streamResponse) {
await this.response({ text: "流式响应已关闭,无法进入唤醒模式" });
return;
}
// 回应 // 回应
await this.response({ text: pickOne(this.onEnterAI)!, keepAlive: true }); await this.response({ text: pickOne(this.onEnterAI)!, keepAlive: true });
// 唤醒 // 唤醒
@ -221,7 +224,7 @@ export class AISpeaker extends Speaker {
} }
}, },
async (msg, data) => { async (msg, data) => {
if (data.answer && data.res !== "break") { if (data.answer && data.res == null && this.streamResponse) {
// 回复完毕 // 回复完毕
await this.response({ await this.response({
text: pickOne(this.onAIReplied)!, text: pickOne(this.onAIReplied)!,
@ -229,7 +232,7 @@ export class AISpeaker extends Speaker {
} }
}, },
async (msg, data) => { async (msg, data) => {
if (!data.answer) { if (data.res === "error") {
// 回答异常 // 回答异常
await this.response({ await this.response({
audio: this.audioError, audio: this.audioError,

View File

@ -23,6 +23,14 @@ type PropertyCommand = [number, number, number];
export type BaseSpeakerConfig = MiServiceConfig & { export type BaseSpeakerConfig = MiServiceConfig & {
debug?: boolean; debug?: boolean;
/**
*
*
*
*
* LLM TTS 使
*/
streamResponse?: boolean;
/** /**
* *
*/ */
@ -55,6 +63,10 @@ export type BaseSpeakerConfig = MiServiceConfig & {
* 500 1 * 500 1
*/ */
checkInterval?: number; checkInterval?: number;
/**
* TTS 3
*/
checkTTSStatusAfter?: number;
/** /**
* TTS / * TTS /
*/ */
@ -62,21 +74,26 @@ export type BaseSpeakerConfig = MiServiceConfig & {
}; };
export class BaseSpeaker { export class BaseSpeaker {
logger = Logger.create({ tag: "Speaker" });
MiNA?: MiNA; MiNA?: MiNA;
MiIOT?: MiIOT; MiIOT?: MiIOT;
config: MiServiceConfig;
logger = Logger.create({ tag: "Speaker" });
debug = false; debug = false;
streamResponse = true;
checkInterval: number; checkInterval: number;
checkTTSStatusAfter: number;
tts: TTSProvider; tts: TTSProvider;
ttsCommand: ActionCommand; ttsCommand: ActionCommand;
wakeUpCommand: ActionCommand; wakeUpCommand: ActionCommand;
playingCommand?: PropertyCommand; playingCommand?: PropertyCommand;
config: MiServiceConfig;
constructor(config: BaseSpeakerConfig) { constructor(config: BaseSpeakerConfig) {
this.config = config; this.config = config;
const { const {
debug = false, debug = false,
streamResponse = true,
checkInterval = 1000, checkInterval = 1000,
checkTTSStatusAfter = 3,
tts = "xiaoai", tts = "xiaoai",
playingCommand, playingCommand,
ttsCommand = [5, 1], ttsCommand = [5, 1],
@ -84,9 +101,12 @@ export class BaseSpeaker {
audioBeep = process.env.AUDIO_BEEP, audioBeep = process.env.AUDIO_BEEP,
} = config; } = config;
this.debug = debug; this.debug = debug;
this.streamResponse = streamResponse;
this.audioBeep = audioBeep; this.audioBeep = audioBeep;
this.checkInterval = clamp(checkInterval, 500, Infinity); this.checkInterval = clamp(checkInterval, 500, Infinity);
this.checkTTSStatusAfter = checkTTSStatusAfter;
this.tts = tts; this.tts = tts;
// todo 考虑维护常见设备型号的指令列表,并自动从 spec 文件判断属性权限
this.ttsCommand = ttsCommand; this.ttsCommand = ttsCommand;
this.wakeUpCommand = wakeUpCommand; this.wakeUpCommand = wakeUpCommand;
this.playingCommand = playingCommand; this.playingCommand = playingCommand;
@ -158,8 +178,8 @@ export class BaseSpeaker {
tts = "xiaoai"; // 没有提供豆包语音接口时,只能使用小爱自带 TTS tts = "xiaoai"; // 没有提供豆包语音接口时,只能使用小爱自带 TTS
} }
const ttsNotXiaoai = (!!stream || !!text) && !audio && tts !== "xiaoai"; const ttsNotXiaoai = tts !== "xiaoai" && !audio;
playSFX = ttsNotXiaoai && playSFX; playSFX = this.streamResponse && ttsNotXiaoai && playSFX;
if (ttsNotXiaoai && !stream) { if (ttsNotXiaoai && !stream) {
// 长文本 TTS 转化成 stream 分段模式 // 长文本 TTS 转化成 stream 分段模式
@ -170,13 +190,17 @@ export class BaseSpeaker {
this.responding = true; this.responding = true;
// 开始响应 // 开始响应
if (stream) { if (stream) {
let _response = ""; let replyText = "";
while (true) { while (true) {
const { nextSentence, noMore } = stream.getNextResponse(); let { nextSentence, noMore } = stream.getNextResponse();
if (!this.streamResponse) {
nextSentence = await stream.getFinalResult();
noMore = true;
}
if (nextSentence) { if (nextSentence) {
if (_response.length < 1) { if (replyText.length < 1) {
// 播放开始提示音 // 播放开始提示音
if (playSFX) { if (playSFX && this.audioBeep) {
await this.MiNA!.play({ url: this.audioBeep }); await this.MiNA!.play({ url: this.audioBeep });
} }
// 在播放 TTS 语音之前,先取消小爱音箱的唤醒状态,防止将 TTS 语音识别成用户指令 // 在播放 TTS 语音之前,先取消小爱音箱的唤醒状态,防止将 TTS 语音识别成用户指令
@ -195,12 +219,12 @@ export class BaseSpeaker {
stream.cancel(); stream.cancel();
break; break;
} }
_response += nextSentence; replyText += nextSentence;
} }
if (noMore) { if (noMore) {
if (_response.length > 0) { if (replyText.length > 0) {
// 播放结束提示音 // 播放结束提示音
if (playSFX) { if (playSFX && this.audioBeep) {
await this.MiNA!.play({ url: this.audioBeep }); await this.MiNA!.play({ url: this.audioBeep });
} }
} }
@ -213,6 +237,9 @@ export class BaseSpeaker {
} }
await sleep(this.checkInterval); await sleep(this.checkInterval);
} }
if (replyText.length < 1) {
return "error";
}
} else { } else {
res = await this._response(options); res = await this._response(options);
} }
@ -223,7 +250,6 @@ export class BaseSpeaker {
private async _response(options: { private async _response(options: {
tts?: TTSProvider; tts?: TTSProvider;
text?: string; text?: string;
stream?: StreamResponse;
audio?: string; audio?: string;
speaker?: string; speaker?: string;
keepAlive?: boolean; keepAlive?: boolean;
@ -233,7 +259,6 @@ export class BaseSpeaker {
let { let {
text, text,
audio, audio,
stream,
playSFX = true, playSFX = true,
keepAlive = false, keepAlive = false,
tts = this.tts, tts = this.tts,
@ -249,11 +274,12 @@ export class BaseSpeaker {
}; };
const ttsText = text?.replace(/\n\s*\n/g, "\n")?.trim(); const ttsText = text?.replace(/\n\s*\n/g, "\n")?.trim();
const ttsNotXiaoai = !stream && !!text && !audio && tts !== "xiaoai"; const ttsNotXiaoai = tts !== "xiaoai" && !audio;
playSFX = ttsNotXiaoai && playSFX; playSFX = this.streamResponse && ttsNotXiaoai && playSFX;
// 播放回复 // 播放回复
const play = async (args?: { tts?: string; url?: string }) => { const play = async (args?: { tts?: string; url?: string }) => {
this.logger.log("🔊 " + (ttsText ?? audio));
// 播放开始提示音 // 播放开始提示音
if (playSFX && this.audioBeep) { if (playSFX && this.audioBeep) {
await this.MiNA!.play({ url: this.audioBeep }); await this.MiNA!.play({ url: this.audioBeep });
@ -267,9 +293,13 @@ export class BaseSpeaker {
} else { } else {
await this.MiNA!.play(args); await this.MiNA!.play(args);
} }
this.logger.log("🔊 " + (ttsText ?? audio)); if (!this.streamResponse) {
// 等待 3 秒,确保本地设备状态已更新 // 非流式响应,直接返回,不再等待设备播放完毕
await sleep(3000); // todo 考虑后续通过 MioT 通知事件,接收设备播放状态变更通知。
return;
}
// 等待一段时间,确保本地设备状态已更新
await sleep(this.checkTTSStatusAfter * 1000);
// 等待回答播放完毕 // 等待回答播放完毕
while (true) { while (true) {
let playing: any = { status: "idle" }; let playing: any = { status: "idle" };
@ -317,7 +347,7 @@ export class BaseSpeaker {
// 开始响应 // 开始响应
let res; let res;
if (audio) { if (audio) {
// 音频回复 // 优先播放音频回复
res = await play({ url: audio }); res = await play({ url: audio });
} else if (ttsText) { } else if (ttsText) {
// 文字回复 // 文字回复

View File

@ -58,7 +58,7 @@ export class StreamResponse {
} }
private _nextChunkIdx = 0; private _nextChunkIdx = 0;
getNextResponse() { getNextResponse(): { nextSentence?: string; noMore: boolean } {
if (this._submitCount > 0) { if (this._submitCount > 0) {
// 在请求下一条消息前,提交当前收到的所有消息 // 在请求下一条消息前,提交当前收到的所有消息
this._batchSubmitImmediately(); this._batchSubmitImmediately();

View File

@ -19,6 +19,7 @@ export async function testMiGPT() {
password: process.env.MI_PASS!, password: process.env.MI_PASS!,
did: process.env.MI_DID, did: process.env.MI_DID,
debug: true, debug: true,
streamResponse: false,
}, },
bot: { bot: {
name: "傻妞", name: "傻妞",