fix: just rm symbol not include text
Browse files- src/hooks/useFeishuBridge.ts +23 -5
- src/services/feishu/FeishuService.ts +95 -65
src/hooks/useFeishuBridge.ts
CHANGED
|
@@ -117,11 +117,29 @@ export function useFeishuBridge({ messages, isLoading }: Props): void {
|
|
| 117 |
// Also send TTS voice if enabled
|
| 118 |
const config = getFeishuConfig()
|
| 119 |
if (config.ttsEnabled) {
|
| 120 |
-
const
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
if (plainText) {
|
| 126 |
await feishuService.sendVoice(completedTurn.chatId, plainText)
|
| 127 |
}
|
|
|
|
| 117 |
// Also send TTS voice if enabled
|
| 118 |
const config = getFeishuConfig()
|
| 119 |
if (config.ttsEnabled) {
|
| 120 |
+
const rawText = completedTurn.responseParts.join('\n')
|
| 121 |
+
|
| 122 |
+
// Deep-clean text before sending to TTS
|
| 123 |
+
let plainText = rawText
|
| 124 |
+
// 去掉代码块标记 ``` ,但保留里面内容
|
| 125 |
+
plainText = plainText.replace(/```(\w*)\n?([\s\S]*?)```/g, '$2')
|
| 126 |
+
// 去掉行内代码标记 ` ,但保留内容
|
| 127 |
+
plainText = plainText.replace(/`([^`]+)`/g, '$1')
|
| 128 |
+
// 去掉 markdown 语法符号(# * _ ~ > | 等)
|
| 129 |
+
plainText = plainText.replace(/[#*_~>|^=\-\[\]()>|\\]/g, '')
|
| 130 |
+
// 去掉花括号内容
|
| 131 |
+
plainText = plainText.replace(/\{[^}]*\}/g, '')
|
| 132 |
+
// 去掉 emoji
|
| 133 |
+
plainText = plainText.replace(
|
| 134 |
+
/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{FE0F}]/gu,
|
| 135 |
+
'',
|
| 136 |
+
)
|
| 137 |
+
// 合并多个换行/空行为单个换行
|
| 138 |
+
plainText = plainText.replace(/\n{2,}/g, '\n')
|
| 139 |
+
// 去掉行首尾空白
|
| 140 |
+
plainText = plainText.split('\n').map(l => l.trim()).join('\n')
|
| 141 |
+
plainText = plainText.trim()
|
| 142 |
+
|
| 143 |
if (plainText) {
|
| 144 |
await feishuService.sendVoice(completedTurn.chatId, plainText)
|
| 145 |
}
|
src/services/feishu/FeishuService.ts
CHANGED
|
@@ -506,7 +506,7 @@ class FeishuService {
|
|
| 506 |
const { optimizeMarkdownForFeishu } = await import(
|
| 507 |
'../../utils/feishuMarkdown.js'
|
| 508 |
)
|
| 509 |
-
const optimized = optimizeMarkdownForFeishu(markdown)
|
| 510 |
await this.channel.send(chatId, { markdown: optimized })
|
| 511 |
} catch (err) {
|
| 512 |
log.fail('send', err, { chatId, markdownLen: markdown.length })
|
|
@@ -518,96 +518,126 @@ class FeishuService {
|
|
| 518 |
try {
|
| 519 |
const config = getFeishuConfig()
|
| 520 |
if (!config.ttsEnabled) return
|
| 521 |
-
|
| 522 |
-
const { readFile, unlink } = await import('node:fs/promises')
|
| 523 |
const { spawn } = await import('node:child_process')
|
| 524 |
-
|
| 525 |
const provider = config.ttsProvider || 'edge'
|
| 526 |
-
const timestamp = Date.now()
|
| 527 |
-
const rawBase = path.join(os.tmpdir(), `feishu_tts_raw_${timestamp}`)
|
| 528 |
-
const oggPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}.ogg`)
|
| 529 |
|
| 530 |
if (provider === 'voxcpm') {
|
| 531 |
const refAudio = config.ttsReferenceAudio
|
| 532 |
-
if (!refAudio)
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
return
|
| 535 |
}
|
| 536 |
-
console.log('[feishu][tts] voxcpm: synthesizing with ref:', refAudio, 'output base:', rawBase)
|
| 537 |
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
{ shell: false },
|
| 543 |
-
)
|
| 544 |
-
let stdout = ''
|
| 545 |
-
let stderr = ''
|
| 546 |
-
child.stdout?.on('data', c => { stdout += String(c) })
|
| 547 |
-
child.stderr?.on('data', c => { stderr += String(c) })
|
| 548 |
-
child.on('close', code => {
|
| 549 |
-
console.log('[feishu][tts] voxcpm exit code:', code)
|
| 550 |
-
if (code === 0) resolve()
|
| 551 |
-
else reject(new Error(`voxcpm exit ${code}: ${stderr.slice(0, 200)}`))
|
| 552 |
-
})
|
| 553 |
-
child.on('error', reject)
|
| 554 |
-
})
|
| 555 |
-
} else {
|
| 556 |
-
const voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
|
| 557 |
-
console.log('[feishu][tts] edge-tts: voice:', voice, 'text len:', text.length)
|
| 558 |
|
|
|
|
|
|
|
| 559 |
await new Promise<void>((resolve, reject) => {
|
| 560 |
-
const child = spawn('
|
| 561 |
-
|
| 562 |
-
'--text', text,
|
| 563 |
-
'--write-media', rawBase,
|
| 564 |
-
])
|
| 565 |
-
let stderr = ''
|
| 566 |
-
child.stderr?.on('data', c => { stderr += String(c) })
|
| 567 |
-
child.on('close', code => {
|
| 568 |
-
console.log('[feishu][tts] edge-tts exit code:', code)
|
| 569 |
-
if (code === 0) resolve()
|
| 570 |
-
else reject(new Error(`edge-tts exit ${code}: ${stderr.slice(0, 200)}`))
|
| 571 |
-
})
|
| 572 |
child.on('error', reject)
|
| 573 |
})
|
| 574 |
-
}
|
| 575 |
|
| 576 |
-
|
| 577 |
-
|
|
|
|
| 578 |
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
await new Promise<void>((resolve, reject) => {
|
| 582 |
-
const child = spawn('
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
child.on('close', code => {
|
| 590 |
-
console.log('[feishu][tts] ffmpeg exit code:', code)
|
| 591 |
-
if (code === 0) resolve()
|
| 592 |
-
else reject(new Error(`ffmpeg exit ${code}`))
|
| 593 |
-
})
|
| 594 |
child.on('error', reject)
|
| 595 |
})
|
| 596 |
-
|
| 597 |
const buffer = await readFile(oggPath)
|
| 598 |
-
console.log('[feishu][tts] sending audio buffer, size:', buffer.length)
|
| 599 |
await this.channel.send(chatId, { audio: { source: buffer } })
|
| 600 |
-
console.log('[feishu][tts] sent successfully')
|
| 601 |
-
|
| 602 |
-
// Clean up temp files
|
| 603 |
await unlink(rawPath).catch(() => {})
|
| 604 |
await unlink(oggPath).catch(() => {})
|
| 605 |
} catch (err) {
|
| 606 |
-
// Don't let TTS failure break the main message flow
|
| 607 |
console.log('[feishu][tts] send-voice-failed:', err instanceof Error ? err.message : String(err))
|
| 608 |
}
|
| 609 |
}
|
| 610 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 612 |
onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
|
| 613 |
// No-op
|
|
|
|
| 506 |
const { optimizeMarkdownForFeishu } = await import(
|
| 507 |
'../../utils/feishuMarkdown.js'
|
| 508 |
)
|
| 509 |
+
const optimized = optimizeMarkdownForFeishu(markdown).replace(/<br\s*\/?>/gi, '\n')
|
| 510 |
await this.channel.send(chatId, { markdown: optimized })
|
| 511 |
} catch (err) {
|
| 512 |
log.fail('send', err, { chatId, markdownLen: markdown.length })
|
|
|
|
| 518 |
try {
|
| 519 |
const config = getFeishuConfig()
|
| 520 |
if (!config.ttsEnabled) return
|
| 521 |
+
const { readFile, unlink, writeFile } = await import('node:fs/promises')
|
|
|
|
| 522 |
const { spawn } = await import('node:child_process')
|
|
|
|
| 523 |
const provider = config.ttsProvider || 'edge'
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
if (provider === 'voxcpm') {
|
| 526 |
const refAudio = config.ttsReferenceAudio
|
| 527 |
+
if (!refAudio) return
|
| 528 |
+
const chunks = this.splitTextForVoxCpm(text)
|
| 529 |
+
console.log('[feishu][tts] voxcpm: splitting into', chunks.length, 'chunks')
|
| 530 |
+
const timestamp = Date.now()
|
| 531 |
+
const rawPaths: string[] = []
|
| 532 |
+
const oggPaths: string[] = []
|
| 533 |
+
|
| 534 |
+
// Generate each chunk
|
| 535 |
+
for (let i = 0; i < chunks.length; i++) {
|
| 536 |
+
const chunk = chunks[i]!.trim()
|
| 537 |
+
if (!chunk) continue
|
| 538 |
+
console.log('[feishu][tts] voxcpm: synthesizing chunk', i + 1, '/', chunks.length, 'len:', chunk.length)
|
| 539 |
+
const rawPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_${i}.wav`)
|
| 540 |
+
const oggPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_${i}.ogg`)
|
| 541 |
+
rawPaths.push(rawPath)
|
| 542 |
+
oggPaths.push(oggPath)
|
| 543 |
+
try {
|
| 544 |
+
await new Promise<void>((resolve, reject) => {
|
| 545 |
+
const child = spawn(
|
| 546 |
+
'/home/yuki/Code/Agent/VersperClaw/.venv/bin/voxcpm',
|
| 547 |
+
['clone', '--text', chunk, '--reference-audio', refAudio, '--denoise', '--output', rawPath],
|
| 548 |
+
{ shell: false },
|
| 549 |
+
)
|
| 550 |
+
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`voxcpm exit ${code}`)) })
|
| 551 |
+
child.on('error', reject)
|
| 552 |
+
})
|
| 553 |
+
// Convert each chunk WAV → OGG (so concat can mix sample rates properly)
|
| 554 |
+
await new Promise<void>((resolve, reject) => {
|
| 555 |
+
const child = spawn('ffmpeg', ['-i', rawPath, '-c:a', 'libopus', '-b:a', '128k', '-y', oggPath])
|
| 556 |
+
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)) })
|
| 557 |
+
child.on('error', reject)
|
| 558 |
+
})
|
| 559 |
+
} catch (err) {
|
| 560 |
+
console.log('[feishu][tts] voxcpm: chunk', i + 1, 'failed, skipping:', err instanceof Error ? err.message : String(err))
|
| 561 |
+
rawPaths.pop()
|
| 562 |
+
oggPaths.pop()
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
if (rawPaths.length === 0) {
|
| 567 |
+
console.log('[feishu][tts] voxcpm: no chunks succeeded')
|
| 568 |
return
|
| 569 |
}
|
|
|
|
| 570 |
|
| 571 |
+
// Write concat list for ffmpeg
|
| 572 |
+
const listPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_list.txt`)
|
| 573 |
+
const listContent = oggPaths.map(p => `file '${p}'`).join('\n')
|
| 574 |
+
await writeFile(listPath, listContent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
|
| 576 |
+
// Concat all OGG chunks into one
|
| 577 |
+
const mergedOggPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_merged.ogg`)
|
| 578 |
await new Promise<void>((resolve, reject) => {
|
| 579 |
+
const child = spawn('ffmpeg', ['-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', '-y', mergedOggPath])
|
| 580 |
+
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg concat exit ${code}`)) })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
child.on('error', reject)
|
| 582 |
})
|
|
|
|
| 583 |
|
| 584 |
+
const buffer = await readFile(mergedOggPath)
|
| 585 |
+
await this.channel.send(chatId, { audio: { source: buffer } })
|
| 586 |
+
console.log('[feishu][tts] voxcpm: merged audio sent, size:', buffer.length)
|
| 587 |
|
| 588 |
+
// Clean up
|
| 589 |
+
for (const p of [...rawPaths, ...oggPaths, listPath, mergedOggPath]) {
|
| 590 |
+
await unlink(p).catch(() => {})
|
| 591 |
+
}
|
| 592 |
+
return
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
// Edge TTS — single shot
|
| 596 |
+
const voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
|
| 597 |
+
const timestamp = Date.now()
|
| 598 |
+
const rawPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}`)
|
| 599 |
+
const oggPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}.ogg`)
|
| 600 |
await new Promise<void>((resolve, reject) => {
|
| 601 |
+
const child = spawn('edge-tts', ['--voice', voice, '--text', text, '--write-media', rawPath])
|
| 602 |
+
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`edge-tts exit ${code}`)) })
|
| 603 |
+
child.on('error', reject)
|
| 604 |
+
})
|
| 605 |
+
await new Promise<void>((resolve, reject) => {
|
| 606 |
+
const child = spawn('ffmpeg', ['-i', rawPath, '-c:a', 'libopus', '-b:a', '128k', '-y', oggPath])
|
| 607 |
+
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)) })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
child.on('error', reject)
|
| 609 |
})
|
|
|
|
| 610 |
const buffer = await readFile(oggPath)
|
|
|
|
| 611 |
await this.channel.send(chatId, { audio: { source: buffer } })
|
|
|
|
|
|
|
|
|
|
| 612 |
await unlink(rawPath).catch(() => {})
|
| 613 |
await unlink(oggPath).catch(() => {})
|
| 614 |
} catch (err) {
|
|
|
|
| 615 |
console.log('[feishu][tts] send-voice-failed:', err instanceof Error ? err.message : String(err))
|
| 616 |
}
|
| 617 |
}
|
| 618 |
|
| 619 |
+
/**
|
| 620 |
+
* Split text into VoxCPM-friendly chunks (max ~150 chars each).
|
| 621 |
+
* Split at Chinese/English sentence boundaries to keep grammar intact.
|
| 622 |
+
*/
|
| 623 |
+
private splitTextForVoxCpm(text: string): string[] {
|
| 624 |
+
const sentences: string[] = []
|
| 625 |
+
const parts = text.split(/(?<=[。!?…\n])/)
|
| 626 |
+
let current = ''
|
| 627 |
+
for (const p of parts) {
|
| 628 |
+
const trimmed = p.trim()
|
| 629 |
+
if (!trimmed) continue
|
| 630 |
+
if (current.length + trimmed.length <= 150) {
|
| 631 |
+
current += (current ? ' ' : '') + trimmed
|
| 632 |
+
} else {
|
| 633 |
+
if (current) sentences.push(current)
|
| 634 |
+
current = trimmed
|
| 635 |
+
}
|
| 636 |
+
}
|
| 637 |
+
if (current) sentences.push(current)
|
| 638 |
+
return sentences
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 642 |
onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
|
| 643 |
// No-op
|