fix: 修复转换微信语音发送逻辑

This commit is contained in:
Wxw-Gu
2026-08-27 14:43:09 +08:00
parent b26d4dd1a7
commit 0d68217dda
18 changed files with 1020 additions and 125 deletions
+27 -1
View File
@@ -123,6 +123,30 @@ function patchPerSendPayload(scriptPath) {
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
}
function patchVoiceAudioBuffer(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
if (!source.includes(staticAllocation)) {
throw new Error('无法定位 wechat_chatter 语音缓冲区')
}
source = source.replace(
staticAllocation,
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
)
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
}
source = source.replace(
audioLengthMarker,
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
}
function patchImageHookReadiness(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (
@@ -182,7 +206,8 @@ function addModifiedWorkNotice(scriptPath) {
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
* and image Hook readiness logging.
* These modifications are not provided by the upstream author.
*/
@@ -194,6 +219,7 @@ function addModifiedWorkNotice(scriptPath) {
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script)
addModifiedWorkNotice(script)
fs.chmodSync(executable, 0o755)
+10 -1
View File
@@ -1777,13 +1777,19 @@ app.whenReady().then(async () => {
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
ipcMain.handle('wechat-personal:getKeepProcess', () =>
personalWechatSendService.getKeepOneBotProcess()
)
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
personalWechatSendService.setKeepOneBotProcess(Boolean(keep))
)
ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
success: personalWechatRuntimeManager.cancelDownload()
}))
ipcMain.handle('wechat-personal:removeRuntime', async () => {
await personalWechatSendService.terminate()
await personalWechatSendService.terminate(true)
return personalWechatRuntimeManager.remove()
})
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
@@ -1797,6 +1803,9 @@ app.whenReady().then(async () => {
ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) =>
personalWechatSendService.send(request)
)
ipcMain.handle('wechat-personal:getVoiceDiagnostic', () =>
personalWechatSendService.getLatestVoiceDiagnostic()
)
ipcMain.handle('wechat-personal:selectImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
@@ -57,6 +57,31 @@ function patchPerSendPayload(scriptPath: string): void {
writeFileSync(scriptPath, source)
}
function patchVoiceAudioBuffer(scriptPath: string, strict = true): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
if (!source.includes(staticAllocation)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace(
staticAllocation,
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
)
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace(
audioLengthMarker,
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
writeFileSync(scriptPath, source)
}
function patchImageHookReadiness(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (
@@ -114,7 +139,8 @@ function addModifiedWorkNotice(scriptPath: string): void {
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
* and image Hook readiness logging.
* These modifications are not provided by the upstream author.
*/
@@ -159,6 +185,12 @@ export class PersonalWechatRuntimeManager {
const runtime = findPersonalWechatRuntime()
if (runtime) {
try {
// Apply compatibility fixes to runtimes installed before this version.
patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false)
} catch {
// Status discovery should remain available even if an old runtime is read-only.
}
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
}
@@ -261,6 +293,7 @@ export class PersonalWechatRuntimeManager {
const script = join(stagedDirectory, 'onebot', 'script.js')
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script)
addModifiedWorkNotice(script)
await chmod(executable, 0o755)
+396 -10
View File
@@ -1,6 +1,6 @@
import { app } from 'electron'
import { execFile, spawn, type ChildProcess } from 'child_process'
import { createHash } from 'crypto'
import { createHash, randomUUID } from 'crypto'
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import { createConnection } from 'net'
import { homedir } from 'os'
@@ -10,10 +10,19 @@ import { promisify } from 'util'
import type {
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} from '../../shared/personal-wechat'
import { isPackagedRuntime } from '../runtime-mode'
import { loadSettings, updateSettings } from './settings-store'
import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder'
import {
validateVoicePcm,
validateVoiceSilkMetadata,
VOICE_FRAME_BYTES,
type VoicePcmMetadata
} from '../voice-pipeline/voice-quality'
import { appLogger } from '../app-logger'
const execFileAsync = promisify(execFile)
const DEFAULT_HOST = '127.0.0.1:58080'
@@ -22,6 +31,10 @@ const REQUEST_TIMEOUT_MS = 20_000
const STOP_TIMEOUT_MS = 3_000
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_VOICE_BYTES = 20 * 1024 * 1024
const MAX_PCM_BYTES = 64 * 1024 * 1024
const VOICE_ENCODER_NAME = 'go-silk'
const VOICE_ENCODER_VERSION = 'wechat_chatter-v0.0.18'
let latestVoiceDiagnostic: PersonalWechatVoiceDiagnostic | null = null
const WECHAT_APP_PATH = '/Applications/WeChat.app'
const WECHAT_FILES_ROOT = join(
homedir(),
@@ -239,6 +252,221 @@ function buildRuntimePythonPath(runtimeRoot: string): string {
.join(delimiter)
}
function bundledFfmpegExecutable(): string {
const bundledFfmpeg = String(ffmpegStaticPath || '')
.replace('app.asar', 'app.asar.unpacked')
.trim()
return bundledFfmpeg && existsSync(bundledFfmpeg) ? bundledFfmpeg : 'ffmpeg'
}
async function convertAudioToPcm(audioData: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn(
bundledFfmpegExecutable(),
['-v', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '16000', '-ac', '1', 'pipe:1'],
{
env: { ...process.env, PATH: buildRuntimePath() },
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
}
)
const chunks: Buffer[] = []
let total = 0
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => {
total += chunk.length
if (total > MAX_PCM_BYTES) {
child.kill()
reject(new Error('转换后的语音 PCM 过大'))
return
}
chunks.push(chunk)
})
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8').slice(0, 2_000)
})
child.once('error', (error) => reject(new Error(`ffmpeg 转换失败:${error.message}`)))
child.once('close', (code) => {
if (code !== 0) {
reject(new Error(`ffmpeg 转换失败${stderr ? `:${stderr.trim()}` : ''}`))
return
}
resolve(Buffer.concat(chunks))
})
child.stdin.end(audioData)
})
}
async function runtimeVoiceLogSnapshot(): Promise<{ path: string; offset: number } | undefined> {
const runtime = findPersonalWechatRuntime()
const runningOneBot = await readOneBotProcessInfo()
// Prefer the runtime discovered by TraceMemo itself. Its path may contain
// spaces (for example, "Application Support"), so parsing `ps` output with
// a whitespace-delimited regex can produce a non-existent log path.
const executable =
runtime?.executable &&
runningOneBot &&
(runningOneBot.command === runtime.executable ||
runningOneBot.command.startsWith(`${runtime.executable} `))
? runtime.executable
: runningOneBot?.command.match(/^(.*\/onebot(?:\/onebot)?)(?:\s|$)/)?.[1]
const processLogPath = executable ? join(dirname(executable), 'log', 'macos.log') : undefined
const logPath = processLogPath && existsSync(processLogPath) ? processLogPath : runtime?.logPath
if (!logPath || !existsSync(logPath)) return undefined
try {
return { path: logPath, offset: statSync(logPath).size }
} catch {
return undefined
}
}
function readVoiceRuntimeEvidence(snapshot?: { path: string; offset: number }): {
uploadResult?: string
uploadDataLen?: number
durationMs?: number
sendResult?: string
} {
if (!snapshot || !existsSync(snapshot.path)) return {}
try {
const data = readFileSync(snapshot.path)
const lines = data
.subarray(Math.min(snapshot.offset, data.length))
.toString('utf8')
.split(/\r?\n/)
const evidence: {
uploadResult?: string
uploadDataLen?: number
durationMs?: number
sendResult?: string
} = {}
for (const line of lines) {
if (!line.trim()) continue
try {
const entry = JSON.parse(line) as Record<string, unknown>
const message = `${String(entry.msg || '')} ${String(entry.message || '')}`
if (message.includes('上传语音任务执行结果')) {
if (entry.result !== undefined) evidence.uploadResult = String(entry.result)
if (entry.silk_len !== undefined) evidence.uploadDataLen = Number(entry.silk_len)
if (entry.duration_ms !== undefined) evidence.durationMs = Number(entry.duration_ms)
}
if (message.includes('发送语音任务执行结果') && entry.result !== undefined) {
evidence.sendResult = String(entry.result)
}
} catch {
// Ignore a partially-written runtime log line.
}
}
return evidence
} catch {
return {}
}
}
async function waitForVoiceRuntimeEvidence(
snapshot: { path: string; offset: number } | undefined,
timeoutMs = 6_000
): Promise<{
uploadResult?: string
uploadDataLen?: number
durationMs?: number
sendResult?: string
}> {
if (!snapshot) return {}
const startedAt = Date.now()
let evidence = readVoiceRuntimeEvidence(snapshot)
while (Date.now() - startedAt < timeoutMs) {
// The OneBot HTTP handler acknowledges the task before its worker writes
// the upload/send callbacks to disk. Poll the request's log suffix so a
// successful asynchronous send is not reported as a validation failure.
if (
evidence.uploadResult !== undefined &&
(evidence.uploadResult !== '0' || evidence.sendResult !== undefined)
) {
return evidence
}
await new Promise((resolve) => setTimeout(resolve, 100))
evidence = readVoiceRuntimeEvidence(snapshot)
}
return evidence
}
const VOICE_DIAGNOSTIC_KEYS = new Set([
'input_bytes',
'normalized_input_bytes',
'pcm_size',
'sample_rate',
'channels',
'input_duration_ms',
'upload_result',
'upload_data_len',
'silk_duration_ms',
'send_result',
'voice_send_mode',
'failure_phase',
'error'
])
function redactVoiceDiagnosticDetails(details: Record<string, unknown>): Record<string, unknown> {
const allowedDetails = Object.fromEntries(
Object.entries(details).filter(([key]) => VOICE_DIAGNOSTIC_KEYS.has(key))
)
if (allowedDetails.error !== undefined) {
const rawError = String(allowedDetails.error).trim()
allowedDetails.error = rawError
.replace(
/(["']?(?:aesKey|cdnKey|token|cookie|authorization|access_token|secret|apiKey)["']?\s*[:=]\s*["']?)([^"',;\]}\s]+)(["']?)/gi,
'$1[redacted]$3'
)
.replace(/Bearer\s+[^\s,;\]}"']+/gi, 'Bearer [redacted]')
.slice(0, 1_000)
}
return allowedDetails
}
export function buildPersonalWechatVoiceDiagnostic(
requestId: string,
phase: PersonalWechatVoiceDiagnostic['phase'],
details: Record<string, unknown>,
previous: PersonalWechatVoiceDiagnostic | null = null
): PersonalWechatVoiceDiagnostic {
const allowedDetails = redactVoiceDiagnosticDetails(details)
return {
...(previous?.request_id === requestId ? previous : {}),
request_id: requestId,
voice_id: requestId,
phase,
encoder_name: VOICE_ENCODER_NAME,
encoder_version: VOICE_ENCODER_VERSION,
...allowedDetails
}
}
function logVoiceAttempt(
requestId: string,
phase: PersonalWechatVoiceDiagnostic['phase'],
details: Record<string, unknown> = {}
): void {
latestVoiceDiagnostic = buildPersonalWechatVoiceDiagnostic(
requestId,
phase,
details,
latestVoiceDiagnostic
)
const allowedDetails = redactVoiceDiagnosticDetails(details)
appLogger.write({
level: phase === 'failed' ? 'error' : 'info',
scope: 'personal-wechat-voice',
message: `voice_${phase}`,
details: {
request_id: requestId,
voice_id: requestId,
encoder_name: VOICE_ENCODER_NAME,
encoder_version: VOICE_ENCODER_VERSION,
...allowedDetails
}
})
}
function createWavBuffer(pcm: Buffer, sampleRate: number, channels: number): Buffer {
const header = Buffer.alloc(44)
header.write('RIFF', 0)
@@ -415,6 +643,18 @@ export class PersonalWechatSendService {
private child: ChildProcess | null = null
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
private lastError = ''
private voiceSendTail: Promise<void> = Promise.resolve()
private keepOneBotProcess = Boolean(loadSettings().keepPersonalWechatProcess)
getKeepOneBotProcess(): boolean {
return this.keepOneBotProcess
}
setKeepOneBotProcess(keep: boolean): boolean {
this.keepOneBotProcess = keep
updateSettings({ keepPersonalWechatProcess: keep })
return this.keepOneBotProcess
}
async getStatus(): Promise<PersonalWechatSenderStatus> {
const preflight = await this.preflight()
@@ -449,7 +689,7 @@ export class PersonalWechatSendService {
canSend: false,
canSendText: false,
canSendImage: false,
message: 'OneBot 仍绑定旧微信进程,请点击“尝试重新绑定”'
message: 'OneBot 仍绑定旧微信进程,请点击“绑定微信”'
}
}
if (hook.readiness === 'failed') {
@@ -459,7 +699,7 @@ export class PersonalWechatSendService {
canSend: false,
canSendText: false,
canSendImage: false,
message: '微信发送 Hook 初始化失败,请尝试重新绑定',
message: '微信发送 Hook 初始化失败,请点击“绑定微信”',
...(hook.error ? { error: hook.error } : {})
}
}
@@ -477,7 +717,7 @@ export class PersonalWechatSendService {
canSendVoice,
message:
hook.imageHookReady && preflight.status.imagePath && !imagePathBound
? 'OneBot 尚未绑定微信图片目录,请点击“尝试重新绑定”'
? 'OneBot 尚未绑定微信图片目录,请点击“绑定微信”'
: canSendText || canSendImage || canSendVoice
? '个人微信已绑定,可使用已初始化的消息类型'
: '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型',
@@ -485,13 +725,42 @@ export class PersonalWechatSendService {
}
}
getLatestVoiceDiagnostic(): PersonalWechatVoiceDiagnostic | null {
return latestVoiceDiagnostic ? { ...latestVoiceDiagnostic } : null
}
async send(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
const requestId = randomUUID()
if (request.type !== 'voice') return this.sendInternal(request, requestId)
// OneBot's voice upload/callback bridge still uses process-wide Frida
// fields. Serialize voice requests here so duration, Silk length and CDN
// callback data cannot cross between two concurrent sends.
const previous = this.voiceSendTail
let release!: () => void
this.voiceSendTail = new Promise<void>((resolve) => {
release = resolve
})
await previous
try {
return await this.sendInternal(request, requestId)
} finally {
release()
}
}
private async sendInternal(
request: PersonalWechatSendRequest,
requestId: string
): Promise<PersonalWechatSendResult> {
const to = String(request?.to || '').trim()
if (!to) {
const status = await this.getStatus()
return { success: false, status, error: '接收者不能为空' }
}
let fileBase64: string | undefined
let voicePcm: VoicePcmMetadata | undefined
let runtimeSnapshot: { path: string; offset: number } | undefined
if (request.type === 'text') {
const text = String(request.text || '').trim()
if (!text) {
@@ -523,9 +792,45 @@ export class PersonalWechatSendService {
error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
}
}
fileBase64 = (
request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath)
).toString('base64')
let fileData: Buffer
try {
fileData =
request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath)
const useLegacyVoicePath = request.type === 'voice' && request.voiceSendMode === 'legacy'
if (request.type === 'voice' && !useLegacyVoicePath) {
const sourceInputBytes = fileData.length
const pcm = await convertAudioToPcm(fileData)
const alignedPcmBytes = Math.floor(pcm.length / VOICE_FRAME_BYTES) * VOICE_FRAME_BYTES
const alignedPcm = pcm.subarray(0, alignedPcmBytes)
voicePcm = validateVoicePcm(alignedPcm)
// The bundled Go encoder consumes 20ms frames. Sending an aligned
// WAV makes the duration in the eventual protobuf match its output.
fileData = createWavBuffer(alignedPcm, voicePcm.sampleRate, voicePcm.channels)
logVoiceAttempt(requestId, 'prepared', {
input_bytes: sourceInputBytes,
normalized_input_bytes: fileData.length,
pcm_size: voicePcm.pcmSize,
sample_rate: voicePcm.sampleRate,
channels: voicePcm.channels,
input_duration_ms: voicePcm.durationMs,
voice_send_mode: 'normalized'
})
} else if (request.type === 'voice') {
logVoiceAttempt(requestId, 'prepared', {
input_bytes: fileData.length,
normalized_input_bytes: fileData.length,
voice_send_mode: 'legacy'
})
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (request.type === 'voice') {
logVoiceAttempt(requestId, 'failed', { failure_phase: 'pcm_validation', error: message })
}
const status = await this.getStatus()
return { success: false, status, error: message }
}
fileBase64 = fileData.toString('base64')
request = { ...request, to, filePath }
}
@@ -547,6 +852,7 @@ export class PersonalWechatSendService {
}
const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64)
if (request.type === 'voice') runtimeSnapshot = await runtimeVoiceLogSnapshot()
try {
const response = await requestWithTimeout(
`http://${DEFAULT_HOST}${oneBot.endpoint}`,
@@ -561,9 +867,83 @@ export class PersonalWechatSendService {
if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`)
const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {}
if (parsed.status && parsed.status !== 'ok') throw new Error(responseText)
if (request.type === 'voice') {
const evidence = await waitForVoiceRuntimeEvidence(runtimeSnapshot)
try {
if (evidence.uploadResult !== '0') throw new Error('未确认微信语音上传结果')
if (evidence.sendResult !== '1') throw new Error('未确认微信语音发送结果')
if (evidence.uploadDataLen === undefined || evidence.durationMs === undefined) {
throw new Error('未获取到微信 Silk 音频元数据')
}
validateVoiceSilkMetadata(evidence.uploadDataLen, evidence.durationMs)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logVoiceAttempt(requestId, 'failed', {
failure_phase: 'silk_validation',
upload_result: evidence.uploadResult,
upload_data_len: evidence.uploadDataLen,
silk_duration_ms: evidence.durationMs,
send_result: evidence.sendResult,
error: message
})
const failedStatus = await this.getStatus()
return {
success: false,
status: { ...failedStatus, state: 'error', error: message },
error: message
}
}
logVoiceAttempt(requestId, 'completed', {
pcm_size: voicePcm?.pcmSize,
input_duration_ms: voicePcm?.durationMs,
upload_result: evidence.uploadResult,
upload_data_len: evidence.uploadDataLen,
silk_duration_ms: evidence.durationMs,
send_result: evidence.sendResult
})
}
return { success: true, status: await this.getStatus() }
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error)
if (request.type === 'voice') {
const evidence = await waitForVoiceRuntimeEvidence(runtimeSnapshot, 1_000)
// OneBot may finish the asynchronous upload/send after its HTTP
// request has timed out. If the runtime log proves that this voice was
// uploaded and sent successfully, do not report a false failure.
const runtimeSendSucceeded =
evidence.uploadResult === '0' &&
evidence.sendResult === '1' &&
evidence.uploadDataLen !== undefined &&
evidence.durationMs !== undefined
if (runtimeSendSucceeded) {
try {
validateVoiceSilkMetadata(evidence.uploadDataLen!, evidence.durationMs!)
this.lastError = ''
logVoiceAttempt(requestId, 'completed', {
pcm_size: voicePcm?.pcmSize,
input_duration_ms: voicePcm?.durationMs,
upload_result: evidence.uploadResult,
upload_data_len: evidence.uploadDataLen,
silk_duration_ms: evidence.durationMs,
send_result: evidence.sendResult
})
return { success: true, status: await this.getStatus() }
} catch {
// Keep the transport error below when the runtime metadata is
// present but fails the same Silk validation as the normal path.
}
}
logVoiceAttempt(requestId, 'failed', {
failure_phase: 'http_send',
pcm_size: voicePcm?.pcmSize,
input_duration_ms: voicePcm?.durationMs,
upload_result: evidence.uploadResult,
upload_data_len: evidence.uploadDataLen,
silk_duration_ms: evidence.durationMs,
send_result: evidence.sendResult,
error: this.lastError
})
}
const failedStatus = await this.getStatus()
return {
success: false,
@@ -614,7 +994,13 @@ export class PersonalWechatSendService {
return status
}
async terminate(): Promise<void> {
async terminate(force = false): Promise<void> {
if (this.keepOneBotProcess && !force) {
this.child = null
this.startPromise = null
this.lastError = ''
return
}
const trackedPid = this.child?.pid
const oneBot = await readOneBotProcessInfo()
if (oneBot) await terminateOneBot(oneBot)
@@ -817,7 +1203,7 @@ export class PersonalWechatSendService {
configPath,
...(imagePath ? { imagePath } : {}),
state: this.child && this.child.exitCode === null ? 'starting' : 'stopped',
message: this.lastError || '尚未绑定当前微信,可点击“尝试重新绑定”'
message: this.lastError || '尚未绑定当前微信,可点击“绑定微信”'
}
}
}
+4 -1
View File
@@ -39,6 +39,8 @@ export interface AppSettings {
showStartupProgress: boolean
ttsSelectedVoiceId: string
ttsModel: TextToSpeechModel
/** Keep a running personal-WeChat OneBot process across app restarts. */
keepPersonalWechatProcess?: boolean
}
function getDefaultDbRoot(): string {
@@ -120,7 +122,8 @@ const DEFAULT_SETTINGS: AppSettings = {
compactMode: false,
showStartupProgress: true,
ttsSelectedVoiceId: '',
ttsModel: 's2.1-pro-free'
ttsModel: 's2.1-pro-free',
keepPersonalWechatProcess: false
}
const SETTINGS_FILE = path.join(
+66
View File
@@ -0,0 +1,66 @@
export const VOICE_SAMPLE_RATE = 16_000
export const VOICE_CHANNELS = 1
export const VOICE_SAMPLE_BYTES = 2
export const VOICE_FRAME_MS = 20
export const VOICE_MIN_PCM_BYTES =
(VOICE_SAMPLE_RATE * VOICE_CHANNELS * VOICE_SAMPLE_BYTES * VOICE_FRAME_MS) / 1000
export const VOICE_FRAME_BYTES = VOICE_MIN_PCM_BYTES
export interface VoicePcmMetadata {
pcmSize: number
sampleRate: number
channels: number
durationMs: number
}
export interface VoiceSilkMetadata {
silkSize: number
durationMs: number
}
/**
* Validate the exact PCM contract consumed by the bundled OneBot encoder.
* The Go Silk encoder emits a header-only payload for sub-frame input, which
* is accepted by the old path but produces an unplayable WeChat voice.
*/
export function validateVoicePcm(
pcm: Uint8Array,
sampleRate = VOICE_SAMPLE_RATE,
channels = VOICE_CHANNELS
): VoicePcmMetadata {
if (sampleRate !== VOICE_SAMPLE_RATE || channels !== VOICE_CHANNELS) {
throw new Error('语音 PCM 必须是 16kHz 单声道')
}
if (pcm.byteLength === 0) throw new Error('语音 PCM 为空')
if (pcm.byteLength % VOICE_SAMPLE_BYTES !== 0) throw new Error('语音 PCM 长度无效')
if (pcm.byteLength < VOICE_MIN_PCM_BYTES) {
throw new Error('语音时长过短,至少需要 20 毫秒')
}
const durationMs = Math.floor(
(pcm.byteLength * 1000) / (sampleRate * channels * VOICE_SAMPLE_BYTES)
)
if (durationMs <= 0) throw new Error('语音时长无效')
return { pcmSize: pcm.byteLength, sampleRate, channels, durationMs }
}
export function validateVoiceSilk(
silk: Uint8Array,
durationMs: number,
silkHeader = '\x02#!SILK_V3'
): VoiceSilkMetadata {
const header = new TextEncoder().encode(silkHeader)
if (silk.byteLength <= header.byteLength) throw new Error('Silk 音频为空或只有文件头')
for (let index = 0; index < header.length; index += 1) {
if (silk[index] !== header[index]) throw new Error('Silk 音频头无效')
}
if (!Number.isFinite(durationMs) || durationMs <= 0) throw new Error('Silk 时长无效')
return { silkSize: silk.byteLength, durationMs: Math.floor(durationMs) }
}
export function validateVoiceSilkMetadata(silkSize: number, durationMs: number): VoiceSilkMetadata {
if (!Number.isInteger(silkSize) || silkSize <= 10) {
throw new Error('Silk 音频为空或只有文件头')
}
if (!Number.isFinite(durationMs) || durationMs <= 0) throw new Error('Silk 时长无效')
return { silkSize, durationMs: Math.floor(durationMs) }
}
+5 -1
View File
@@ -58,7 +58,8 @@ import type {
PersonalWechatVoiceSelectionResult,
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} from '../shared/personal-wechat'
import type {
PersonalWechatRuntimeDownloadResult,
@@ -581,6 +582,8 @@ declare global {
limit?: number
) => Promise<{ success: boolean; insights: ImageInsight[] }>
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
@@ -595,6 +598,7 @@ declare global {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
) => Promise<PersonalWechatSendResult>
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
clearAgentHubLogs: () => Promise<void>
+8 -1
View File
@@ -30,7 +30,8 @@ import type {
PersonalWechatVoiceSelectionResult,
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} from '../shared/personal-wechat'
import type {
PersonalWechatRuntimeDownloadResult,
@@ -352,6 +353,10 @@ const api = {
ipcRenderer.invoke('image:listInsights', sessionId, limit),
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:getStatus'),
getPersonalWechatKeepOneBotProcess: (): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
@@ -381,6 +386,8 @@ const api = {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
@@ -206,7 +206,7 @@ export function PersonalWechatChatComposer({
const sendGenerated = async (): Promise<void> => {
if (!generatedVoiceRef.current) return
const result = await send(
await send(
{
type: 'voice',
to: targetId,
@@ -215,7 +215,20 @@ export function PersonalWechatChatComposer({
},
voiceText.trim() || '语音消息'
)
if (result) clearGeneratedVoice()
}
const sendGeneratedAlternative = async (): Promise<void> => {
if (!generatedVoiceRef.current) return
await send(
{
type: 'voice',
to: targetId,
filePath: generatedVoiceRef.current.filePath,
isGroup: isGroupChat,
voiceSendMode: 'legacy'
},
voiceText.trim() || '语音消息'
)
}
const canSend = Boolean(
@@ -341,7 +354,7 @@ export function PersonalWechatChatComposer({
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
</div>
</div>
<div>
<div className="personal-wechat-generated-result-actions">
<Button
variant="outline"
size="sm"
@@ -362,6 +375,15 @@ export function PersonalWechatChatComposer({
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
{busy ? '发送中…' : '发送'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void sendGeneratedAlternative()}
disabled={!canSend}
title="使用另一条语音发送路径"
>
{busy ? '发送中…' : '空白语音?重新处理'}
</Button>
</div>
</div>
) : (
@@ -2,19 +2,29 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { Contact } from '../../../../shared/types'
import type {
PersonalWechatSendRequest,
PersonalWechatSenderStatus
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} from '../../../../shared/personal-wechat'
import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Switch
} from '../ui'
import {
PersonalWechatChatComposer,
type ChatMessage,
type PersonalWechatComposerMode
} from './PersonalWechatChatComposer'
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
type SelectedLocalFile = { path: string; name: string }
@@ -71,8 +81,15 @@ export function PersonalWechatSendDialog({
const [runtimeBusy, setRuntimeBusy] = useState(false)
const [sendBusy, setSendBusy] = useState(false)
const [detectionAttempted, setDetectionAttempted] = useState(false)
// Status can be reconstructed from a previous OneBot process/log. These
// session gates ensure the user explicitly binds and detects after opening
// the flow instead of inheriting stale readiness.
const [sessionBound, setSessionBound] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [sendError, setSendError] = useState<string | null>(null)
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
const [keepOneBotProcess, setKeepOneBotProcess] = useState(false)
const [composerStarted, setComposerStarted] = useState(Boolean(initialImage))
const requestIdRef = useRef(0)
const restoreFocusRef = useRef<HTMLElement | null>(null)
@@ -81,7 +98,10 @@ export function PersonalWechatSendDialog({
const targetId = contact.m_nsUsrName
const isBusy = binding || runtimeBusy || sendBusy
const setupReady = Boolean(
senderStatus?.canSendText && (senderStatus?.canSendImage || senderStatus?.canSendVoice)
sessionBound &&
detectionAttempted &&
senderStatus?.canSendText &&
(senderStatus?.canSendImage || senderStatus?.canSendVoice)
)
const refreshStatus = useCallback(async (): Promise<void> => {
@@ -97,6 +117,15 @@ export function PersonalWechatSendDialog({
setRuntimeStatus(nextRuntime)
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
setSenderStatus(nextSender)
setSessionBound(
nextSender.state === 'online' ||
Boolean(
nextSender.wechatPid &&
nextSender.boundWechatPid === nextSender.wechatPid &&
nextSender.attachReady &&
nextSender.baseAddressReady
)
)
} catch (error) {
if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error))
} finally {
@@ -114,6 +143,34 @@ export function PersonalWechatSendDialog({
return unsubscribe
}, [refreshStatus])
useEffect(() => {
let active = true
const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess
if (!readKeepProcess) return undefined
void readKeepProcess().then((keep) => {
if (active && typeof keep === 'boolean') setKeepOneBotProcess(keep)
})
return () => {
active = false
}
}, [])
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess
if (!saveKeepProcess) {
setSendError('请重启 TraceMemo 后再使用“保留 OneBot 进程”')
return
}
setKeepOneBotProcess(keep)
try {
const saved = await saveKeepProcess(keep)
if (typeof saved === 'boolean') setKeepOneBotProcess(saved)
} catch (error) {
setKeepOneBotProcess(!keep)
setSendError(error instanceof Error ? error.message : String(error))
}
}
const handleDownloadRuntime = async (): Promise<void> => {
if (runtimeBusy) return
setRuntimeBusy(true)
@@ -138,6 +195,15 @@ export function PersonalWechatSendDialog({
try {
const nextStatus = await window.api.rebindPersonalWechatSender()
setSenderStatus(nextStatus)
setSessionBound(
nextStatus.state === 'online' ||
Boolean(
nextStatus.wechatPid &&
nextStatus.boundWechatPid === nextStatus.wechatPid &&
nextStatus.attachReady &&
nextStatus.baseAddressReady
)
)
if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message)
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error))
@@ -185,103 +251,140 @@ export function PersonalWechatSendDialog({
}
}
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
setVoiceDiagnostic(diagnostic)
setVoiceDiagnosticOpen(true)
}
return (
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
onOpenAutoFocus={() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
}}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
>
<DialogHeader className="personal-wechat-chat-header">
<div className="personal-wechat-chat-avatar" aria-hidden>
{displayName.slice(0, 1)}
</div>
<div className="personal-wechat-chat-heading">
<DialogTitle>{displayName}</DialogTitle>
<DialogDescription>
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
</DialogDescription>
</div>
<span
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
/>
</DialogHeader>
<>
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
onOpenAutoFocus={() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
}}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
>
<DialogHeader className="personal-wechat-chat-header">
<div className="personal-wechat-chat-avatar" aria-hidden>
{displayName.slice(0, 1)}
</div>
<div className="personal-wechat-chat-heading">
<DialogTitle>{displayName}</DialogTitle>
<DialogDescription>
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
</DialogDescription>
</div>
<span
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
/>
</DialogHeader>
<div className="personal-wechat-chat-body">
<div className="personal-wechat-chat-body">
{setupReady && composerStarted && (
<div className="personal-wechat-message-list" aria-label="消息列表">
{messages.length === 0 ? (
<div className="personal-wechat-empty-message">还没有发送消息。</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
>
<span className="personal-wechat-message-kind">
{message.type === 'text'
? '文字'
: message.type === 'image'
? '图片'
: '语音'}
</span>
<span>{message.text || message.fileName}</span>
</div>
))
)}
</div>
)}
{(!setupReady || !composerStarted) && senderStatus && (
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
sessionBound={sessionBound}
onDownloadRuntime={() => void handleDownloadRuntime()}
onBind={() => void handleBind()}
detectionAttempted={detectionAttempted}
onDetect={() => void handleDetect()}
onStartSending={() => setComposerStarted(true)}
onOpenTextToSpeechSettings={handleOpenSettings}
/>
)}
{setupReady && composerStarted && (
<PersonalWechatChatComposer
status={senderStatus!}
targetId={targetId}
isGroupChat={isGroupChat}
initialMode={initialMode}
initialImage={initialImage}
onOpenTextToSpeechSettings={handleOpenSettings}
onCancel={handleClose}
onSend={handleSend}
onMessage={(message) => setMessages((current) => [...current, message])}
busy={sendBusy}
/>
)}
{sendError && (
<div className="personal-wechat-global-error" role="alert">
{sendError}
</div>
)}
</div>
{setupReady && composerStarted && (
<div className="personal-wechat-message-list" aria-label="消息列表">
{messages.length === 0 ? (
<div className="personal-wechat-empty-message">还没有发送消息。</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
>
<span className="personal-wechat-message-kind">
{message.type === 'text'
? '文字'
: message.type === 'image'
? '图片'
: '语音'}
</span>
<span>{message.text || message.fileName}</span>
</div>
))
)}
<div className="personal-wechat-chat-footer flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>保留 OneBot 进程</span>
<Switch
checked={keepOneBotProcess}
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
aria-label="保留 OneBot 进程"
/>
</div>
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
语音发送诊断
</Button>
</div>
)}
{(!setupReady || !composerStarted) && senderStatus && (
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
onDownloadRuntime={() => void handleDownloadRuntime()}
onBind={() => void handleBind()}
detectionAttempted={detectionAttempted}
onDetect={() => void handleDetect()}
onStartSending={() => setComposerStarted(true)}
onOpenTextToSpeechSettings={handleOpenSettings}
/>
)}
{setupReady && composerStarted && (
<PersonalWechatChatComposer
status={senderStatus!}
targetId={targetId}
isGroupChat={isGroupChat}
initialMode={initialMode}
initialImage={initialImage}
onOpenTextToSpeechSettings={handleOpenSettings}
onCancel={handleClose}
onSend={handleSend}
onMessage={(message) => setMessages((current) => [...current, message])}
busy={sendBusy}
/>
)}
{sendError && (
<div className="personal-wechat-global-error" role="alert">
{sendError}
{(!setupReady || !composerStarted) && (
<div className="personal-wechat-chat-footer flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>保留 OneBot 进程</span>
<Switch
checked={keepOneBotProcess}
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
aria-label="保留 OneBot 进程"
/>
</div>
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
关闭
</Button>
</div>
)}
</div>
{(!setupReady || !composerStarted) && (
<div className="personal-wechat-chat-footer">
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
关闭
</Button>
</div>
)}
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
<PersonalWechatVoiceDiagnosticDialog
open={voiceDiagnosticOpen}
diagnostic={voiceDiagnostic}
onOpenChange={setVoiceDiagnosticOpen}
/>
</>
)
}
@@ -15,6 +15,7 @@ interface PersonalWechatSetupGuideProps {
binding: boolean
detecting: boolean
detectionAttempted: boolean
sessionBound: boolean
onDownloadRuntime: () => void
onBind: () => void
onDetect: () => void
@@ -61,6 +62,7 @@ export function PersonalWechatSetupGuide({
binding,
detecting,
detectionAttempted,
sessionBound,
onDownloadRuntime,
onBind,
onDetect,
@@ -70,12 +72,14 @@ export function PersonalWechatSetupGuide({
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
const connected = senderStatus ? isWechatBound(senderStatus) : false
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
const canSendText = Boolean(senderStatus?.canSendText)
const canSendImage = Boolean(senderStatus?.canSendImage)
const canSendVoice = Boolean(senderStatus?.canSendVoice)
const mediaReady = canSendImage || canSendVoice
const verificationComplete = canSendText && mediaReady
const detectedTextReady = detectionAttempted && canSendText
const detectedMediaReady = detectionAttempted && mediaReady
const verificationComplete = connected && detectedTextReady && detectedMediaReady
const allReady = verificationComplete
const progress = runtimeProgress || runtimeStatus
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
@@ -160,7 +164,8 @@ export function PersonalWechatSetupGuide({
<p className="personal-wechat-step-warning" role="note">
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
<br />
微信总是自动更新?请在微信左下角打开“设置 → 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
微信总是自动更新?请在微信左下角打开“设置 →
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
</p>
)}
<Button
@@ -188,7 +193,7 @@ export function PersonalWechatSetupGuide({
<p className="personal-wechat-step-hint">
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
</p>
{!verificationComplete && (
{connected && !verificationComplete && (
<Button
size="sm"
variant="outline"
@@ -214,8 +219,8 @@ export function PersonalWechatSetupGuide({
<strong>能力检测</strong>
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
{[
['文字消息', canSendText],
['图片和语音消息', mediaReady]
['文字消息', detectedTextReady],
['图片和语音消息', detectedMediaReady]
].map(([label, ready]) => (
<span key={String(label)} className={ready ? 'is-ready' : ''}>
<b aria-hidden>{ready ? '✓' : '−'}</b>
@@ -0,0 +1,55 @@
import { useMemo, useState } from 'react'
import type { PersonalWechatVoiceDiagnostic } from '../../../../shared/personal-wechat'
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui'
interface PersonalWechatVoiceDiagnosticDialogProps {
open: boolean
diagnostic: PersonalWechatVoiceDiagnostic | null
onOpenChange: (open: boolean) => void
}
export function PersonalWechatVoiceDiagnosticDialog({
open,
diagnostic,
onOpenChange
}: PersonalWechatVoiceDiagnosticDialogProps): React.ReactElement {
const [copied, setCopied] = useState(false)
const json = useMemo(() => (diagnostic ? JSON.stringify(diagnostic, null, 2) : ''), [diagnostic])
const copyDiagnostic = async (): Promise<void> => {
if (!json) return
const result = await window.api.copyText(json)
setCopied(result.success)
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) setCopied(false)
onOpenChange(nextOpen)
}}
>
<DialogContent className="max-w-[560px]">
<DialogHeader>
<DialogTitle>语音发送诊断</DialogTitle>
</DialogHeader>
{json ? (
<pre className="max-h-[min(50vh,420px)] overflow-auto rounded-md border border-border-subtle bg-muted/40 p-3 text-xs leading-5 text-foreground">
{json}
</pre>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">暂无诊断信息</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button onClick={() => void copyDiagnostic()} disabled={!json}>
{copied ? '已复制' : '复制诊断 JSON'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+24
View File
@@ -62,6 +62,8 @@ export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRe
export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest {
type: 'voice'
filePath: string
/** Internal comparison switch for the temporary voice regression test. */
voiceSendMode?: 'normalized' | 'legacy'
}
export type PersonalWechatSendRequest =
@@ -75,6 +77,28 @@ export interface PersonalWechatSendResult {
error?: string
}
/** Safe, user-copyable metadata for the most recent voice send attempt. */
export interface PersonalWechatVoiceDiagnostic {
request_id: string
voice_id: string
phase: 'prepared' | 'completed' | 'failed'
encoder_name: string
encoder_version: string
input_bytes?: number
normalized_input_bytes?: number
pcm_size?: number
sample_rate?: number
channels?: number
input_duration_ms?: number
upload_result?: string
upload_data_len?: number
silk_duration_ms?: number
send_result?: string
voice_send_mode?: 'normalized' | 'legacy'
failure_phase?: string
error?: string
}
export interface PersonalWechatImageSelectionResult {
canceled: boolean
path?: string
@@ -16,6 +16,8 @@ const getTextToSpeechSettings = vi.fn()
const listTextToSpeechVoices = vi.fn()
const synthesizeTextToSpeech = vi.fn()
const removeGeneratedTextToSpeechAudio = vi.fn()
const getPersonalWechatVoiceDiagnostic = vi.fn()
const copyText = vi.fn()
const contact = {
m_nsUsrName: 'fixture-room@chatroom',
@@ -71,6 +73,11 @@ function renderDialog(
}
async function startComposer(): Promise<void> {
const bind = await screen.findByRole('button', { name: '绑定微信' })
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
const detect = await screen.findByRole('button', { name: '重新检测' })
fireEvent.click(detect)
const start = await screen.findByRole('button', { name: '开始发送' })
fireEvent.click(start)
}
@@ -125,6 +132,8 @@ describe('PersonalWechatSendDialog', () => {
audioDataUrl: 'data:audio/mpeg;base64,fixture'
})
removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true })
getPersonalWechatVoiceDiagnostic.mockReset().mockResolvedValue(null)
copyText.mockReset().mockResolvedValue({ success: true })
Object.defineProperty(window, 'api', {
configurable: true,
value: {
@@ -139,7 +148,9 @@ describe('PersonalWechatSendDialog', () => {
getTextToSpeechSettings,
listTextToSpeechVoices,
synthesizeTextToSpeech,
removeGeneratedTextToSpeechAudio
removeGeneratedTextToSpeechAudio,
getPersonalWechatVoiceDiagnostic,
copyText
}
})
})
@@ -165,15 +176,13 @@ describe('PersonalWechatSendDialog', () => {
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
expect(screen.getByText('能力检测')).toBeInTheDocument()
expect(screen.getByText('图片和语音消息')).toBeInTheDocument()
expect(screen.getByRole('note')).toHaveTextContent(
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
)
fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' }))
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(versionsDialog).toBeInTheDocument()
expect(versionsDialog).toHaveTextContent('4.1.11.53')
expect(
screen.getByText(
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
)
).toBeInTheDocument()
expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument()
expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument()
expect(screen.queryByText('PID 4668')).not.toBeVisible()
@@ -232,6 +241,27 @@ describe('PersonalWechatSendDialog', () => {
)
})
it('sends the same generated voice through the comparison path', async () => {
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
fireEvent.change(screen.getByRole('textbox', { name: '语音文字' }), {
target: { value: '入口2语音测试' }
})
fireEvent.click(screen.getByRole('button', { name: '生成语音' }))
await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '发送2' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'voice',
to: 'fixture-room@chatroom',
filePath: '/tmp/generated.mp3',
isGroup: true,
voiceSendMode: 'legacy'
})
)
})
it('uses the existing runtime, binding and detection IPC actions', async () => {
getRuntimeStatus
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
@@ -290,6 +320,9 @@ describe('PersonalWechatSendDialog', () => {
})
renderDialog()
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
await screen.findByText('✓ 微信已绑定')
fireEvent.click(await screen.findByRole('button', { name: '重新检测' }))
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
expect(screen.queryByText('图片消息')).not.toBeInTheDocument()
@@ -307,6 +340,8 @@ describe('PersonalWechatSendDialog', () => {
message: '等待消息初始化'
})
renderDialog()
fireEvent.click(await screen.findByRole('button', { name: '绑定微信' }))
await screen.findByText('✓ 微信已绑定')
const detect = await screen.findByRole('button', { name: '重新检测' })
expect(detect).toBeEnabled()
fireEvent.click(detect)
@@ -329,4 +364,41 @@ describe('PersonalWechatSendDialog', () => {
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled()
})
it('shows and copies the latest redacted voice diagnostic JSON', async () => {
getPersonalWechatVoiceDiagnostic.mockResolvedValue({
request_id: 'request-1',
voice_id: 'request-1',
phase: 'completed',
encoder_name: 'go-silk',
encoder_version: 'wechat_chatter-v0.0.18',
input_bytes: 35107,
normalized_input_bytes: 69804,
pcm_size: 69760,
sample_rate: 16000,
channels: 1,
input_duration_ms: 2180,
upload_result: '0',
upload_data_len: 4380,
silk_duration_ms: 2180,
send_result: '1'
})
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' }))
const diagnosticDialog = await screen.findByRole('dialog', { name: '语音发送诊断' })
expect(diagnosticDialog).toHaveTextContent('"encoder_name": "go-silk"')
expect(diagnosticDialog).not.toHaveTextContent('aesKey')
fireEvent.click(screen.getByRole('button', { name: '复制诊断 JSON' }))
await waitFor(() => expect(copyText).toHaveBeenCalledWith(expect.stringContaining('request-1')))
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
})
it('shows an empty state when no voice diagnostic exists', async () => {
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' }))
expect(await screen.findByText('暂无诊断信息')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '复制诊断 JSON' })).toBeDisabled()
})
})
@@ -108,6 +108,8 @@ describe('preload IPC contract', () => {
}
await api.sendPersonalWechatMessage(sendRequest)
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', sendRequest)
await api.getPersonalWechatVoiceDiagnostic()
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
await api.selectPersonalWechatImage()
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
})
@@ -11,6 +11,7 @@ vi.mock('electron', () => ({
}))
import {
buildPersonalWechatVoiceDiagnostic,
buildPersonalWechatOneBotRequest,
findWechatImagePath,
findPersonalWechatRuntime,
@@ -32,6 +33,37 @@ afterEach(() => {
})
describe('personal WeChat OneBot request', () => {
it('keeps voice diagnostics to the safe metadata allowlist', () => {
const diagnostic = buildPersonalWechatVoiceDiagnostic('request-1', 'completed', {
input_bytes: 10,
upload_result: '0',
error: undefined,
aesKey: 'secret',
cdnKey: 'secret',
token: 'secret',
runtime_log: 'private'
})
expect(diagnostic).toMatchObject({ request_id: 'request-1', upload_result: '0' })
expect(diagnostic).not.toHaveProperty('aesKey')
expect(diagnostic).not.toHaveProperty('cdnKey')
expect(diagnostic).not.toHaveProperty('token')
expect(diagnostic).not.toHaveProperty('runtime_log')
})
it('redacts secrets embedded in diagnostic errors', () => {
const diagnostic = buildPersonalWechatVoiceDiagnostic('request-2', 'failed', {
error:
'upload failed aesKey=secret-value Bearer bearer-secret token:token-value {"cdnKey":"json-secret"}'
})
expect(diagnostic.error).toContain('aesKey=[redacted]')
expect(diagnostic.error).toContain('Bearer [redacted]')
expect(diagnostic.error).toContain('token:[redacted]')
expect(diagnostic.error).not.toContain('secret-value')
expect(diagnostic.error).not.toContain('bearer-secret')
expect(diagnostic.error).not.toContain('token-value')
expect(diagnostic.error).not.toContain('json-secret')
})
it('builds a private text message request', () => {
expect(
buildPersonalWechatOneBotRequest({
+8
View File
@@ -87,6 +87,14 @@ describe('personal WeChat runtime security invariants', () => {
for (const source of [runtimeManagerSource, preparationScriptSource]) {
expect(source).not.toContain('pilk==')
expect(source).not.toMatch(/['"]pip['"]/)
expect(source).toContain('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')
expect(source).toContain('上传前按语音长度重新分配')
}
const senderSource = readFileSync(
resolve('src/main/services/personal-wechat-send-service.ts'),
'utf8'
)
expect(senderSource).toContain('buildRuntimePythonPath(preflight.runtime.root)')
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
VOICE_MIN_PCM_BYTES,
validateVoicePcm,
validateVoiceSilk,
validateVoiceSilkMetadata
} from '../../src/main/voice-pipeline/voice-quality'
describe('voice quality gates', () => {
it('accepts a complete 20ms PCM frame', () => {
expect(validateVoicePcm(new Uint8Array(VOICE_MIN_PCM_BYTES))).toMatchObject({
pcmSize: VOICE_MIN_PCM_BYTES,
sampleRate: 16_000,
channels: 1,
durationMs: 20
})
})
it('rejects empty, odd-sized and sub-frame PCM', () => {
expect(() => validateVoicePcm(new Uint8Array())).toThrow('PCM 为空')
expect(() => validateVoicePcm(new Uint8Array(3))).toThrow('长度无效')
expect(() => validateVoicePcm(new Uint8Array(VOICE_MIN_PCM_BYTES - 2))).toThrow('过短')
})
it('rejects a header-only Silk payload', () => {
expect(() => validateVoiceSilkMetadata(10, 20)).toThrow('只有文件头')
expect(() => validateVoiceSilk(new TextEncoder().encode('\x02#!SILK_V3'), 20)).toThrow(
'只有文件头'
)
})
it('accepts valid Silk metadata', () => {
expect(validateVoiceSilkMetadata(128, 240)).toEqual({ silkSize: 128, durationMs: 240 })
const silk = new Uint8Array(128)
silk.set(new TextEncoder().encode('\x02#!SILK_V3'))
expect(validateVoiceSilk(silk, 240)).toEqual({ silkSize: 128, durationMs: 240 })
})
})