diff --git a/.migpt.example.js b/.migpt.example.js index e9a73dd..a6ce25d 100644 --- a/.migpt.example.js +++ b/.migpt.example.js @@ -50,6 +50,8 @@ export default { ttsCommand: [5, 1], // 设备唤醒指令,请到 https://home.miot-spec.com 查询具体指令 wakeUpCommand: [5, 3], + // 是否启用流式响应,部分小爱音箱型号不支持查询播放状态,需要关闭流式响应 + streamResponse: true, // 查询是否在播放中指令,请到 https://home.miot-spec.com 查询具体指令 // playingCommand: [3, 1, 1], }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 293c7ee..6c9b298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 -- 修复小爱回复无法被终止的问题 +- 修复小爱回复无法被终止的问题([issue#5](https://github.com/idootop/mi-gpt/issues/5)) # v2.1.1 -- 修复 DB 初始化失败的问题 +- 修复 DB 初始化失败的问题([issue#17](https://github.com/idootop/mi-gpt/issues/17)) - 优化版本号读取方式(import) # v2.1.0 @@ -15,8 +30,8 @@ # v2.0.1 -- 新增 ARMv7 Docker 镜像 -- 新增 debug 开关,用于调试 [issue#14](https://github.com/idootop/mi-gpt/issues/14) +- 新增 ARMv7 Docker 镜像([issue#15](https://github.com/idootop/mi-gpt/issues/15)) +- 新增 debug 开关,用于调试([issue#14](https://github.com/idootop/mi-gpt/issues/14)) # v2.0.0 diff --git a/src/services/bot/index.ts b/src/services/bot/index.ts index aa7c894..201cf5d 100644 --- a/src/services/bot/index.ts +++ b/src/services/bot/index.ts @@ -225,6 +225,9 @@ export class MyBot { if (answer) { stream.finish(answer); options.onFinished?.(answer); + } else { + stream.finish(answer); + stream.cancel(); } }); return stream; diff --git a/src/services/openai.ts b/src/services/openai.ts index c6dee08..fa4bba6 100644 --- a/src/services/openai.ts +++ b/src/services/openai.ts @@ -80,7 +80,7 @@ class OpenAIClient { }, { signal } ).catch((e) => { - this._logger.error("openai chat failed", e); + this._logger.error("LLM 响应异常", e); return null; }); if (requestId) { @@ -124,7 +124,7 @@ class OpenAIClient { messages: [...systemMsg, { role: "user", content: user }], response_format: jsonMode ? { type: "json_object" } : undefined, }).catch((e) => { - this._logger.error("❌ openai chat failed", e); + this._logger.error("LLM 响应异常", e); return null; }); if (!stream) { diff --git a/src/services/speaker/ai.ts b/src/services/speaker/ai.ts index 1d8f041..93813df 100644 --- a/src/services/speaker/ai.ts +++ b/src/services/speaker/ai.ts @@ -125,7 +125,6 @@ export class AISpeaker extends Speaker { this.askAI = askAI; this.name = name; this.callAIKeywords = callAIKeywords; - this.wakeUpKeywords = wakeUpKeywords; this.exitKeywords = exitKeywords; this.onEnterAI = onEnterAI; @@ -140,6 +139,10 @@ export class AISpeaker extends Speaker { } async enterKeepAlive() { + if (!this.streamResponse) { + await this.response({ text: "流式响应已关闭,无法进入唤醒模式" }); + return; + } // 回应 await this.response({ text: pickOne(this.onEnterAI)!, keepAlive: true }); // 唤醒 @@ -221,7 +224,7 @@ export class AISpeaker extends Speaker { } }, async (msg, data) => { - if (data.answer && data.res !== "break") { + if (data.answer && data.res == null && this.streamResponse) { // 回复完毕 await this.response({ text: pickOne(this.onAIReplied)!, @@ -229,7 +232,7 @@ export class AISpeaker extends Speaker { } }, async (msg, data) => { - if (!data.answer) { + if (data.res === "error") { // 回答异常 await this.response({ audio: this.audioError, diff --git a/src/services/speaker/base.ts b/src/services/speaker/base.ts index d80b7cc..ab1cd60 100644 --- a/src/services/speaker/base.ts +++ b/src/services/speaker/base.ts @@ -23,6 +23,14 @@ type PropertyCommand = [number, number, number]; export type BaseSpeakerConfig = MiServiceConfig & { debug?: boolean; + /** + * 是否启用流式响应 + * + * 部分小爱音箱型号不支持查询播放状态,需要关闭流式响应 + * + * 关闭后会在 LLM 回答完毕后再 TTS 完整文本,且无法使用唤醒模式等功能 + */ + streamResponse?: boolean; /** * 语音合成服务商 */ @@ -55,6 +63,10 @@ export type BaseSpeakerConfig = MiServiceConfig & { * 播放状态检测间隔(单位毫秒,最低 500 毫秒,默认 1 秒) */ checkInterval?: number; + /** + * 下发 TTS 指令多长时间后开始检测播放状态(单位秒,默认 3 秒) + */ + checkTTSStatusAfter?: number; /** * TTS 开始/结束提示音 */ @@ -62,21 +74,26 @@ export type BaseSpeakerConfig = MiServiceConfig & { }; export class BaseSpeaker { - logger = Logger.create({ tag: "Speaker" }); MiNA?: MiNA; MiIOT?: MiIOT; + config: MiServiceConfig; + logger = Logger.create({ tag: "Speaker" }); debug = false; + streamResponse = true; checkInterval: number; + checkTTSStatusAfter: number; tts: TTSProvider; ttsCommand: ActionCommand; wakeUpCommand: ActionCommand; playingCommand?: PropertyCommand; - config: MiServiceConfig; + constructor(config: BaseSpeakerConfig) { this.config = config; const { debug = false, + streamResponse = true, checkInterval = 1000, + checkTTSStatusAfter = 3, tts = "xiaoai", playingCommand, ttsCommand = [5, 1], @@ -84,9 +101,12 @@ export class BaseSpeaker { audioBeep = process.env.AUDIO_BEEP, } = config; this.debug = debug; + this.streamResponse = streamResponse; this.audioBeep = audioBeep; this.checkInterval = clamp(checkInterval, 500, Infinity); + this.checkTTSStatusAfter = checkTTSStatusAfter; this.tts = tts; + // todo 考虑维护常见设备型号的指令列表,并自动从 spec 文件判断属性权限 this.ttsCommand = ttsCommand; this.wakeUpCommand = wakeUpCommand; this.playingCommand = playingCommand; @@ -158,8 +178,8 @@ export class BaseSpeaker { tts = "xiaoai"; // 没有提供豆包语音接口时,只能使用小爱自带 TTS } - const ttsNotXiaoai = (!!stream || !!text) && !audio && tts !== "xiaoai"; - playSFX = ttsNotXiaoai && playSFX; + const ttsNotXiaoai = tts !== "xiaoai" && !audio; + playSFX = this.streamResponse && ttsNotXiaoai && playSFX; if (ttsNotXiaoai && !stream) { // 长文本 TTS 转化成 stream 分段模式 @@ -170,13 +190,17 @@ export class BaseSpeaker { this.responding = true; // 开始响应 if (stream) { - let _response = ""; + let replyText = ""; while (true) { - const { nextSentence, noMore } = stream.getNextResponse(); + let { nextSentence, noMore } = stream.getNextResponse(); + if (!this.streamResponse) { + nextSentence = await stream.getFinalResult(); + noMore = true; + } if (nextSentence) { - if (_response.length < 1) { + if (replyText.length < 1) { // 播放开始提示音 - if (playSFX) { + if (playSFX && this.audioBeep) { await this.MiNA!.play({ url: this.audioBeep }); } // 在播放 TTS 语音之前,先取消小爱音箱的唤醒状态,防止将 TTS 语音识别成用户指令 @@ -195,12 +219,12 @@ export class BaseSpeaker { stream.cancel(); break; } - _response += nextSentence; + replyText += nextSentence; } if (noMore) { - if (_response.length > 0) { + if (replyText.length > 0) { // 播放结束提示音 - if (playSFX) { + if (playSFX && this.audioBeep) { await this.MiNA!.play({ url: this.audioBeep }); } } @@ -213,6 +237,9 @@ export class BaseSpeaker { } await sleep(this.checkInterval); } + if (replyText.length < 1) { + return "error"; + } } else { res = await this._response(options); } @@ -223,7 +250,6 @@ export class BaseSpeaker { private async _response(options: { tts?: TTSProvider; text?: string; - stream?: StreamResponse; audio?: string; speaker?: string; keepAlive?: boolean; @@ -233,7 +259,6 @@ export class BaseSpeaker { let { text, audio, - stream, playSFX = true, keepAlive = false, tts = this.tts, @@ -249,11 +274,12 @@ export class BaseSpeaker { }; const ttsText = text?.replace(/\n\s*\n/g, "\n")?.trim(); - const ttsNotXiaoai = !stream && !!text && !audio && tts !== "xiaoai"; - playSFX = ttsNotXiaoai && playSFX; + const ttsNotXiaoai = tts !== "xiaoai" && !audio; + playSFX = this.streamResponse && ttsNotXiaoai && playSFX; // 播放回复 const play = async (args?: { tts?: string; url?: string }) => { + this.logger.log("🔊 " + (ttsText ?? audio)); // 播放开始提示音 if (playSFX && this.audioBeep) { await this.MiNA!.play({ url: this.audioBeep }); @@ -267,9 +293,13 @@ export class BaseSpeaker { } else { await this.MiNA!.play(args); } - this.logger.log("🔊 " + (ttsText ?? audio)); - // 等待 3 秒,确保本地设备状态已更新 - await sleep(3000); + if (!this.streamResponse) { + // 非流式响应,直接返回,不再等待设备播放完毕 + // todo 考虑后续通过 MioT 通知事件,接收设备播放状态变更通知。 + return; + } + // 等待一段时间,确保本地设备状态已更新 + await sleep(this.checkTTSStatusAfter * 1000); // 等待回答播放完毕 while (true) { let playing: any = { status: "idle" }; @@ -317,7 +347,7 @@ export class BaseSpeaker { // 开始响应 let res; if (audio) { - // 音频回复 + // 优先播放音频回复 res = await play({ url: audio }); } else if (ttsText) { // 文字回复 diff --git a/src/services/speaker/stream.ts b/src/services/speaker/stream.ts index 240c4f9..b562550 100644 --- a/src/services/speaker/stream.ts +++ b/src/services/speaker/stream.ts @@ -58,7 +58,7 @@ export class StreamResponse { } private _nextChunkIdx = 0; - getNextResponse() { + getNextResponse(): { nextSentence?: string; noMore: boolean } { if (this._submitCount > 0) { // 在请求下一条消息前,提交当前收到的所有消息 this._batchSubmitImmediately(); diff --git a/tests/migpt.ts b/tests/migpt.ts index b12197d..09adaeb 100644 --- a/tests/migpt.ts +++ b/tests/migpt.ts @@ -19,6 +19,7 @@ export async function testMiGPT() { password: process.env.MI_PASS!, did: process.env.MI_DID, debug: true, + streamResponse: false, }, bot: { name: "傻妞",