From 5acfa14475e7f2d1e79698b7e052b78242932648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <84499436+Wxw-Gu@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:42:42 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=20=E5=A2=9E=E5=8A=A0windows?= =?UTF-8?q?=E5=8F=91=E9=80=81=E8=83=BD=E5=8A=9B=20=E6=8A=BD=E7=A6=BB?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=88=B0=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20=E6=8A=BD=E7=A6=BB=E6=96=87=E5=AD=97=E8=BD=AC=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 20 +- .../personal-wechat-capability-service.ts | 21 +- .../services/personal-wechat-send-service.ts | 376 +++++++- src/main/services/settings-store.ts | 4 +- src/main/voice-pipeline/audio-decoder.ts | 22 + src/preload/index.d.ts | 5 + src/preload/index.ts | 2 + src/renderer/src/App.tsx | 2 + src/renderer/src/components/ChatWindow.tsx | 3 + .../chat/PersonalWechatChatComposer.tsx | 300 +++---- .../chat/PersonalWechatSendDialog.tsx | 44 +- .../chat/PersonalWechatSetupGuide.tsx | 26 +- .../chat/PersonalWechatWindowsSendDialog.tsx | 241 ++++++ .../src/components/reports/ReportViewer.tsx | 5 +- .../features/settings/SettingsWorkspace.tsx | 7 + .../settings/model/settingsNavigation.ts | 1 + .../src/features/settings/model/types.ts | 1 + .../settings/pages/PersonalWechatSendPage.tsx | 572 +++++++++++-- .../settings/pages/TextToSpeechPage.tsx | 806 ++++++------------ src/renderer/src/styles/chat.scss | 64 +- .../src/styles/settings-text-to-speech.scss | 23 +- src/renderer/src/utils/runtime-environment.ts | 4 +- src/shared/personal-wechat.ts | 2 + .../personal-wechat-send-dialog.test.tsx | 41 +- .../personal-wechat-send-page.test.tsx | 142 +++ tests/component/report-features.test.tsx | 2 + .../text-to-speech-page-windows.test.tsx | 197 +++++ tests/component/text-to-speech-page.test.tsx | 84 +- tests/e2e/app.spec.ts | 5 +- tests/integration/preload-contract.test.ts | 2 + ...personal-wechat-capability-service.test.ts | 17 +- .../unit/personal-wechat-send-service.test.ts | 89 ++ tests/unit/settings-navigation.test.ts | 1 + 33 files changed, 2226 insertions(+), 905 deletions(-) create mode 100644 src/renderer/src/components/chat/PersonalWechatWindowsSendDialog.tsx create mode 100644 tests/component/personal-wechat-send-page.test.tsx create mode 100644 tests/component/text-to-speech-page-windows.test.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 8add8c6..cc8c29d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1857,6 +1857,9 @@ app.whenReady().then(async () => { ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) => personalWechatSendService.setKeepOneBotProcess(Boolean(keep)) ) + ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) => + personalWechatSendService.checkWindowsStatus(port) + ) ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus()) ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download()) ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({ @@ -1874,9 +1877,18 @@ app.whenReady().then(async () => { return error ? { success: false, error } : { success: true } }) ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind()) - ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) => - personalWechatSendService.send(request) - ) + ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => { + if (request.type !== 'voice' || String(request.fromId || '').trim()) { + return personalWechatSendService.send(request) + } + let fromId = '' + try { + const self = await chat.getSelfAccountInfoAsync() + fromId = String(self?.wxid || '').trim() + } catch { + } + return personalWechatSendService.send(fromId ? { ...request, fromId } : request) + }) ipcMain.handle('wechat-personal:getVoiceDiagnostic', () => personalWechatSendService.getLatestVoiceDiagnostic() ) @@ -1913,7 +1925,7 @@ app.whenReady().then(async () => { ipcMain.handle('agent-hub:selectTestImage', async (event) => { const window = BrowserWindow.fromWebContents(event.sender) const result = await dialog.showOpenDialog(window!, { - title: '选择要测试发送的图片', + title: '选择要发送的图片', properties: ['openFile'], filters: [ { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }, diff --git a/src/main/services/personal-wechat-capability-service.ts b/src/main/services/personal-wechat-capability-service.ts index 739d8b4..2e58d92 100644 --- a/src/main/services/personal-wechat-capability-service.ts +++ b/src/main/services/personal-wechat-capability-service.ts @@ -37,13 +37,14 @@ export class PersonalWechatCapabilityService { message: status === 'unsupported' ? '微信消息发送目前仅支持 macOS' - : status === 'needs_binding' || status === 'unconfigured' - ? '请先绑定个人微信' - : status === 'needs_verification' - ? '请先完成微信消息能力检测' - : status === 'ready' - ? '个人微信已准备好发送日报' - : senderStatus.message || senderStatus.error || '个人微信发送能力异常', + : senderStatus.message || + (status === 'needs_binding' || status === 'unconfigured' + ? '请先绑定个人微信' + : status === 'needs_verification' + ? '请先完成微信消息能力检测' + : status === 'ready' + ? '个人微信已准备好发送日报' + : senderStatus.error || '个人微信发送能力异常'), ...(senderStatus.error ? { error: senderStatus.error } : {}) } } @@ -52,6 +53,12 @@ export class PersonalWechatCapabilityService { senderStatus: PersonalWechatSenderStatus, canSendImage: boolean ): PersonalWechatSendCapabilityState { + if (senderStatus.platform === 'win32') { + if (senderStatus.canSend) return 'ready' + if (!senderStatus.endpoint) return 'unconfigured' + if (senderStatus.state === 'error' && senderStatus.endpointReady) return 'error' + return 'needs_verification' + } if (senderStatus.platform !== 'darwin' || senderStatus.state === 'unsupported_platform') { return 'unsupported' } diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index 160fcf0..4dfb11a 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -1,9 +1,9 @@ import { app } from 'electron' import { execFile, spawn, type ChildProcess } from 'child_process' -import { createHash, randomUUID } from 'crypto' -import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { createHash, randomBytes, randomUUID } from 'crypto' +import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs' import { createConnection } from 'net' -import { homedir } from 'os' +import { homedir, tmpdir } from 'os' import { delimiter, dirname, join, sep } from 'path' import ffmpegStaticPath from 'ffmpeg-static' import { promisify } from 'util' @@ -15,7 +15,7 @@ import type { } from '../../shared/personal-wechat' import { isPackagedRuntime } from '../runtime-mode' import { loadSettings, updateSettings } from './settings-store' -import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder' +import { SilkAudioDecoder, SilkAudioEncoder } from '../voice-pipeline/audio-decoder' import { validateVoicePcm, validateVoiceSilkMetadata, @@ -26,6 +26,7 @@ import { appLogger } from '../app-logger' const execFileAsync = promisify(execFile) const DEFAULT_HOST = '127.0.0.1:58080' +const WINDOWS_LOOPBACK_HOST = '127.0.0.1' const START_TIMEOUT_MS = 20_000 const REQUEST_TIMEOUT_MS = 20_000 const STOP_TIMEOUT_MS = 3_000 @@ -41,6 +42,22 @@ const WECHAT_FILES_ROOT = join( 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files' ) +export function normalizeWindowsWechatPort(value: unknown): string | null { + const text = String(value ?? '').trim() + if (!/^\d{1,5}$/.test(text)) return null + const port = Number(text) + if (!Number.isInteger(port) || port < 1 || port > 65_535) return null + return String(port) +} + +function windowsHookHost(port?: unknown): string | null { + const configuredPort = + port === undefined + ? normalizeWindowsWechatPort(loadSettings().windowsWechatPort) + : normalizeWindowsWechatPort(port) + return configuredPort ? `${WINDOWS_LOOPBACK_HOST}:${configuredPort}` : null +} + export interface RuntimeLayout { root: string executable: string @@ -128,7 +145,7 @@ export function parsePersonalWechatHookLog(log: string): { baseAddress = text.match(/WeChat core module base:\s*(0x[0-9a-f]+)/i)?.[1] } else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) { readiness = 'initializing' - error = '微信底层 Hook 尚未就绪,消息没有发出' + error = '微信发送能力尚未就绪,消息没有发出' } else if (text.includes('捕获到 StartTask 调用')) { readiness = 'ready' textHookReady = true @@ -496,6 +513,71 @@ async function prepareVoiceFile(filePath: string): Promise { return createWavBuffer(decoded.pcm, decoded.sampleRate, decoded.channels) } +async function detectVoiceDurationMs(filePath: string): Promise { + const data = readFileSync(filePath) + if (data.subarray(0, 10).equals(Buffer.from('\x02#!SILK_V3'))) { + const decoded = await new SilkAudioDecoder().decode({ + data, + codec: 'silk', + sourceHash: createHash('sha256').update(data).digest('hex') + }) + const samples = decoded.channels > 0 ? decoded.pcm.length / 2 / decoded.channels : 0 + if (samples > 0 && decoded.sampleRate > 0) { + return Math.round((samples / decoded.sampleRate) * 1000) + } + } + + const ffmpegPath = String(ffmpegStaticPath || '').replace('app.asar', 'app.asar.unpacked') + if (!ffmpegPath || !existsSync(ffmpegPath)) return undefined + let output = '' + try { + const result = await execFileAsync(ffmpegPath, ['-i', filePath, '-f', 'null', '-'], { + maxBuffer: 2 * 1024 * 1024 + }) + output = `${result.stdout || ''}\n${result.stderr || ''}` + } catch (error) { + output = `${(error as { stdout?: string }).stdout || ''}\n${(error as { stderr?: string }).stderr || ''}` + } + const match = output.match(/Duration:\s*(\d+):(\d{2}):(\d+(?:\.\d+)?)/i) + if (match) { + const seconds = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + if (Number.isFinite(seconds) && seconds > 0) return Math.round(seconds * 1000) + } + return undefined +} + +async function prepareWindowsVoiceFile(filePath: string): Promise<{ + filePath: string + durationMs?: number + temporary: boolean +}> { + const source = readFileSync(filePath) + if (source.subarray(0, 10).equals(Buffer.from('\x02#!SILK_V3'))) { + return { filePath, durationMs: await detectVoiceDurationMs(filePath), temporary: false } + } + + const ffmpegPath = String(ffmpegStaticPath || '').replace('app.asar', 'app.asar.unpacked') + if (!ffmpegPath || !existsSync(ffmpegPath)) { + throw new Error('Windows 语音发送需要可用的 FFmpeg 才能转换为 SILK') + } + const result = await execFileAsync( + ffmpegPath, + ['-v', 'error', '-i', filePath, '-ar', '24000', '-ac', '1', '-f', 's16le', 'pipe:1'], + { encoding: 'buffer', maxBuffer: MAX_VOICE_BYTES } + ) + const pcm = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || '') + if (!pcm.length) throw new Error('语音转换失败:没有得到 PCM 音频数据') + const encoded = await new SilkAudioEncoder().encode(pcm, 24_000) + if (!encoded.data.length) throw new Error('语音转换失败:没有得到 SILK 数据') + const convertedPath = join(tmpdir(), `tracememo-voice-${randomBytes(8).toString('hex')}.silk`) + writeFileSync(convertedPath, encoded.data) + return { + filePath: convertedPath, + ...(encoded.durationMs > 0 ? { durationMs: encoded.durationMs } : {}), + temporary: true + } +} + async function readWechatVersion(): Promise { const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ '-c', @@ -639,6 +721,119 @@ async function requestWithTimeout( } } +type WindowsHookResponse = Record + +export function parseWindowsHookResponse( + responseText: string, + requireSuccessRet = false +): WindowsHookResponse { + const normalized = responseText.trim() + if (!normalized) throw new Error('Windows 微信发送能力返回空响应') + + let parsed: unknown + try { + parsed = JSON.parse(normalized) + } catch { + throw new Error(`Windows 微信发送能力返回无效 JSON:${normalized.slice(0, 200)}`) + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Windows 微信发送能力返回格式无效') + } + + const response = parsed as WindowsHookResponse + if (requireSuccessRet && response.ret !== 0) { + const detail = response.retmsg ?? response.msg + throw new Error( + detail ? String(detail) : `Windows 微信发送能力返回失败:ret=${String(response.ret)}` + ) + } + return response +} + +export function parseWindowsLoginStatus(response: WindowsHookResponse): boolean { + return response.status === true +} + +export function buildWindowsWechatRequest( + request: PersonalWechatSendRequest, + options: { filePath?: string; durationMs?: number } = {} +): { + endpoint: string + body: Record +} { + const target = String(request.to || '').trim() + if (request.type === 'text') { + return { + endpoint: '/SendMsg', + body: { toWxid: target, type: 'text', msg: request.text.trim() } + } + } + if (request.type === 'image') { + return { + endpoint: '/SendMsg', + body: { toWxid: target, type: 'image', msg: String(request.filePath || '').trim() } + } + } + + const fromId = String(request.fromId || '').trim() + const filePath = String(options.filePath || request.filePath || '').trim() + const durationMs = options.durationMs ?? request.durationMs + return { + endpoint: '/SendMsg', + body: { + toWxid: target, + type: 'voice', + msg: filePath, + ...(fromId ? { fromWxid: fromId } : {}), + ...(durationMs !== undefined ? { duration: durationMs } : {}) + } + } +} + +function windowsStatusBase(host = windowsHookHost() || ''): PersonalWechatSenderStatus { + return { + state: 'checking', + platform: process.platform, + arch: process.arch, + sipDisabled: true, + wechatRunning: false, + runtimeReady: false, + endpoint: host, + endpointReady: false, + attachReady: false, + baseAddressReady: false, + textHookInstalled: false, + textHookReady: false, + imageHookInstalled: false, + imageHookReady: false, + messageListenerReady: false, + canSend: false, + canSendText: false, + canSendImage: false, + canSendVoice: false, + message: '正在检查 Windows 微信消息接口' + } +} + +async function requestWindowsHook( + endpoint: string, + body: Record, + timeoutMs = REQUEST_TIMEOUT_MS, + method: 'GET' | 'POST' = 'POST', + host = windowsHookHost() +): Promise { + if (!host) throw new Error('尚未配置微信发送能力端口') + const init: RequestInit = { + method, + headers: { 'Content-Type': 'application/json' } + } + if (method !== 'GET') init.body = JSON.stringify(body) + const response = await requestWithTimeout(`http://${host}${endpoint}`, init, timeoutMs) + const responseText = await response.text() + if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`) + return parseWindowsHookResponse(responseText, method === 'POST') +} + export class PersonalWechatSendService { private child: ChildProcess | null = null private startPromise: Promise | null = null @@ -657,6 +852,7 @@ export class PersonalWechatSendService { } async getStatus(): Promise { + if (process.platform === 'win32') return this.getWindowsStatus() const preflight = await this.preflight() const [endpointReady, oneBot] = await Promise.all([ this.isEndpointOnline(), @@ -699,7 +895,7 @@ export class PersonalWechatSendService { canSend: false, canSendText: false, canSendImage: false, - message: '微信发送 Hook 初始化失败,请点击“绑定微信”', + message: '微信发送能力初始化失败,请尝试重新绑定', ...(hook.error ? { error: hook.error } : {}) } } @@ -725,11 +921,27 @@ export class PersonalWechatSendService { } } + async checkWindowsStatus(port?: string): Promise { + if (process.platform !== 'win32') return this.getStatus() + const normalizedPort = normalizeWindowsWechatPort(port) + if (!normalizedPort) { + const base = windowsStatusBase() + return { + ...base, + state: 'error', + message: '请输入 1 到 65535 之间的微信发送能力端口', + error: '端口格式无效或为空' + } + } + return this.getWindowsStatus(normalizedPort) + } + getLatestVoiceDiagnostic(): PersonalWechatVoiceDiagnostic | null { return latestVoiceDiagnostic ? { ...latestVoiceDiagnostic } : null } async send(request: PersonalWechatSendRequest): Promise { + if (process.platform === 'win32') return this.sendWindows(request) const requestId = randomUUID() if (request.type !== 'voice') return this.sendInternal(request, requestId) @@ -846,7 +1058,7 @@ export class PersonalWechatSendService { request.type === 'text' ? '请先在微信中给任意好友手动发送一条文字,再重新检测' : request.type === 'voice' - ? '语音复用媒体上传 Hook,请先在微信中手动发送一张普通图片,再重新检测' + ? '语音复用媒体上传能力,请先在微信中手动发送一张普通图片,再重新检测' : '请先在微信中给任意好友手动发送一张普通图片,再重新检测' return { success: false, status, error: status.error || guidance } } @@ -954,6 +1166,7 @@ export class PersonalWechatSendService { } async rebind(): Promise { + if (process.platform === 'win32') return this.getWindowsStatus() const preflight = await this.preflight() if (!preflight.runtime || !preflight.status.configPath || !preflight.status.wechatPid) { return preflight.status @@ -995,6 +1208,7 @@ export class PersonalWechatSendService { } async terminate(force = false): Promise { + if (process.platform === 'win32') return if (this.keepOneBotProcess && !force) { this.child = null this.startPromise = null @@ -1022,6 +1236,150 @@ export class PersonalWechatSendService { return this.startPromise } + private async getWindowsStatus(port?: string): Promise { + const host = windowsHookHost(port) + const base = windowsStatusBase(host || '') + if (!host) { + return { + ...base, + state: 'error', + message: '尚未配置微信发送能力端口', + error: '请先输入端口并检测后保存' + } + } + try { + const result = await requestWindowsHook('/getLoginInfo', {}, 3_000, 'GET', host) + const loggedIn = parseWindowsLoginStatus(result) + const connected = { + wechatRunning: true, + runtimeReady: true, + endpointReady: true, + attachReady: true, + baseAddressReady: true, + textHookInstalled: false, + textHookReady: false, + imageHookInstalled: false, + imageHookReady: false, + messageListenerReady: false + } + if (!loggedIn) { + return { + ...base, + ...connected, + state: 'hook_not_ready', + message: 'Windows 微信发送能力已连接,但微信尚未登录', + error: 'getLoginInfo 返回 status=false,登录后才能发送消息' + } + } + + return { + ...base, + ...connected, + state: 'online' as const, + canSend: true, + canSendText: true, + canSendImage: true, + canSendVoice: true, + message: 'Windows 微信发送能力已连接,可以发送消息' + } + } catch (error) { + return { + ...base, + state: 'wechat_not_running', + message: '未检测到 Windows 微信发送能力,请先启动并登录微信', + error: error instanceof Error ? error.message : String(error) + } + } + } + + private async sendWindows(request: PersonalWechatSendRequest): Promise { + const to = String(request?.to || '').trim() + if (!to) { + const status = await this.getWindowsStatus() + return { success: false, status, error: '接收者不能为空' } + } + if (request.type === 'text') { + const text = String(request.text || '').trim() + if (!text) { + const status = await this.getWindowsStatus() + return { success: false, status, error: '文字内容不能为空' } + } + if (text.length > 2_000) { + const status = await this.getWindowsStatus() + return { success: false, status, error: '文字内容不能超过 2000 个字符' } + } + request = { ...request, to, text } + } else { + const filePath = String(request.filePath || '').trim() + if (!filePath || !existsSync(filePath)) { + const status = await this.getWindowsStatus() + return { + success: false, + status, + error: `请选择有效的${request.type === 'voice' ? '语音' : '图片'}文件` + } + } + const size = statSync(filePath).size + if (size <= 0 || size > (request.type === 'voice' ? MAX_VOICE_BYTES : MAX_IMAGE_BYTES)) { + const status = await this.getWindowsStatus() + return { + success: false, + status, + error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB` + } + } + request = { ...request, to, filePath } + } + + const status = await this.getWindowsStatus() + if (!status.canSend) return { success: false, status, error: status.error || status.message } + + let temporaryVoicePath: string | undefined + try { + if (request.type === 'text') { + const windowsRequest = buildWindowsWechatRequest(request) + await requestWindowsHook(windowsRequest.endpoint, windowsRequest.body) + } else if (request.type === 'voice') { + const fromId = String(request.fromId || '').trim() + if (!fromId) throw new Error('无法识别当前微信账号 wxid,无法发送语音') + const preparedVoice = await prepareWindowsVoiceFile(request.filePath) + temporaryVoicePath = preparedVoice.temporary ? preparedVoice.filePath : undefined + const requestedDuration = Number(request.durationMs) + const rawDuration = + Number.isFinite(requestedDuration) && requestedDuration > 0 + ? requestedDuration + : preparedVoice.durationMs + if (!rawDuration || !Number.isFinite(rawDuration)) { + throw new Error('无法计算语音时长,请重新生成或选择有效的 SILK 文件') + } + const durationMs = Math.max(1, Math.min(60_000, Math.round(rawDuration))) + const windowsRequest = buildWindowsWechatRequest(request, { + filePath: preparedVoice.filePath, + durationMs + }) + await requestWindowsHook(windowsRequest.endpoint, windowsRequest.body, 60_000) + } else { + const windowsRequest = buildWindowsWechatRequest(request) + await requestWindowsHook(windowsRequest.endpoint, windowsRequest.body, 60_000) + } + return { success: true, status: await this.getWindowsStatus() } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + success: false, + status: { ...(await this.getWindowsStatus()), state: 'error', error: message }, + error: `发送失败:${message}` + } + } finally { + if (temporaryVoicePath) { + try { + unlinkSync(temporaryVoicePath) + } catch { + } + } + } + } + private async startRuntime(): Promise { const preflight = await this.preflight() if (!preflight.runtime || !preflight.status.configPath) { @@ -1166,7 +1524,7 @@ export class PersonalWechatSendService { wechatPid, wechatVersion, state: 'runtime_missing', - message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 文字转语音”下载' + message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 微信发送”下载' } } } @@ -1183,7 +1541,7 @@ export class PersonalWechatSendService { runtimeReady: true, executablePath: runtime.executable, state: 'unsupported_version', - message: `当前微信版本 ${wechatVersion} 暂不支持,请前往文字转语音设置查看支持的版本`, + message: `当前微信版本 ${wechatVersion} 暂不支持,请前往微信发送设置查看支持的版本`, error: `缺少 ${configPath}` } } diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index d67ce41..8c002d9 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -41,6 +41,7 @@ export interface AppSettings { ttsModel: TextToSpeechModel /** Keep a running personal-WeChat OneBot process across app restarts. */ keepPersonalWechatProcess?: boolean + windowsWechatPort: string } function getDefaultDbRoot(): string { @@ -123,7 +124,8 @@ const DEFAULT_SETTINGS: AppSettings = { showStartupProgress: true, ttsSelectedVoiceId: '', ttsModel: 's2.1-pro-free', - keepPersonalWechatProcess: false + keepPersonalWechatProcess: false, + windowsWechatPort: '' } const SETTINGS_FILE = path.join( diff --git a/src/main/voice-pipeline/audio-decoder.ts b/src/main/voice-pipeline/audio-decoder.ts index b9a5fbb..6bac7ff 100644 --- a/src/main/voice-pipeline/audio-decoder.ts +++ b/src/main/voice-pipeline/audio-decoder.ts @@ -24,6 +24,11 @@ export interface VoiceAudioDecoder { decode(source: EncodedVoiceSource): Promise } +export interface EncodedSilkAudio { + data: Buffer + durationMs: number +} + export type SilkWasmRuntimeLocation = { packagePath: string wasmPath: string @@ -86,6 +91,23 @@ export class SilkAudioDecoder implements VoiceAudioDecoder { } } +export class SilkAudioEncoder { + async encode(pcm: Buffer, sampleRate: number): Promise { + const locations = getSilkWasmRuntimeLocations() + const runtime = findSilkWasmRuntimeLocation(locations) + if (!runtime) throw new Error('silk.wasm 未找到') + const silkWasm = nodeRequire(runtime.packagePath) as { + encode?: ( + data: Buffer, + sampleRate: number + ) => Promise<{ data: Uint8Array; duration?: number }> + } + if (!silkWasm.encode) throw new Error('silk-wasm 运行时无效') + const result = await silkWasm.encode(pcm, sampleRate) + return { data: Buffer.from(result.data), durationMs: Math.round(Number(result.duration) || 0) } + } +} + export class AudioDecoderRegistry { private readonly decoders = new Map() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index c444aaa..a699a1f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -438,6 +438,7 @@ declare global { showStartupProgress: boolean ttsSelectedVoiceId: string ttsModel: import('../shared/text-to-speech').TextToSpeechModel + windowsWechatPort: string imageXorKey: string imageAesKey: string } @@ -477,6 +478,7 @@ declare global { showStartupProgress: boolean ttsSelectedVoiceId: string ttsModel: import('../shared/text-to-speech').TextToSpeechModel + windowsWechatPort: string imageXorKey: string imageAesKey: string } @@ -499,6 +501,7 @@ declare global { showStartupProgress: boolean ttsSelectedVoiceId: string ttsModel: import('../shared/text-to-speech').TextToSpeechModel + windowsWechatPort: string imageXorKey: string imageAesKey: string }> @@ -517,6 +520,7 @@ declare global { appearanceTheme: 'system' | 'light' | 'dark' compactMode: boolean showStartupProgress: boolean + windowsWechatPort: string imageXorKey: string imageAesKey: string } @@ -596,6 +600,7 @@ declare global { getPersonalWechatSendCapability: () => Promise getPersonalWechatKeepOneBotProcess: () => Promise setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise + checkPersonalWechatSenderStatus: (port?: string) => Promise getPersonalWechatRuntimeStatus: () => Promise downloadPersonalWechatRuntime: () => Promise cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 6144a6d..35bc438 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -369,6 +369,8 @@ const api = { ipcRenderer.invoke('wechat-personal:getKeepProcess'), setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise => ipcRenderer.invoke('wechat-personal:setKeepProcess', keep), + checkPersonalWechatSenderStatus: (port?: string): Promise => + ipcRenderer.invoke('wechat-personal:checkStatus', port), getPersonalWechatRuntimeStatus: (): Promise => ipcRenderer.invoke('wechat-personal:getRuntimeStatus'), downloadPersonalWechatRuntime: (): Promise => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0af518e..db86f93 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1753,6 +1753,7 @@ function App(): React.ReactElement { onLoadOlderMessages={handleLoadOlderMessages} onCreateGroupReport={handleOpenReportWorkspace} onOpenTextToSpeechSettings={openTextToSpeechSettings} + onOpenPersonalWechatSettings={openWechatSendSettings} isAiLoading={reportGeneration.isGenerating} jumpToTime={archiveJumpTime} /> @@ -1821,6 +1822,7 @@ function App(): React.ReactElement { onReveal={handleRevealReport} onSwitchTemplate={handleSwitchReportTemplate} sendTarget={selectedReportContact} + onOpenPersonalWechatSettings={openWechatSendSettings} /> diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index e8e4b97..52e1152 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -21,6 +21,7 @@ interface ChatWindowProps { onLoadOlderMessages?: () => Promise onCreateGroupReport?: () => void onOpenTextToSpeechSettings?: () => void + onOpenPersonalWechatSettings?: () => void isAiLoading?: boolean jumpToTime?: number | null } @@ -38,6 +39,7 @@ const ChatWindow: React.FC = ({ onLoadOlderMessages, onCreateGroupReport, onOpenTextToSpeechSettings, + onOpenPersonalWechatSettings, isAiLoading = false, jumpToTime }) => { @@ -171,6 +173,7 @@ const ChatWindow: React.FC = ({ isGroupChat={isGroupChat} onClose={() => setSendDialogOpen(false)} onOpenTextToSpeechSettings={onOpenTextToSpeechSettings} + onOpenPersonalWechatSettings={onOpenPersonalWechatSettings} /> )} diff --git a/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx index 3e6e1dd..44fd62a 100644 --- a/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx +++ b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx @@ -20,6 +20,7 @@ export type ChatMessage = { } interface PersonalWechatChatComposerProps { + className?: string status: PersonalWechatSenderStatus targetId: string isGroupChat: boolean @@ -42,6 +43,7 @@ function formatAudioTime(value: number): string { } export function PersonalWechatChatComposer({ + className, status, targetId, isGroupChat, @@ -245,7 +247,10 @@ export function PersonalWechatChatComposer({ const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0 return ( -
+
语音 - {mode === 'text' && ( -
-