@foxden-app/foxclaw 0.5.49 → 0.5.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -0
- package/CHANGELOG.md +24 -0
- package/dist/channels/bridge_messaging_router.d.ts +1 -1
- package/dist/channels/bridge_messaging_router.js +2 -2
- package/dist/channels/telegram/telegram_messaging_port.d.ts +1 -1
- package/dist/channels/telegram/telegram_messaging_port.js +2 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/controller/controller.d.ts +1 -0
- package/dist/controller/controller.js +61 -1
- package/dist/i18n.d.ts +2 -0
- package/dist/i18n.js +3 -0
- package/dist/telegram/gateway.d.ts +1 -1
- package/dist/telegram/gateway.js +2 -2
- package/dist/voice/tts.js +7 -4
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -72,7 +72,9 @@ CODEX_CLI_BIN=/absolute/path/to/codex
|
|
|
72
72
|
# VOICE_TTS_SSH_DIR=/path/to/qwen-speech-server
|
|
73
73
|
# VOICE_TTS_DESIGN_INSTRUCT=用自然清晰的中文女声朗读,语速适中。
|
|
74
74
|
# VOICE_SUMMARY_BUTTON_ENABLED=true
|
|
75
|
+
# VOICE_SUMMARY_TEXT_LIMIT=180
|
|
75
76
|
# VOICE_TEXT_LIMIT=2800
|
|
77
|
+
# VOICE_TTS_TIMEOUT_MS=120000
|
|
76
78
|
|
|
77
79
|
# Optional: standard HTTP(S) proxy for Telegram and ChatGPT/Codex backend requests.
|
|
78
80
|
# Put these in the same env file that `foxclaw start` installs into systemd/launchd.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.51 - 2026-06-20
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 新增 `/voice file <path> [caption]`,可以把 Codex 已经生成好的本地音频文件直接作为 Telegram voice 发出去,不再重复 TTS。
|
|
9
|
+
- 支持 `.ogg`、`.opus`、`.oga`、`.mp3`、`.m4a`,相对路径按 `DEFAULT_CWD` 解析,并限制 50MB 以内。
|
|
10
|
+
- Telegram voice multipart 现在会按文件类型设置 content type,避免所有语音文件都被当成 `audio/ogg`。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- Added `/voice file <path> [caption]` for sending an already-generated local audio file as a Telegram voice message without re-running TTS.
|
|
14
|
+
- Supports `.ogg`, `.opus`, `.oga`, `.mp3`, and `.m4a`; relative paths resolve from `DEFAULT_CWD` and files are capped at 50MB.
|
|
15
|
+
- Telegram voice multipart uploads now set content type by file type instead of treating every voice file as `audio/ogg`.
|
|
16
|
+
|
|
17
|
+
## 0.5.50 - 2026-06-20
|
|
18
|
+
|
|
19
|
+
### 中文
|
|
20
|
+
- 给 Telegram “听总结”按钮增加独立的 `VOICE_SUMMARY_TEXT_LIMIT`,默认只朗读短摘要,避免把完整最终回复交给慢速 TTS 后端导致长时间排队或超时。
|
|
21
|
+
- 新增 `VOICE_TTS_TIMEOUT_MS`,HTTP/SSH 语音后端共用该超时配置;SSH 超时会终止远端调用,HTTP 超时会中止请求。
|
|
22
|
+
- `/voice <文本>` 仍使用 `VOICE_TEXT_LIMIT`,方便手动朗读较长文本;最终总结按钮走更短的摘要上限,优先保证可听、及时返回。
|
|
23
|
+
|
|
24
|
+
### English
|
|
25
|
+
- Added a separate `VOICE_SUMMARY_TEXT_LIMIT` for Telegram "Listen" buttons, so final-answer voice summaries read a short digest instead of sending the entire final response to slow TTS backends.
|
|
26
|
+
- Added `VOICE_TTS_TIMEOUT_MS` for both HTTP and SSH voice backends; SSH calls are terminated on timeout and HTTP requests are aborted.
|
|
27
|
+
- `/voice <text>` still uses `VOICE_TEXT_LIMIT` for manual longer reads, while final-answer buttons use the shorter summary limit for timely playback.
|
|
28
|
+
|
|
5
29
|
## 0.5.49 - 2026-06-20
|
|
6
30
|
|
|
7
31
|
### 中文
|
|
@@ -17,7 +17,7 @@ export declare class BridgeMessagingRouter {
|
|
|
17
17
|
sendHtml(scopeId: string, text: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
18
18
|
sendRichHtml(scopeId: string, html: string, fallbackHtml: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
19
19
|
sendRichMarkdown(scopeId: string, markdown: string, fallbackText: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
20
|
-
sendVoice(scopeId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
|
|
20
|
+
sendVoice(scopeId: string, filename: string, contents: Buffer, caption?: string, contentType?: string): Promise<number>;
|
|
21
21
|
editPlain(scopeId: string, messageId: number, text: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
22
22
|
editHtml(scopeId: string, messageId: number, text: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
23
23
|
editRichHtml(scopeId: string, messageId: number, html: string, fallbackHtml: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
@@ -49,11 +49,11 @@ export class BridgeMessagingRouter {
|
|
|
49
49
|
}
|
|
50
50
|
return this.telegram.sendRichMarkdown(scopeId, markdown, keyboard);
|
|
51
51
|
}
|
|
52
|
-
sendVoice(scopeId, filename, contents, caption) {
|
|
52
|
+
sendVoice(scopeId, filename, contents, caption, contentType) {
|
|
53
53
|
if (this.isWeixinScope(scopeId)) {
|
|
54
54
|
throw new Error(`Voice messages are not supported for Weixin scope ${scopeId}`);
|
|
55
55
|
}
|
|
56
|
-
return this.telegram.sendVoice(scopeId, filename, contents, caption);
|
|
56
|
+
return this.telegram.sendVoice(scopeId, filename, contents, caption, contentType);
|
|
57
57
|
}
|
|
58
58
|
editPlain(scopeId, messageId, text, keyboard) {
|
|
59
59
|
if (this.isWeixinScope(scopeId)) {
|
|
@@ -15,7 +15,7 @@ export declare class TelegramMessagingPort implements ChannelPort {
|
|
|
15
15
|
sendHtml(bridgeScopeId: string, text: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
16
16
|
sendRichHtml(bridgeScopeId: string, html: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
17
17
|
sendRichMarkdown(bridgeScopeId: string, markdown: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
18
|
-
sendVoice(bridgeScopeId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
|
|
18
|
+
sendVoice(bridgeScopeId: string, filename: string, contents: Buffer, caption?: string, contentType?: string): Promise<number>;
|
|
19
19
|
editPlain(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
20
20
|
editHtml(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
21
21
|
editRichHtml(bridgeScopeId: string, messageId: number, html: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
@@ -24,9 +24,9 @@ export class TelegramMessagingPort {
|
|
|
24
24
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
25
25
|
return this.gateway.sendRichMessage(target.chatId, telegramRichMarkdown(markdown, { skipEntityDetection: true }), inlineKeyboard, target.topicId);
|
|
26
26
|
}
|
|
27
|
-
async sendVoice(bridgeScopeId, filename, contents, caption) {
|
|
27
|
+
async sendVoice(bridgeScopeId, filename, contents, caption, contentType) {
|
|
28
28
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
29
|
-
return this.gateway.sendVoice(target.chatId, filename, contents, caption, target.topicId);
|
|
29
|
+
return this.gateway.sendVoice(target.chatId, filename, contents, caption, target.topicId, contentType);
|
|
30
30
|
}
|
|
31
31
|
async editPlain(bridgeScopeId, messageId, text, inlineKeyboard) {
|
|
32
32
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
package/dist/config.d.ts
CHANGED
|
@@ -72,7 +72,9 @@ export interface AppConfig {
|
|
|
72
72
|
voiceTtsDesignInstruct: string;
|
|
73
73
|
voiceFfmpegBin: string;
|
|
74
74
|
voiceSummaryButtonEnabled: boolean;
|
|
75
|
+
voiceSummaryTextLimit: number;
|
|
75
76
|
voiceTextLimit: number;
|
|
77
|
+
voiceTtsTimeoutMs: number;
|
|
76
78
|
}
|
|
77
79
|
export declare function loadConfig(): AppConfig;
|
|
78
80
|
export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
|
package/dist/config.js
CHANGED
|
@@ -106,7 +106,9 @@ export function loadConfig() {
|
|
|
106
106
|
voiceTtsDesignInstruct: process.env.VOICE_TTS_DESIGN_INSTRUCT?.trim() || '用自然清晰的中文女声朗读,语速适中。',
|
|
107
107
|
voiceFfmpegBin: process.env.VOICE_FFMPEG_BIN?.trim() || 'ffmpeg',
|
|
108
108
|
voiceSummaryButtonEnabled: boolEnv('VOICE_SUMMARY_BUTTON_ENABLED', true),
|
|
109
|
+
voiceSummaryTextLimit: intEnv('VOICE_SUMMARY_TEXT_LIMIT', 180),
|
|
109
110
|
voiceTextLimit: intEnv('VOICE_TEXT_LIMIT', 2800),
|
|
111
|
+
voiceTtsTimeoutMs: intEnv('VOICE_TTS_TIMEOUT_MS', 120_000),
|
|
110
112
|
};
|
|
111
113
|
ensureAppDirs(config);
|
|
112
114
|
return config;
|
|
@@ -319,6 +319,7 @@ export declare class BridgeSessionCore {
|
|
|
319
319
|
private handleQuotaCommand;
|
|
320
320
|
private handleQuotaNudgeCommand;
|
|
321
321
|
private handleVoiceCommand;
|
|
322
|
+
private handleVoiceFileCommand;
|
|
322
323
|
private handleVoiceCallback;
|
|
323
324
|
private sendVoiceForText;
|
|
324
325
|
private handleLoginDeviceCommand;
|
|
@@ -5175,6 +5175,10 @@ export class BridgeSessionCore {
|
|
|
5175
5175
|
await this.sendMessage(scopeId, t(locale, 'quota_nudge_sent'));
|
|
5176
5176
|
}
|
|
5177
5177
|
async handleVoiceCommand(scopeId, locale, args) {
|
|
5178
|
+
if (args[0]?.toLowerCase() === 'file' || args[0]?.toLowerCase() === 'send') {
|
|
5179
|
+
await this.handleVoiceFileCommand(scopeId, locale, args.slice(1));
|
|
5180
|
+
return;
|
|
5181
|
+
}
|
|
5178
5182
|
const raw = args.join(' ').trim();
|
|
5179
5183
|
const snippetId = raw.toLowerCase() === 'last' ? this.latestVoiceSnippetByScope.get(scopeId) ?? null : null;
|
|
5180
5184
|
const text = snippetId ? this.voiceSnippets.get(snippetId)?.text ?? '' : raw;
|
|
@@ -5184,6 +5188,47 @@ export class BridgeSessionCore {
|
|
|
5184
5188
|
}
|
|
5185
5189
|
await this.sendVoiceForText(scopeId, locale, text);
|
|
5186
5190
|
}
|
|
5191
|
+
async handleVoiceFileCommand(scopeId, locale, args) {
|
|
5192
|
+
if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
|
|
5193
|
+
await this.sendMessage(scopeId, locale === 'zh' ? '当前只有 Telegram 支持语音消息。' : 'Voice messages are currently supported only on Telegram.');
|
|
5194
|
+
return;
|
|
5195
|
+
}
|
|
5196
|
+
const fileArg = args[0]?.trim();
|
|
5197
|
+
if (!fileArg) {
|
|
5198
|
+
await this.sendMessage(scopeId, locale === 'zh'
|
|
5199
|
+
? '用法:/voice file /path/to/audio.ogg [说明]'
|
|
5200
|
+
: 'Usage: /voice file /path/to/audio.ogg [caption]');
|
|
5201
|
+
return;
|
|
5202
|
+
}
|
|
5203
|
+
const filePath = path.resolve(this.config.defaultCwd, fileArg);
|
|
5204
|
+
const contentType = telegramVoiceContentType(filePath);
|
|
5205
|
+
if (!contentType) {
|
|
5206
|
+
await this.sendMessage(scopeId, locale === 'zh'
|
|
5207
|
+
? '只支持作为 Telegram voice 发送的音频格式:.ogg、.opus、.oga、.mp3、.m4a。'
|
|
5208
|
+
: 'Supported Telegram voice file formats: .ogg, .opus, .oga, .mp3, .m4a.');
|
|
5209
|
+
return;
|
|
5210
|
+
}
|
|
5211
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
5212
|
+
if (!stat?.isFile()) {
|
|
5213
|
+
await this.sendMessage(scopeId, locale === 'zh' ? `找不到音频文件:${filePath}` : `Audio file not found: ${filePath}`);
|
|
5214
|
+
return;
|
|
5215
|
+
}
|
|
5216
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
5217
|
+
await this.sendMessage(scopeId, locale === 'zh' ? 'Telegram voice 文件不能超过 50MB。' : 'Telegram voice files must be 50MB or smaller.');
|
|
5218
|
+
return;
|
|
5219
|
+
}
|
|
5220
|
+
try {
|
|
5221
|
+
const contents = await fs.readFile(filePath);
|
|
5222
|
+
const caption = args.slice(1).join(' ').trim() || (locale === 'zh' ? 'FoxClaw 语音文件' : 'FoxClaw voice file');
|
|
5223
|
+
await this.messaging.sendVoice(scopeId, path.basename(filePath), contents, caption, contentType);
|
|
5224
|
+
}
|
|
5225
|
+
catch (error) {
|
|
5226
|
+
this.logger.warn('voice.file_send_failed', { scopeId, filePath, error: toErrorMeta(error) });
|
|
5227
|
+
await this.sendMessage(scopeId, locale === 'zh'
|
|
5228
|
+
? `语音文件发送失败:${formatUserError(error)}`
|
|
5229
|
+
: `Voice file send failed: ${formatUserError(error)}`);
|
|
5230
|
+
}
|
|
5231
|
+
}
|
|
5187
5232
|
async handleVoiceCallback(event, localId, locale) {
|
|
5188
5233
|
const snippet = this.voiceSnippets.get(localId);
|
|
5189
5234
|
if (!snippet || snippet.scopeId !== event.scopeId) {
|
|
@@ -8394,7 +8439,7 @@ export class BridgeSessionCore {
|
|
|
8394
8439
|
}
|
|
8395
8440
|
registerVoiceSnippet(scopeId, text) {
|
|
8396
8441
|
this.pruneVoiceSnippets();
|
|
8397
|
-
const normalized = normalizeVoiceText(text, this.config.
|
|
8442
|
+
const normalized = normalizeVoiceText(text, this.config.voiceSummaryTextLimit);
|
|
8398
8443
|
const id = crypto.randomBytes(6).toString('hex');
|
|
8399
8444
|
this.voiceSnippets.set(id, { scopeId, text: normalized, createdAt: Date.now() });
|
|
8400
8445
|
this.latestVoiceSnippetByScope.set(scopeId, id);
|
|
@@ -8451,6 +8496,21 @@ function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
|
|
|
8451
8496
|
active.segments.push(segment);
|
|
8452
8497
|
return segment;
|
|
8453
8498
|
}
|
|
8499
|
+
function telegramVoiceContentType(filePath) {
|
|
8500
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
8501
|
+
switch (extension) {
|
|
8502
|
+
case '.ogg':
|
|
8503
|
+
case '.oga':
|
|
8504
|
+
case '.opus':
|
|
8505
|
+
return 'audio/ogg';
|
|
8506
|
+
case '.mp3':
|
|
8507
|
+
return 'audio/mpeg';
|
|
8508
|
+
case '.m4a':
|
|
8509
|
+
return 'audio/mp4';
|
|
8510
|
+
default:
|
|
8511
|
+
return null;
|
|
8512
|
+
}
|
|
8513
|
+
}
|
|
8454
8514
|
function renderCollapsedCommentary(locale, segments) {
|
|
8455
8515
|
const firstAt = segments[0]?.startedAtMs ?? Date.now();
|
|
8456
8516
|
const lastSegment = segments[segments.length - 1];
|
package/dist/i18n.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ declare const MESSAGES: {
|
|
|
46
46
|
readonly cmd_desc_fast: "Toggle Fast mode";
|
|
47
47
|
readonly cmd_desc_active: "Active-turn message behavior";
|
|
48
48
|
readonly cmd_desc_rich: "Telegram RichMessage demo";
|
|
49
|
+
readonly cmd_desc_voice: "Send voice from text or file";
|
|
49
50
|
readonly cmd_desc_status: "Bridge status";
|
|
50
51
|
readonly cmd_desc_update: "Update and restart FoxClaw";
|
|
51
52
|
readonly cmd_desc_account: "Codex account";
|
|
@@ -745,6 +746,7 @@ declare const MESSAGES: {
|
|
|
745
746
|
readonly cmd_desc_fast: "切换 Fast 模式";
|
|
746
747
|
readonly cmd_desc_active: "运行中新消息处理方式";
|
|
747
748
|
readonly cmd_desc_rich: "Telegram RichMessage 演示";
|
|
749
|
+
readonly cmd_desc_voice: "朗读文本或发送语音文件";
|
|
748
750
|
readonly cmd_desc_status: "查看桥接状态";
|
|
749
751
|
readonly cmd_desc_update: "升级并重启 FoxClaw";
|
|
750
752
|
readonly cmd_desc_account: "Codex 账号";
|
package/dist/i18n.js
CHANGED
|
@@ -44,6 +44,7 @@ const MESSAGES = {
|
|
|
44
44
|
cmd_desc_fast: 'Toggle Fast mode',
|
|
45
45
|
cmd_desc_active: 'Active-turn message behavior',
|
|
46
46
|
cmd_desc_rich: 'Telegram RichMessage demo',
|
|
47
|
+
cmd_desc_voice: 'Send voice from text or file',
|
|
47
48
|
cmd_desc_status: 'Bridge status',
|
|
48
49
|
cmd_desc_update: 'Update and restart FoxClaw',
|
|
49
50
|
cmd_desc_account: 'Codex account',
|
|
@@ -743,6 +744,7 @@ const MESSAGES = {
|
|
|
743
744
|
cmd_desc_fast: '切换 Fast 模式',
|
|
744
745
|
cmd_desc_active: '运行中新消息处理方式',
|
|
745
746
|
cmd_desc_rich: 'Telegram RichMessage 演示',
|
|
747
|
+
cmd_desc_voice: '朗读文本或发送语音文件',
|
|
746
748
|
cmd_desc_status: '查看桥接状态',
|
|
747
749
|
cmd_desc_update: '升级并重启 FoxClaw',
|
|
748
750
|
cmd_desc_account: 'Codex 账号',
|
|
@@ -1417,6 +1419,7 @@ export function getTelegramCommands(locale) {
|
|
|
1417
1419
|
{ command: 'fast', description: t(locale, 'cmd_desc_fast') },
|
|
1418
1420
|
{ command: 'active', description: t(locale, 'cmd_desc_active') },
|
|
1419
1421
|
{ command: 'rich', description: t(locale, 'cmd_desc_rich') },
|
|
1422
|
+
{ command: 'voice', description: t(locale, 'cmd_desc_voice') },
|
|
1420
1423
|
{ command: 'account', description: t(locale, 'cmd_desc_account') },
|
|
1421
1424
|
{ command: 'quota', description: t(locale, 'cmd_desc_quota') },
|
|
1422
1425
|
{ command: 'login_device', description: t(locale, 'cmd_desc_login_device') },
|
|
@@ -68,7 +68,7 @@ export declare class TelegramGateway extends EventEmitter {
|
|
|
68
68
|
callback_data: string;
|
|
69
69
|
}>>, messageThreadId?: number | null): Promise<number>;
|
|
70
70
|
sendDocument(chatId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
|
|
71
|
-
sendVoice(chatId: string, filename: string, contents: Buffer, caption?: string, messageThreadId?: number | null): Promise<number>;
|
|
71
|
+
sendVoice(chatId: string, filename: string, contents: Buffer, caption?: string, messageThreadId?: number | null, contentType?: string): Promise<number>;
|
|
72
72
|
sendMessageDraft(chatId: string, draftId: number, text: string, messageThreadId?: number | null): Promise<void>;
|
|
73
73
|
sendRichMessageDraft(chatId: string, draftId: number, richMessage: TelegramInputRichMessage, messageThreadId?: number | null): Promise<void>;
|
|
74
74
|
editMessage(chatId: string, messageId: number, text: string, inlineKeyboard?: Array<Array<{
|
package/dist/telegram/gateway.js
CHANGED
|
@@ -83,7 +83,7 @@ export class TelegramGateway extends EventEmitter {
|
|
|
83
83
|
}
|
|
84
84
|
return result.result.message_id;
|
|
85
85
|
}
|
|
86
|
-
async sendVoice(chatId, filename, contents, caption, messageThreadId) {
|
|
86
|
+
async sendVoice(chatId, filename, contents, caption, messageThreadId, contentType = 'audio/ogg') {
|
|
87
87
|
const result = await callTelegramMultipartApi(this.botToken, 'sendVoice', {
|
|
88
88
|
chat_id: chatId,
|
|
89
89
|
...(caption ? { caption } : {}),
|
|
@@ -92,7 +92,7 @@ export class TelegramGateway extends EventEmitter {
|
|
|
92
92
|
fieldName: 'voice',
|
|
93
93
|
filename,
|
|
94
94
|
contents,
|
|
95
|
-
contentType
|
|
95
|
+
contentType,
|
|
96
96
|
}]);
|
|
97
97
|
if (!result.ok || !result.result) {
|
|
98
98
|
throw new Error(result.description || 'Failed to send Telegram voice message');
|
package/dist/voice/tts.js
CHANGED
|
@@ -61,14 +61,17 @@ async function requestTtsWav(config, pathname, body) {
|
|
|
61
61
|
throw new Error('VOICE_TTS_URL is not configured');
|
|
62
62
|
}
|
|
63
63
|
const endpoint = new URL(pathname, config.voiceTtsUrl.endsWith('/') ? config.voiceTtsUrl : `${config.voiceTtsUrl}/`);
|
|
64
|
+
const abortController = new AbortController();
|
|
65
|
+
const timer = setTimeout(() => abortController.abort(), config.voiceTtsTimeoutMs);
|
|
64
66
|
const response = await fetch(endpoint, {
|
|
65
67
|
method: 'POST',
|
|
68
|
+
signal: abortController.signal,
|
|
66
69
|
headers: {
|
|
67
70
|
'content-type': 'application/json',
|
|
68
71
|
...(config.voiceTtsToken ? { authorization: `Bearer ${config.voiceTtsToken}` } : {}),
|
|
69
72
|
},
|
|
70
73
|
body: JSON.stringify(body),
|
|
71
|
-
});
|
|
74
|
+
}).finally(() => clearTimeout(timer));
|
|
72
75
|
if (!response.ok) {
|
|
73
76
|
throw new Error(`TTS request failed: ${response.status} ${response.statusText}: ${await response.text().catch(() => '')}`);
|
|
74
77
|
}
|
|
@@ -172,9 +175,9 @@ PY
|
|
|
172
175
|
encodedText,
|
|
173
176
|
config.voiceTtsSshDir,
|
|
174
177
|
config.voiceTtsDesignInstruct,
|
|
175
|
-
], script);
|
|
178
|
+
], script, config.voiceTtsTimeoutMs);
|
|
176
179
|
}
|
|
177
|
-
async function runSshBinary(args, stdin) {
|
|
180
|
+
async function runSshBinary(args, stdin, timeoutMs) {
|
|
178
181
|
return new Promise((resolve, reject) => {
|
|
179
182
|
const child = spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
180
183
|
const stdout = [];
|
|
@@ -182,7 +185,7 @@ async function runSshBinary(args, stdin) {
|
|
|
182
185
|
const timer = setTimeout(() => {
|
|
183
186
|
child.kill('SIGTERM');
|
|
184
187
|
reject(new Error('remote TTS timed out'));
|
|
185
|
-
},
|
|
188
|
+
}, timeoutMs);
|
|
186
189
|
child.stdout.on('data', (chunk) => {
|
|
187
190
|
stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
188
191
|
});
|