feat: 增加windows发送能力

抽离功能到设置页面
抽离文字转语音功能
This commit is contained in:
电摇小子
2026-08-31 02:44:24 +08:00
parent 698251796f
commit 5acfa14475
33 changed files with 2226 additions and 905 deletions
+16 -4
View File
@@ -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'] },
@@ -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'
}
@@ -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<Buffer> {
return createWavBuffer(decoded.pcm, decoded.sampleRate, decoded.channels)
}
async function detectVoiceDurationMs(filePath: string): Promise<number | undefined> {
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<string> {
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
'-c',
@@ -639,6 +721,119 @@ async function requestWithTimeout(
}
}
type WindowsHookResponse = Record<string, unknown>
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<string, unknown>
} {
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<string, unknown>,
timeoutMs = REQUEST_TIMEOUT_MS,
method: 'GET' | 'POST' = 'POST',
host = windowsHookHost()
): Promise<WindowsHookResponse> {
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<PersonalWechatSenderStatus> | null = null
@@ -657,6 +852,7 @@ export class PersonalWechatSendService {
}
async getStatus(): Promise<PersonalWechatSenderStatus> {
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<PersonalWechatSenderStatus> {
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<PersonalWechatSendResult> {
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<PersonalWechatSenderStatus> {
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<void> {
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<PersonalWechatSenderStatus> {
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<PersonalWechatSendResult> {
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<PersonalWechatSenderStatus> {
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}`
}
}
+3 -1
View File
@@ -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(
+22
View File
@@ -24,6 +24,11 @@ export interface VoiceAudioDecoder {
decode(source: EncodedVoiceSource): Promise<DecodedVoiceAudio>
}
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<EncodedSilkAudio> {
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<string, VoiceAudioDecoder>()
+5
View File
@@ -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<PersonalWechatSendCapability>
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
+2
View File
@@ -369,6 +369,8 @@ const api = {
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:checkStatus', port),
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
+2
View File
@@ -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}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div>
@@ -21,6 +21,7 @@ interface ChatWindowProps {
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
onOpenTextToSpeechSettings?: () => void
onOpenPersonalWechatSettings?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
}
@@ -38,6 +39,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onLoadOlderMessages,
onCreateGroupReport,
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
isAiLoading = false,
jumpToTime
}) => {
@@ -171,6 +173,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
isGroupChat={isGroupChat}
onClose={() => setSendDialogOpen(false)}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
onOpenPersonalWechatSettings={onOpenPersonalWechatSettings}
/>
)}
@@ -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 (
<section className="personal-wechat-composer" aria-label="发送消息">
<section
className={`personal-wechat-composer${className ? ` ${className}` : ''}`}
aria-label="发送消息"
>
<SegmentedControl
className="personal-wechat-composer-mode"
aria-label="消息类型"
@@ -262,158 +267,167 @@ export function PersonalWechatChatComposer({
<SegmentedControlItem value="voice">语音</SegmentedControlItem>
</SegmentedControl>
{mode === 'text' && (
<div className="personal-wechat-text-editor">
<Textarea
aria-label="消息内容"
placeholder="输入消息"
value={text}
maxLength={2000}
rows={3}
disabled={isBusy}
onChange={(event) => {
setText(event.target.value)
setFeedback(null)
}}
/>
<span>{text.length} / 2000</span>
</div>
)}
{mode === 'image' && (
<div className="personal-wechat-file-editor">
<div>
<strong>{image?.name || '还没有选择图片'}</strong>
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
<div className="personal-wechat-composer-editor">
{mode === 'text' && (
<div className="personal-wechat-text-editor">
<Textarea
aria-label="消息内容"
placeholder="输入消息"
value={text}
maxLength={2000}
rows={3}
disabled={isBusy}
onChange={(event) => {
setText(event.target.value)
setFeedback(null)
}}
/>
<span>{text.length} / 2000</span>
</div>
<Button variant="outline" size="sm" onClick={() => void selectImage()} disabled={isBusy}>
{image ? '重新选择' : '选择图片'}
</Button>
</div>
)}
)}
{mode === 'voice' && (
<div className="personal-wechat-voice-editor">
<SegmentedControl
aria-label="语音来源"
value={voiceSource}
onValueChange={(value) => {
clearGeneratedVoice()
setVoiceSource(value as VoiceSource)
setFeedback(null)
}}
disabled={isBusy}
>
<SegmentedControlItem value="generated">输入文字生成</SegmentedControlItem>
<SegmentedControlItem value="file">选择本地文件</SegmentedControlItem>
</SegmentedControl>
{voiceSource === 'generated' ? (
<>
<div className="personal-wechat-voice-heading">
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
{onOpenTextToSpeechSettings && (
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings}>
语音设置
</Button>
)}
</div>
<Textarea
aria-label="语音文字"
placeholder="输入要生成的文字"
value={voiceText}
maxLength={1000}
rows={2}
disabled={isBusy}
onChange={(event) => {
clearGeneratedVoice()
setVoiceText(event.target.value)
setFeedback(null)
}}
/>
{generatedVoice ? (
<div className="personal-wechat-generated-result">
<audio
ref={generatedAudioRef}
src={generatedVoice.audioDataUrl}
preload="metadata"
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
onTimeUpdate={(event) => setPreviewCurrentTime(event.currentTarget.currentTime)}
onPause={() => setIsPreviewPlaying(false)}
onPlay={() => setIsPreviewPlaying(true)}
onEnded={() => {
setIsPreviewPlaying(false)
setPreviewCurrentTime(0)
}}
/>
<div>
<strong>语音已生成</strong>
<span>
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
</span>
<div className="personal-wechat-preview-track">
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
{mode === 'image' && (
<div className="personal-wechat-file-editor">
<div>
<strong>{image?.name || '还没有选择图片'}</strong>
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void selectImage()}
disabled={isBusy}
>
{image ? '重新选择' : '选择图片'}
</Button>
</div>
)}
{mode === 'voice' && (
<div className="personal-wechat-voice-editor">
<SegmentedControl
aria-label="语音来源"
value={voiceSource}
onValueChange={(value) => {
clearGeneratedVoice()
setVoiceSource(value as VoiceSource)
setFeedback(null)
}}
disabled={isBusy}
>
<SegmentedControlItem value="generated">输入文字生成</SegmentedControlItem>
<SegmentedControlItem value="file">选择本地文件</SegmentedControlItem>
</SegmentedControl>
{voiceSource === 'generated' ? (
<>
<div className="personal-wechat-voice-heading">
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
{onOpenTextToSpeechSettings && (
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings}>
语音设置
</Button>
)}
</div>
<Textarea
aria-label="语音文字"
placeholder="输入要生成的文字"
value={voiceText}
maxLength={1000}
rows={2}
disabled={isBusy}
onChange={(event) => {
clearGeneratedVoice()
setVoiceText(event.target.value)
setFeedback(null)
}}
/>
{generatedVoice ? (
<div className="personal-wechat-generated-result">
<audio
ref={generatedAudioRef}
src={generatedVoice.audioDataUrl}
preload="metadata"
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
onTimeUpdate={(event) =>
setPreviewCurrentTime(event.currentTarget.currentTime)
}
onPause={() => setIsPreviewPlaying(false)}
onPlay={() => setIsPreviewPlaying(true)}
onEnded={() => {
setIsPreviewPlaying(false)
setPreviewCurrentTime(0)
}}
/>
<div>
<strong>语音已生成</strong>
<span>
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
</span>
<div className="personal-wechat-preview-track">
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
</div>
</div>
<div className="personal-wechat-generated-result-actions">
<Button
variant="outline"
size="sm"
onClick={async () => {
const audio = generatedAudioRef.current
if (!audio) return
if (audio.paused) {
await audio.play()
setIsPreviewPlaying(true)
} else {
audio.pause()
}
}}
disabled={busy}
>
{isPreviewPlaying ? '暂停' : '试听'}
</Button>
<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>
<div className="personal-wechat-generated-result-actions">
<Button
variant="outline"
size="sm"
onClick={async () => {
const audio = generatedAudioRef.current
if (!audio) return
if (audio.paused) {
await audio.play()
setIsPreviewPlaying(true)
} else {
audio.pause()
}
}}
disabled={busy}
>
{isPreviewPlaying ? '暂停' : '试听'}
</Button>
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
{busy ? '发送中…' : '发送'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void sendGeneratedAlternative()}
disabled={!canSend}
title="使用另一条语音发送路径"
>
{busy ? '发送中…' : '空白语音?重新处理'}
</Button>
</div>
) : (
<Button
size="sm"
onClick={() => void generateVoice()}
disabled={!generatedReady || isBusy}
>
{isGenerating ? '正在生成…' : '生成语音'}
</Button>
)}
</>
) : (
<div className="personal-wechat-file-editor">
<div>
<strong>{voice?.name || '还没有选择语音文件'}</strong>
<small>{voice ? voice.path : '支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC'}</small>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => void generateVoice()}
disabled={!generatedReady || isBusy}
onClick={() => void selectVoice()}
disabled={isBusy}
>
{isGenerating ? '正在生成…' : '生成语音'}
{voice ? '重新选择' : '选择语音'}
</Button>
)}
</>
) : (
<div className="personal-wechat-file-editor">
<div>
<strong>{voice?.name || '还没有选择语音文件'}</strong>
<small>{voice ? voice.path : '支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC'}</small>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void selectVoice()}
disabled={isBusy}
>
{voice ? '重新选择' : '选择语音'}
</Button>
</div>
)}
</div>
)}
)}
</div>
)}
</div>
<div className="personal-wechat-composer-footer">
{feedback && (
@@ -18,6 +18,7 @@ import {
DialogTitle,
Switch
} from '../ui'
import { isMac, isWindows } from '../../utils/runtime-environment'
import {
PersonalWechatChatComposer,
type ChatMessage,
@@ -25,14 +26,16 @@ import {
} from './PersonalWechatChatComposer'
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
type SelectedLocalFile = { path: string; name: string }
interface PersonalWechatSendDialogProps {
export interface PersonalWechatSendDialogProps {
contact: Contact
isGroupChat: boolean
onClose: () => void
onOpenTextToSpeechSettings?: () => void
onOpenPersonalWechatSettings?: () => void
initialMode?: PersonalWechatComposerMode
initialImage?: SelectedLocalFile | null
}
@@ -63,7 +66,12 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
}
}
export function PersonalWechatSendDialog({
export function PersonalWechatSendDialog(props: PersonalWechatSendDialogProps): React.ReactElement {
if (isWindows) return <PersonalWechatWindowsSendDialog {...props} />
return <PersonalWechatMacSendDialog {...props} />
}
function PersonalWechatMacSendDialog({
contact,
isGroupChat,
onClose,
@@ -110,7 +118,9 @@ export function PersonalWechatSendDialog({
setSendError(null)
try {
const [nextRuntime, nextSender] = await Promise.all([
window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null),
isMac
? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null)
: Promise.resolve(null),
window.api.getPersonalWechatSenderStatus()
])
if (requestId !== requestIdRef.current) return
@@ -135,6 +145,7 @@ export function PersonalWechatSendDialog({
useEffect(() => {
void refreshStatus()
if (!isMac) return undefined
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
setRuntimeProgress(status)
setRuntimeStatus(status)
@@ -144,6 +155,7 @@ export function PersonalWechatSendDialog({
}, [refreshStatus])
useEffect(() => {
if (!isMac) return undefined
let active = true
const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess
if (!readKeepProcess) return undefined
@@ -156,6 +168,7 @@ export function PersonalWechatSendDialog({
}, [])
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
if (!isMac) return
const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess
if (!saveKeepProcess) {
setSendError('请重启 TraceMemo 后再使用“保留 OneBot 进程”')
@@ -252,6 +265,7 @@ export function PersonalWechatSendDialog({
}
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
if (!isMac) return
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
setVoiceDiagnostic(diagnostic)
setVoiceDiagnosticOpen(true)
@@ -348,7 +362,7 @@ export function PersonalWechatSendDialog({
</div>
)}
</div>
{setupReady && composerStarted && (
{setupReady && composerStarted && isMac && (
<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>
@@ -364,15 +378,19 @@ export function PersonalWechatSendDialog({
</div>
)}
{(!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>
<div
className={`personal-wechat-chat-footer flex items-center ${isMac ? 'justify-between' : 'justify-end'}`}
>
{isMac && (
<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>
@@ -5,6 +5,7 @@ import type {
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { isMac } from '../../utils/runtime-environment'
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
interface PersonalWechatSetupGuideProps {
@@ -37,8 +38,10 @@ function bindingHint(status: PersonalWechatSenderStatus): string {
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
if (status.state === 'unsupported_version')
return '当前微信版本暂不支持,请查看语音设置中的支持版本。'
if (status.state === 'runtime_missing') return '请先完成语音模型准备。'
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
if (status.state === 'runtime_missing') {
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
}
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
}
@@ -70,6 +73,7 @@ export function PersonalWechatSetupGuide({
onOpenTextToSpeechSettings
}: PersonalWechatSetupGuideProps): React.ReactElement {
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
@@ -101,12 +105,12 @@ export function PersonalWechatSetupGuide({
>
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
<div className="personal-wechat-step-content">
<strong>准备语音模型</strong>
<p>发送语音消息需要本地模型,首次使用时下载一次即可。</p>
<strong>准备 {runtimeLabel}</strong>
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
{runtimeDownloading ? (
<div className="personal-wechat-download-progress" aria-live="polite">
<div>
<span>正在准备模型</span>
<span>正在准备 {runtimeLabel}</span>
<span>{progressPercent}%</span>
</div>
<div className="personal-wechat-progress-track">
@@ -114,18 +118,20 @@ export function PersonalWechatSetupGuide({
</div>
</div>
) : runtimeReady ? (
<span className="personal-wechat-step-status">✓ 语音模型已准备</span>
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
) : (
<Button
size="sm"
onClick={onDownloadRuntime}
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
>
下载模型
下载运行时
</Button>
)}
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
<p className="personal-wechat-step-error">模型准备失败,请重试或查看语音设置。</p>
<p className="personal-wechat-step-error">
运行时准备失败,请重试或查看微信发送设置。
</p>
)}
</div>
</li>
@@ -277,7 +283,9 @@ export function PersonalWechatSetupGuide({
<dl>
{[
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动'],
...(isMac
? [['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动']]
: []),
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
[
'接口监听',
@@ -0,0 +1,241 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
PersonalWechatSendRequest,
PersonalWechatSenderStatus
} from '../../../../shared/personal-wechat'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
return {
state: 'error',
platform: 'win32',
arch: 'unknown',
sipDisabled: true,
wechatRunning: false,
runtimeReady: true,
endpoint: '',
endpointReady: false,
attachReady: true,
baseAddressReady: true,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '无法检测 Windows 微信发送能力',
error: error instanceof Error ? error.message : String(error)
}
}
function statusLabel(status: PersonalWechatSenderStatus | null): string {
if (!status) return '正在检测'
if (status.state === 'online' && status.canSend) return '微信发送能力已就绪'
if (status.state === 'online') return '微信发送能力未就绪'
if (status.state === 'hook_not_ready') return '微信发送能力初始化中'
if (status.state === 'wechat_not_running') return '微信未运行'
if (status.state === 'unsupported_version') return '微信版本不匹配'
if (status.state === 'error') return '绑定异常'
return '微信发送能力未配置'
}
function statusText(value: string): string {
return value.replace(/OneBot|Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
}
function statusDescription(status: PersonalWechatSenderStatus | null): string {
if (!status) return '正在检查本地 Windows 微信发送能力'
return statusText(status.message)
}
export function PersonalWechatWindowsSendDialog({
contact,
isGroupChat,
onClose,
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
initialMode = 'text',
initialImage = null
}: PersonalWechatSendDialogProps): React.ReactElement {
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [detecting, setDetecting] = useState(true)
const [sendBusy, setSendBusy] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const requestIdRef = useRef(0)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName
const refreshStatus = useCallback(async (): Promise<void> => {
const requestId = ++requestIdRef.current
setDetecting(true)
try {
const nextStatus = await window.api.getPersonalWechatSenderStatus()
if (requestId === requestIdRef.current) setStatus(nextStatus)
} catch (error) {
if (requestId === requestIdRef.current) setStatus(fallbackStatus(error))
} finally {
if (requestId === requestIdRef.current) setDetecting(false)
}
}, [])
useEffect(() => {
void refreshStatus()
}, [refreshStatus])
const handleClose = useCallback((): void => {
if (sendBusy || closingRef.current) return
closingRef.current = true
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
onClose()
queueMicrotask(() => restoreFocus?.focus())
}, [onClose, sendBusy])
const handleOpenPersonalWechatSettings = (): void => {
if (!onOpenPersonalWechatSettings || sendBusy) return
handleClose()
onOpenPersonalWechatSettings()
}
const handleSend = async (
request: PersonalWechatSendRequest
): Promise<{ success: boolean; error?: string }> => {
setSendBusy(true)
try {
const response = await window.api.sendPersonalWechatMessage({ ...request, to: targetId })
setStatus(response.status)
return { success: response.success, error: response.error }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setStatus((current) => ({
...(current || fallbackStatus(error)),
state: 'error',
error: message
}))
return { success: false, error: message }
} finally {
setSendBusy(false)
}
}
const statusTone = useMemo(() => {
if (status?.state === 'online' && status.canSend) return 'ready'
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
return 'blocked'
}, [status])
return (
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[620px] gap-4 overflow-y-auto p-[22px]"
onOpenAutoFocus={() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
}}
onEscapeKeyDown={(event) => sendBusy && event.preventDefault()}
onPointerDownOutside={(event) => sendBusy && event.preventDefault()}
>
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
<div>
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
个人微信发送
</DialogTitle>
</div>
<DialogDescription className="sr-only">
向当前微信联系人或群聊发送文字、图片和语音。
</DialogDescription>
</DialogHeader>
<div className="personal-wechat-send-device-note" role="note">
<span aria-hidden>i</span>
<p>
<strong>请在其他设备确认发送结果</strong>
语音、图片等消息请切换到手机、平板等其他设备确认是否送达。
</p>
</div>
<div className="personal-wechat-send-target">
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
<strong>{displayName}</strong>
<code>{targetId}</code>
</div>
<div className={`personal-wechat-send-status ${statusTone}`}>
<span className="personal-wechat-send-status-dot" />
<div>
<strong>{statusLabel(status)}</strong>
<p>{statusDescription(status)}</p>
{status?.error ? <small className="error">{statusText(status.error)}</small> : null}
</div>
<div className="personal-wechat-send-status-actions">
{status && !status.canSend && onOpenPersonalWechatSettings ? (
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={handleOpenPersonalWechatSettings}
disabled={sendBusy}
>
前往发送能力设置
</Button>
) : null}
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={() => void refreshStatus()}
disabled={sendBusy || detecting}
>
{detecting ? '检测中…' : '重新检测'}
</Button>
</div>
</div>
{status ? (
<>
{messages.length > 0 ? (
<div className="personal-wechat-message-list" aria-label="消息列表">
{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>
) : null}
<PersonalWechatChatComposer
status={status}
targetId={targetId}
isGroupChat={isGroupChat}
initialMode={initialMode}
initialImage={initialImage}
className="personal-wechat-windows-composer"
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
onCancel={handleClose}
onSend={handleSend}
onMessage={(message) => setMessages((current) => [...current, message])}
busy={sendBusy}
/>
</>
) : null}
</DialogContent>
</Dialog>
)
}
@@ -22,6 +22,7 @@ interface ReportViewerProps {
) => Promise<{ success: boolean; error?: string }>
sendTarget?: Contact | null
personalWechatSendSupported?: boolean
onOpenPersonalWechatSettings?: () => void
}
const calculateFitZoom = (
@@ -50,7 +51,8 @@ export function ReportViewer({
onReveal,
onSwitchTemplate,
sendTarget = null,
personalWechatSendSupported = supportsPersonalWechatSend
personalWechatSendSupported = supportsPersonalWechatSend,
onOpenPersonalWechatSettings
}: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1)
const [fitZoom, setFitZoom] = useState(1)
@@ -276,6 +278,7 @@ export function ReportViewer({
name: report.pngPath.split(/[\\/]/).pop() || '群聊日报.png'
}}
onClose={() => setSendDialogOpen(false)}
onOpenPersonalWechatSettings={onOpenPersonalWechatSettings}
/>
)}
</main>
@@ -104,6 +104,13 @@ export function SettingsWorkspace({
)
case 'text-to-speech':
return <TextToSpeechPage onNotice={onNotice} />
case 'personal-wechat-send':
return (
<PersonalWechatSendPage
onNotice={onNotice}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
)
case 'recall-protection':
return <RecallProtectionPage onNotice={onNotice} />
case 'advanced':
@@ -46,5 +46,6 @@ export const SETTINGS_CATEGORY_LABELS = {
...Object.fromEntries(
SETTINGS_NAVIGATION.flatMap((group) => group.items.map((item) => [item.id, item.label]))
),
'personal-wechat-send': '微信发送',
'storage-export': '存储与导出'
} as Record<SettingsCategoryId, string>
@@ -5,6 +5,7 @@ export type SettingsCategoryId =
| 'voice-recognition'
| 'wechat-send'
| 'text-to-speech'
| 'personal-wechat-send'
| 'ai-model'
| 'recall-protection'
| 'local-api'
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactElement } from 'react'
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
import type {
PersonalWechatSendCapability,
PersonalWechatSenderStatus
@@ -7,8 +7,33 @@ import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../../shared/personal-wechat-runtime'
import { Button, Skeleton } from '../../../components/ui'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Skeleton
} from '../../../components/ui'
import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide'
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
import { isMac, isWindows } from '../../../utils/runtime-environment'
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
missing: '未下载',
downloading: '下载中',
ready: '已就绪',
invalid: '需要修复',
error: '下载失败',
unsupported: '暂不支持'
}
function formatBytes(value: number): string {
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> = {
unsupported: '暂不支持',
@@ -19,6 +44,13 @@ const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> =
error: '异常'
}
function normalizeWindowsPort(value: string): string | null {
const text = value.trim()
if (!/^\d{1,5}$/.test(text)) return null
const port = Number(text)
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? String(port) : null
}
function boundToCurrentWechat(status: PersonalWechatSenderStatus | null): boolean {
if (!status) return false
return (
@@ -51,8 +83,21 @@ export function PersonalWechatSendPage({
const [detecting, setDetecting] = useState(false)
const [detectionAttempted, setDetectionAttempted] = useState(false)
const [error, setError] = useState('')
const [windowsSenderStatus, setWindowsSenderStatus] = useState<PersonalWechatSenderStatus | null>(
null
)
const [windowsPortInput, setWindowsPortInput] = useState('')
const [windowsDetectedPort, setWindowsDetectedPort] = useState('')
const [windowsEndpointBusy, setWindowsEndpointBusy] = useState(false)
const [showWechatVersions, setShowWechatVersions] = useState(false)
const runtimeVersionsTriggerRef = useRef<HTMLButtonElement | null>(null)
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
const refresh = useCallback(async (): Promise<void> => {
if (isWindows) {
setLoading(false)
return
}
setError('')
try {
const [nextCapability, nextSender, nextRuntime] = await Promise.all([
@@ -72,19 +117,53 @@ export function PersonalWechatSendPage({
}, [])
useEffect(() => {
if (isWindows) return
void refresh()
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
const subscribe = window.api.onPersonalWechatRuntimeProgress
if (!subscribe) return
const unsubscribe = subscribe((status) => {
setRuntimeProgress(status)
setRuntimeStatus(status)
if (status.state === 'ready') void refresh()
})
return unsubscribe
return () => unsubscribe?.()
}, [refresh])
useEffect(() => {
if (!isWindows) return
let active = true
const loadWindowsEndpoint = async (): Promise<void> => {
try {
const settingsResult = await window.api.getSettings()
const configuredPort = String(settingsResult.settings.windowsWechatPort || '').trim()
if (!active) return
setWindowsPortInput(configuredPort)
if (configuredPort) {
const status = await window.api.getPersonalWechatSenderStatus()
if (!active) return
setWindowsSenderStatus(status)
}
setLoading(false)
} catch (reason) {
if (!active) return
setWindowsSenderStatus(null)
setError(reason instanceof Error ? reason.message : '微信发送能力状态读取失败')
setLoading(false)
}
}
void loadWindowsEndpoint()
return () => {
active = false
}
}, [refresh])
const downloadRuntime = async (): Promise<void> => {
if (runtimeBusy) return
if (runtimeBusy || !personalWechatRuntimeSupported) return
setRuntimeBusy(true)
setError('')
setRuntimeStatus((current) =>
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
)
try {
const result = await window.api.downloadPersonalWechatRuntime()
setRuntimeStatus(result.status)
@@ -97,6 +176,41 @@ export function PersonalWechatSendPage({
}
}
const cancelRuntimeDownload = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
await window.api.cancelPersonalWechatRuntimeDownload()
onNotice('正在取消发送组件下载')
}
const removeRuntime = async (): Promise<void> => {
if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return
if (!window.confirm('卸载微信发送组件?以后需要发送个人微信消息时可以重新下载。')) return
setRuntimeBusy(true)
try {
setRuntimeStatus(await window.api.removePersonalWechatRuntime())
onNotice('微信发送组件已卸载')
} catch (reason) {
onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败')
} finally {
setRuntimeBusy(false)
}
}
const openRuntimeDirectory = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
const result = await window.api.openPersonalWechatRuntimeDirectory()
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
}
const refreshRuntime = async (): Promise<void> => {
if (!isMac || runtimeBusy) return
try {
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
} catch (reason) {
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
}
}
const bindWechat = async (): Promise<void> => {
if (binding) return
setBinding(true)
@@ -116,6 +230,10 @@ export function PersonalWechatSendPage({
}
const detectCapability = async (): Promise<void> => {
if (isWindows) {
await detectWindowsEndpoint()
return
}
if (detecting) return
setDetecting(true)
setDetectionAttempted(true)
@@ -126,91 +244,379 @@ export function PersonalWechatSendPage({
}
}
const normalizedWindowsPort = normalizeWindowsPort(windowsPortInput)
const windowsCanSave = Boolean(
normalizedWindowsPort &&
normalizedWindowsPort === windowsDetectedPort &&
windowsSenderStatus?.endpointReady &&
windowsSenderStatus.canSend
)
const handleWindowsPortInput = (value: string): void => {
setWindowsPortInput(value)
setWindowsDetectedPort('')
setWindowsSenderStatus(null)
}
const detectWindowsEndpoint = async (): Promise<void> => {
if (windowsEndpointBusy) return
if (!normalizedWindowsPort) {
onNotice('请输入 1 到 65535 之间的微信发送能力端口')
return
}
setWindowsEndpointBusy(true)
setError('')
setWindowsDetectedPort('')
try {
const nextStatus = await window.api.checkPersonalWechatSenderStatus(normalizedWindowsPort)
setWindowsSenderStatus(nextStatus)
if (nextStatus.endpointReady && nextStatus.canSend) {
setWindowsDetectedPort(normalizedWindowsPort)
onNotice('已检测到微信发送能力,请点击保存')
} else {
onNotice(nextStatus.error || nextStatus.message || '未检测到微信发送能力')
}
} catch (reason) {
onNotice(reason instanceof Error ? reason.message : '微信发送能力检测失败')
} finally {
setWindowsEndpointBusy(false)
}
}
const saveWindowsEndpoint = async (): Promise<void> => {
if (windowsEndpointBusy || !windowsCanSave || !normalizedWindowsPort) return
setWindowsEndpointBusy(true)
try {
await window.api.setSettings({ windowsWechatPort: normalizedWindowsPort })
setWindowsDetectedPort('')
await refresh()
onNotice('微信发送能力端口已保存')
} catch (reason) {
onNotice(reason instanceof Error ? reason.message : '微信发送能力端口保存失败')
} finally {
setWindowsEndpointBusy(false)
}
}
const clearWindowsEndpoint = async (): Promise<void> => {
if (windowsEndpointBusy || !windowsPortInput.trim()) return
setWindowsEndpointBusy(true)
setError('')
try {
await window.api.setSettings({ windowsWechatPort: '' })
setWindowsPortInput('')
setWindowsDetectedPort('')
setWindowsSenderStatus(null)
await refresh()
onNotice('微信发送能力端口已清除')
} catch (reason) {
onNotice(reason instanceof Error ? reason.message : '微信发送能力端口清除失败')
} finally {
setWindowsEndpointBusy(false)
}
}
const status = capability?.status || 'error'
const ready = capability?.ready === true
const pageStatus: PersonalWechatSendCapability['status'] = isWindows
? windowsSenderStatus?.canSend
? 'ready'
: windowsSenderStatus?.state === 'error' && windowsSenderStatus.endpointReady
? 'error'
: windowsPortInput.trim()
? 'needs_verification'
: 'unconfigured'
: status
const pageReady = isWindows ? Boolean(windowsSenderStatus?.canSend) : ready
const pageCapabilities = isWindows
? {
text: Boolean(windowsSenderStatus?.canSendText),
image: Boolean(windowsSenderStatus?.canSendImage),
voice: Boolean(windowsSenderStatus?.canSendVoice)
}
: capability?.capabilities || { text: false, image: false, voice: false }
const pageMessage = isWindows
? windowsSenderStatus?.message || '请输入端口并检测 Windows 微信发送能力'
: capability?.message || '微信发送能力暂不可用'
return (
<div className="settings-page personal-wechat-send-page">
<header className="settings-page-header">
<div>
<h1>微信发送</h1>
<p>管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。</p>
</div>
<span
className={`settings-status-badge ${loading ? 'checking' : ready ? '' : status === 'unsupported' ? 'unavailable' : 'warning'}`}
>
{loading ? '检测中' : capabilityLabel[status]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
{loading && !capability ? (
<Skeleton className="h-28 w-full" />
) : (
<>
<h2 className="settings-section-heading">发送能力</h2>
<section className="settings-card">
<div className="flex items-start justify-between gap-4">
<div>
<span className="settings-card-kicker">个人微信</span>
<strong className="mt-1 block text-base">
{capability?.message || '微信发送能力暂不可用'}
</strong>
<p className="mt-2 text-sm text-muted-foreground">
{status === 'unsupported'
? '微信消息发送目前仅支持 macOS。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '检测中…' : '重新检测'}
</Button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', capability?.capabilities.text],
['图片', capability?.capabilities.image],
['语音', capability?.capabilities.voice]
] as const
).map(([label, available]) => (
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<strong className="mt-1 block text-sm">
{available ? '可发送' : '未就绪'}
</strong>
<>
<div className="settings-page personal-wechat-send-page">
<header className="settings-page-header">
<div>
<h1>微信发送</h1>
<p>管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。</p>
</div>
<span
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
>
{loading ? '检测中' : capabilityLabel[pageStatus]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
{loading && !capability ? (
<Skeleton className="h-28 w-full" />
) : (
<>
<h2 className="settings-section-heading">发送能力</h2>
<section className="settings-card">
<div className="flex items-start justify-between gap-4">
<div>
<span className="settings-card-kicker">个人微信</span>
<strong className="mt-1 block text-base">{pageMessage}</strong>
<p className="mt-2 text-sm text-muted-foreground">
{pageStatus === 'unsupported' && !isWindows
? '微信消息发送目前仅支持 macOS。'
: isWindows
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
</p>
</div>
))}
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</section>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '检测中…' : '重新检测'}
</Button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', pageCapabilities.text],
['图片', pageCapabilities.image],
['语音', pageCapabilities.voice]
] as const
).map(([label, available]) => (
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<strong className="mt-1 block text-sm">
{available ? '可发送' : '未就绪'}
</strong>
</div>
))}
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</section>
<h2 className="settings-section-heading">配置与检测</h2>
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
detectionAttempted={detectionAttempted}
sessionBound={boundToCurrentWechat(senderStatus)}
onDownloadRuntime={() => void downloadRuntime()}
onBind={() => void bindWechat()}
onDetect={() => void detectCapability()}
onStartSending={() =>
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
</>
)}
{isWindows ? (
<>
<h2 className="settings-section-heading">Windows 接口配置</h2>
<section className="settings-card tts-runtime-card">
<div className="tts-runtime-summary">
<span className="settings-card-kicker">Windows</span>
<strong>
{!windowsSenderStatus
? windowsPortInput
? '待检测'
: '未配置'
: windowsSenderStatus.canSend
? '微信发送能力已就绪'
: windowsSenderStatus.endpointReady
? '接口已连接,但暂不可发送'
: '未检测到发送能力'}
</strong>
<small>
{windowsSenderStatus?.message || '输入端口后检测微信发送能力'}
</small>
</div>
<div className="tts-runtime-actions tts-windows-endpoint-actions">
<label className="tts-windows-port-field">
<span>端口</span>
<Input
aria-label="微信发送能力端口"
className="tts-windows-port-input"
type="number"
min={1}
max={65535}
inputMode="numeric"
placeholder="端口号"
value={windowsPortInput}
disabled={windowsEndpointBusy}
onChange={(event) => handleWindowsPortInput(event.target.value)}
/>
</label>
<Button
variant={windowsCanSave ? 'default' : 'outline'}
size="sm"
disabled={windowsEndpointBusy}
onClick={() =>
void (windowsCanSave ? saveWindowsEndpoint() : detectWindowsEndpoint())
}
>
{windowsEndpointBusy
? windowsCanSave
? '保存中…'
: '检测中…'
: windowsCanSave
? '保存'
: '检测'}
</Button>
<Button
variant="ghost"
size="sm"
disabled={windowsEndpointBusy || !windowsPortInput.trim()}
onClick={() => void clearWindowsEndpoint()}
>
清除端口
</Button>
</div>
</section>
</>
) : isMac ? (
<>
<h2 className="settings-section-heading">OneBot 运行时</h2>
<section className="settings-card tts-runtime-card">
<div className="tts-runtime-summary">
<span className="settings-card-kicker">
OneBot {runtimeStatus?.version || 'v0.0.18'}
</span>
<strong>
{runtimeStatus?.state === 'downloading'
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
: runtimeStatus
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
: '正在检测'}
</strong>
<small>
{!runtimeStatus
? '正在检测当前平台与组件状态'
: personalWechatRuntimeSupported
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
: '当前 Mac 环境不满足个人微信发送组件要求'}
</small>
{runtimeStatus?.error ? (
<p className="tts-runtime-error">{runtimeStatus.error}</p>
) : null}
</div>
<div className="tts-runtime-actions">
{personalWechatRuntimeSupported &&
runtimeStatus?.state === 'downloading' ? (
<Button
variant="outline"
size="sm"
onClick={() => void cancelRuntimeDownload()}
>
取消下载
</Button>
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
<>
{runtimeStatus.directory ? (
<Button
variant="outline"
size="sm"
onClick={() => void openRuntimeDirectory()}
>
打开目录
</Button>
) : null}
{runtimeStatus.removable ? (
<Button
variant="destructive"
size="sm"
disabled={runtimeBusy}
onClick={() => void removeRuntime()}
>
卸载组件
</Button>
) : null}
</>
) : personalWechatRuntimeSupported ? (
<Button
size="sm"
disabled={runtimeBusy}
onClick={() => void downloadRuntime()}
>
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
? '重新下载'
: '下载组件'}
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={runtimeBusy}
onClick={() => void refreshRuntime()}
>
重新检测组件
</Button>
{personalWechatRuntimeSupported ? (
<Button
variant="outline"
size="sm"
onClick={(event) => {
runtimeVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
支持版本
</Button>
) : null}
</div>
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<div className="tts-runtime-progress">
<div>
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
<small>
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
{formatBytes(runtimeStatus.totalBytes)}
</small>
</div>
<progress
value={runtimeStatus.progress}
max={1}
aria-label="微信发送组件下载进度"
/>
</div>
) : null}
</section>
<h2 className="settings-section-heading">配置与检测</h2>
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
detectionAttempted={detectionAttempted}
sessionBound={boundToCurrentWechat(senderStatus)}
onDownloadRuntime={() => void downloadRuntime()}
onBind={() => void bindWechat()}
onDetect={() => void detectCapability()}
onStartSending={() =>
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
</>
) : null}
</>
)}
</div>
</div>
</div>
</div>
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
<DialogContent
className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto"
onCloseAutoFocus={(event) => {
const trigger = runtimeVersionsTriggerRef.current
if (!trigger) return
event.preventDefault()
trigger.focus()
runtimeVersionsTriggerRef.current = null
}}
>
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
</>
)
}
@@ -4,14 +4,8 @@ import type {
TextToSpeechSettings,
TextToSpeechVoice
} from '../../../../../shared/text-to-speech'
import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Select,
SelectContent,
@@ -19,20 +13,8 @@ import {
SelectTrigger,
SelectValue
} from '../../../components/ui'
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
import { isMac, isWindows } from '../../../utils/runtime-environment'
const VOICE_PAGE_SIZE = 24
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
missing: '未下载',
downloading: '下载中',
ready: '已就绪',
invalid: '需要修复',
error: '下载失败',
unsupported: '暂不支持'
}
const VOICE_FILTERS = [
{ value: 'Chinese', label: '中文' },
@@ -139,11 +121,6 @@ function compactCount(value?: number): string {
}).format(value)
}
function formatBytes(value: number): string {
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function voiceMetaTags(voice: TextToSpeechVoice): string[] {
return Array.from(new Set([...voice.languages, ...voice.tags])).filter(Boolean)
}
@@ -191,13 +168,8 @@ export function TextToSpeechPage({
const [savingKey, setSavingKey] = useState(false)
const [savingVoiceId, setSavingVoiceId] = useState('')
const [playingVoiceId, setPlayingVoiceId] = useState('')
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
const [runtimeBusy, setRuntimeBusy] = useState(false)
const [showWechatVersions, setShowWechatVersions] = useState(false)
const [error, setError] = useState('')
const audioRef = useRef<HTMLAudioElement | null>(null)
const wechatVersionsTriggerRef = useRef<HTMLButtonElement | null>(null)
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
const loadVoices = useCallback(
async (nextPage: number, title: string, append = false, tags: string[] = []): Promise<void> => {
@@ -254,35 +226,6 @@ export function TextToSpeechPage({
}
}, [loadVoices])
useEffect(() => {
try {
if (sessionStorage.getItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY) !== '1') return
sessionStorage.removeItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY)
setShowWechatVersions(true)
} catch {
// The page remains usable if session storage is unavailable.
}
}, [])
useEffect(() => {
let active = true
void window.api
.getPersonalWechatRuntimeStatus()
.then((status) => active && setRuntimeStatus(status))
.catch((reason) => {
if (!active) return
setRuntimeStatus(null)
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
})
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
if (active) setRuntimeStatus(status)
})
return () => {
active = false
unsubscribe()
}
}, [])
const selectedVoice = voices.find((voice) => voice.id === selectedVoiceId)
const visibleVoices = useMemo(() => voices, [voices])
@@ -431,51 +374,6 @@ export function TextToSpeechPage({
if (!result.success) onNotice(result.error || '无法打开 API Key 页面')
}
const refreshRuntime = async (): Promise<void> => {
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
}
const downloadRuntime = async (): Promise<void> => {
if (runtimeBusy || !isMac || !runtimeStatus?.supported) return
setRuntimeBusy(true)
setRuntimeStatus((current) =>
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
)
try {
const result = await window.api.downloadPersonalWechatRuntime()
setRuntimeStatus(result.status)
onNotice(result.success ? '微信发送组件已准备好' : result.error || '发送组件下载失败')
} finally {
setRuntimeBusy(false)
}
}
const cancelRuntimeDownload = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
await window.api.cancelPersonalWechatRuntimeDownload()
onNotice('正在取消发送组件下载')
}
const removeRuntime = async (): Promise<void> => {
if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return
if (!window.confirm('卸载微信发送组件?以后需要向个人微信发送语音时可以重新下载。')) return
setRuntimeBusy(true)
try {
setRuntimeStatus(await window.api.removePersonalWechatRuntime())
onNotice('微信发送组件已卸载')
} catch (reason) {
onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败')
} finally {
setRuntimeBusy(false)
}
}
const openRuntimeDirectory = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
const result = await window.api.openPersonalWechatRuntimeDirectory()
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
}
const keyStatusText = !settings
? '正在读取 API Key 状态'
: settings.keySource === 'secure-storage'
@@ -487,449 +385,289 @@ export function TextToSpeechPage({
: '当前系统安全存储不可用,请从应用环境中提供 API Key'
return (
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
<div className="settings-page text-to-speech-page">
<header className="settings-page-header">
<div>
<h1>文字转语音</h1>
<p>选择喜欢的音色,把文字生成为可发送的微信语音</p>
</div>
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
</span>
</header>
<div className="settings-page text-to-speech-page">
<header className="settings-page-header">
<div>
<h1>文字转语音</h1>
<p>配置语音服务和音色,把文字生成为自然语音</p>
</div>
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content text-to-speech-content">
<section className="tts-usage-guide">
<div className="tts-usage-guide-heading">
<div>
<span className="tts-experimental-badge">实验性功能</span>
<h2>使用说明</h2>
</div>
<p>语音生成与微信发送是两个独立步骤。建议先生成并试听,确认无误后再发送。</p>
</div>
<ol className="tts-usage-steps">
<li>
<span>1</span>
<div>
<strong>配置并选择音色</strong>
<p>保存 API Key,在下方音色库中选择一个音色。</p>
</div>
</li>
<li>
<span>2</span>
<div>
<strong>在档案中生成语音</strong>
<p>打开联系人或群聊的发送,选择“语音 → 输入文字生成”。</p>
</div>
</li>
<li>
<span>3</span>
<div>
<strong>试听后手动发送</strong>
<p>点击“生成语音”,播放检查内容,再点击发送到当前会话。</p>
</div>
</li>
</ol>
<div className="tts-hook-warning">
<div className="tts-hook-warning-icon" aria-hidden>
!
</div>
<div>
<strong>微信发送能力注意事项</strong>
<ul>
<li>
仅支持与当前 OneBot 运行时配置匹配的 macOS
微信版本;微信自动更新后需要重新确认兼容性。
</li>
<li>
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
会降低系统安全性,请确认风险后再使用。
</li>
<li>微信重新登录或 PID 改变后,需要在发送窗口中重新检测或绑定。</li>
<li>
绑定前建议关闭微信自动升级 在微信左下角打开“设置 →
通用”,取消勾选“有更新时自动升级微信”。微信自动更新后,版本可能不再兼容发送组件。
</li>
</ul>
{personalWechatRuntimeSupported ? (
<Button
className="mt-2"
variant="outline"
size="sm"
onClick={(event) => {
wechatVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
查看支持版本
</Button>
) : null}
</div>
</div>
</section>
<h2 className="settings-section-heading">微信发送组件</h2>
<section className="settings-card tts-runtime-card">
<div className="tts-runtime-summary">
<span className="settings-card-kicker">
OneBot {runtimeStatus?.version || 'v0.0.18'}
</span>
<strong>
{!isMac
? '暂不支持'
: runtimeStatus?.state === 'downloading'
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
: runtimeStatus
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
: '正在检测'}
</strong>
<small>
{isWindows
? 'Windows 暂不支持个人微信发送;仍可生成和试听语音'
: !isMac
? '当前平台暂不支持个人微信发送'
: !runtimeStatus
? '正在检测当前平台与组件状态'
: personalWechatRuntimeSupported
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
: '当前 Mac 环境不满足个人微信发送组件要求'}
</small>
{runtimeStatus?.error ? (
<p className="tts-runtime-error">{runtimeStatus.error}</p>
) : null}
</div>
<div className="tts-runtime-actions">
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<Button variant="outline" size="sm" onClick={() => void cancelRuntimeDownload()}>
取消下载
</Button>
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
<>
{runtimeStatus.directory ? (
<Button
variant="outline"
size="sm"
onClick={() => void openRuntimeDirectory()}
>
打开目录
</Button>
) : null}
{runtimeStatus.removable ? (
<Button
variant="destructive"
size="sm"
disabled={runtimeBusy}
onClick={() => void removeRuntime()}
>
卸载组件
</Button>
) : null}
</>
) : personalWechatRuntimeSupported ? (
<Button size="sm" disabled={runtimeBusy} onClick={() => void downloadRuntime()}>
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
? '重新下载'
: '下载组件'}
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={runtimeBusy}
onClick={() => void refreshRuntime()}
>
重新检测
</Button>
{personalWechatRuntimeSupported ? (
<Button
variant="outline"
size="sm"
onClick={(event) => {
wechatVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
支持版本
</Button>
) : null}
</div>
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<div className="tts-runtime-progress">
<div>
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
<small>
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
{formatBytes(runtimeStatus.totalBytes)}
</small>
</div>
<progress
value={runtimeStatus.progress}
max={1}
aria-label="微信发送组件下载进度"
/>
</div>
) : null}
</section>
<h2 className="settings-section-heading">API 设置</h2>
<section className="settings-card tts-api-card">
<div className="tts-api-heading">
<div>
<span className="settings-card-kicker">语音服务</span>
<strong>API Key</strong>
<p>保存后即可加载音色并生成语音。</p>
</div>
<Button variant="outline" size="sm" onClick={() => void openApiKeys()}>
前往 api.fish.audio 获取 Key
</Button>
</div>
<label className="tts-api-input">
<span>API Key</span>
<div>
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
disabled={savingKey || !settings?.encryptionAvailable}
placeholder={
settings?.hasStoredApiKey
? '已安全保存;输入新 Key 可替换'
: settings?.hasEnvironmentApiKey
? '当前使用环境变量;也可保存一个应用专用 Key'
: '粘贴 API Key'
}
autoComplete="off"
onChange={(event) => setApiKey(event.target.value)}
/>
<Button variant="outline" onClick={() => setShowApiKey((current) => !current)}>
{showApiKey ? '隐藏' : '显示'}
</Button>
<Button
className="tts-save-key-button"
disabled={!apiKey.trim() || savingKey || !settings?.encryptionAvailable}
onClick={() => void saveApiKey()}
>
{savingKey ? '保存中…' : '保存 Key'}
</Button>
</div>
</label>
<div className="tts-api-footer">
<span>{keyStatusText}</span>
{settings?.hasStoredApiKey ? (
<Button
variant="destructive"
size="sm"
disabled={savingKey}
onClick={() => void clearApiKey()}
>
清除应用内 Key
</Button>
) : null}
</div>
<div className="tts-model-select">
<span>合成模型</span>
<Select
value={settings?.model || 's2.1-pro-free'}
disabled={!settings}
onValueChange={(value) => void changeModel(value as TextToSpeechModel)}
>
<SelectTrigger aria-label="合成模型">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s2.1-pro-free">s2.1-pro-free</SelectItem>
<SelectItem value="s2.1-pro">s2.1-pro</SelectItem>
</SelectContent>
</Select>
</div>
</section>
<div className="tts-voice-heading">
<div className="settings-page-scroll">
<div className="settings-page-content text-to-speech-content">
<section className="tts-usage-guide">
<div className="tts-usage-guide-heading">
<div>
<h2 className="settings-section-heading">选择音色</h2>
<span className="tts-experimental-badge">实验性功能</span>
<h2>使用说明</h2>
</div>
<p>配置服务、选择音色,然后在需要使用语音的会话中生成并试听。</p>
</div>
<ol className="tts-usage-steps">
<li>
<span>1</span>
<div>
<strong>配置语音服务</strong>
<p>保存 API Key,在下方音色库中选择一个音色。</p>
</div>
</li>
<li>
<span>2</span>
<div>
<strong>输入文字生成</strong>
<p>在会话的语音工具中选择“输入文字生成”,提交需要转换的内容。</p>
</div>
</li>
<li>
<span>3</span>
<div>
<strong>试听生成结果</strong>
<p>点击“生成语音”后播放结果,确认内容和音色效果。</p>
</div>
</li>
</ol>
</section>
<h2 className="settings-section-heading">API 设置</h2>
<section className="settings-card tts-api-card">
<div className="tts-api-heading">
<div>
<span className="settings-card-kicker">语音服务</span>
<strong>API Key</strong>
<p>保存后即可加载音色并生成语音。</p>
</div>
<Button variant="outline" size="sm" onClick={() => void openApiKeys()}>
前往 api.fish.audio 获取 Key
</Button>
</div>
<label className="tts-api-input">
<span>API Key</span>
<div>
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
disabled={savingKey || !settings?.encryptionAvailable}
placeholder={
settings?.hasStoredApiKey
? '已安全保存;输入新 Key 可替换'
: settings?.hasEnvironmentApiKey
? '当前使用环境变量;也可保存一个应用专用 Key'
: '粘贴 API Key'
}
autoComplete="off"
onChange={(event) => setApiKey(event.target.value)}
/>
<Button variant="outline" onClick={() => setShowApiKey((current) => !current)}>
{showApiKey ? '隐藏' : '显示'}
</Button>
<Button
className="tts-save-key-button"
disabled={!apiKey.trim() || savingKey || !settings?.encryptionAvailable}
onClick={() => void saveApiKey()}
>
{savingKey ? '保存中…' : '保存 Key'}
</Button>
</div>
</label>
<div className="tts-api-footer">
<span>{keyStatusText}</span>
{settings?.hasStoredApiKey ? (
<Button
variant="destructive"
size="sm"
disabled={savingKey}
onClick={() => void clearApiKey()}
>
清除应用内 Key
</Button>
) : null}
</div>
<div className="tts-model-select">
<span>合成模型</span>
<Select
value={settings?.model || 's2.1-pro-free'}
disabled={!settings}
onValueChange={(value) => void changeModel(value as TextToSpeechModel)}
>
<SelectTrigger aria-label="合成模型">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s2.1-pro-free">s2.1-pro-free</SelectItem>
<SelectItem value="s2.1-pro">s2.1-pro</SelectItem>
</SelectContent>
</Select>
</div>
</section>
<div className="tts-voice-heading">
<div>
<h2 className="settings-section-heading">选择音色</h2>
<p>
当前已加载 {voices.length} 个音色{total ? `,共找到 ${total} 个` : ''}。
</p>
</div>
<form
className="tts-voice-search"
onSubmit={(event) => {
event.preventDefault()
void searchVoices()
}}
>
<span aria-hidden>⌕</span>
<Input
type="search"
value={query}
aria-label="按音色名称搜索"
placeholder="按音色名称搜索"
disabled={!settings?.hasApiKey || loadingVoices}
onChange={(event) => setQuery(event.target.value)}
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={!settings?.hasApiKey || loadingVoices}
>
搜索
</Button>
</form>
</div>
<div className="tts-voice-filters" aria-label="音色筛选">
<button
type="button"
className={!selectedTags.length ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void clearVoiceFilters()}
>
全部
</button>
{VOICE_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
className={selectedTags.includes(filter.value) ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void toggleVoiceFilter(filter.value)}
>
{filter.label}
</button>
))}
</div>
{selectedVoice ? (
<section className="tts-current-voice">
<VoiceAvatar voice={selectedVoice} />
<div>
<span className="settings-card-kicker">当前音色</span>
<strong>{selectedVoice.name}</strong>
<p>
当前已加载 {voices.length} 个音色{total ? `,共找到 ${total} 个` : ''}。
{selectedVoice.authorName ? `${selectedVoice.authorName} · ` : ''}
{selectedVoice.description}
</p>
</div>
<form
className="tts-voice-search"
onSubmit={(event) => {
event.preventDefault()
void searchVoices()
}}
>
<span aria-hidden>⌕</span>
<Input
type="search"
value={query}
aria-label="按音色名称搜索"
placeholder="按音色名称搜索"
disabled={!settings?.hasApiKey || loadingVoices}
onChange={(event) => setQuery(event.target.value)}
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={!settings?.hasApiKey || loadingVoices}
>
搜索
</Button>
</form>
</div>
<div className="tts-voice-filters" aria-label="音色筛选">
<button
type="button"
className={!selectedTags.length ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void clearVoiceFilters()}
>
全部
</button>
{VOICE_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
className={selectedTags.includes(filter.value) ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void toggleVoiceFilter(filter.value)}
>
{filter.label}
</button>
))}
</div>
{selectedVoice ? (
<section className="tts-current-voice">
<VoiceAvatar voice={selectedVoice} />
<div>
<span className="settings-card-kicker">当前音色</span>
<strong>{selectedVoice.name}</strong>
<p>
{selectedVoice.authorName ? `${selectedVoice.authorName} · ` : ''}
{selectedVoice.description}
</p>
</div>
<div className="tts-current-tags">
{voiceMetaTags(selectedVoice)
.slice(0, 5)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(selectedVoice).length > 5 ? (
<span>+{voiceMetaTags(selectedVoice).length - 5}</span>
) : null}
</div>
</section>
) : null}
{!settings?.hasApiKey && !loading ? (
<div className="settings-card tts-voice-empty">
<strong>先配置 API Key</strong>
<span>保存后即可加载并选择音色。</span>
<div className="tts-current-tags">
{voiceMetaTags(selectedVoice)
.slice(0, 5)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(selectedVoice).length > 5 ? (
<span>+{voiceMetaTags(selectedVoice).length - 5}</span>
) : null}
</div>
) : (
<div className="tts-voice-grid" role="radiogroup" aria-label="可用音色">
{visibleVoices.map((voice) => {
const selected = voice.id === selectedVoiceId
return (
<article
key={voice.id}
className={`tts-voice-card ${selected ? 'selected' : ''}`}
</section>
) : null}
{!settings?.hasApiKey && !loading ? (
<div className="settings-card tts-voice-empty">
<strong>先配置 API Key</strong>
<span>保存后即可加载并选择音色。</span>
</div>
) : (
<div className="tts-voice-grid" role="radiogroup" aria-label="可用音色">
{visibleVoices.map((voice) => {
const selected = voice.id === selectedVoiceId
return (
<article
key={voice.id}
className={`tts-voice-card ${selected ? 'selected' : ''}`}
>
<button
type="button"
role="radio"
aria-checked={selected}
className="tts-voice-select"
disabled={Boolean(savingVoiceId)}
onClick={() => void selectVoice(voice)}
>
<button
type="button"
role="radio"
aria-checked={selected}
className="tts-voice-select"
disabled={Boolean(savingVoiceId)}
onClick={() => void selectVoice(voice)}
>
<VoiceAvatar voice={voice} />
<span className="tts-voice-copy">
<span className="tts-voice-title-row">
<strong>{voice.name}</strong>
{voice.authorName ? <em>{voice.authorName}</em> : null}
</span>
<small>{voice.description || '公开音色'}</small>
<span className="tts-voice-tags">
{voiceMetaTags(voice)
.slice(0, 4)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(voice).length > 4 ? (
<span>+{voiceMetaTags(voice).length - 4}</span>
) : null}
</span>
<span className="tts-voice-stats">
<span title="使用量">▥ {compactCount(voice.taskCount)}</span>
<span title="喜欢">♡ {compactCount(voice.likeCount)}</span>
{voice.markCount ? (
<span title="收藏">☆ {compactCount(voice.markCount)}</span>
) : null}
</span>
<VoiceAvatar voice={voice} />
<span className="tts-voice-copy">
<span className="tts-voice-title-row">
<strong>{voice.name}</strong>
{voice.authorName ? <em>{voice.authorName}</em> : null}
</span>
<span className="tts-voice-check">
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
<small>{voice.description || '公开音色'}</small>
<span className="tts-voice-tags">
{voiceMetaTags(voice)
.slice(0, 4)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(voice).length > 4 ? (
<span>+{voiceMetaTags(voice).length - 4}</span>
) : null}
</span>
</button>
<Button
variant="outline"
size="sm"
disabled={!voice.previewUrl}
onClick={() => playPreview(voice)}
>
{playingVoiceId === voice.id ? '停止' : '试听'}
</Button>
</article>
)
})}
</div>
)}
<span className="tts-voice-stats">
<span title="使用量">▥ {compactCount(voice.taskCount)}</span>
<span title="喜欢">♡ {compactCount(voice.likeCount)}</span>
{voice.markCount ? (
<span title="收藏">☆ {compactCount(voice.markCount)}</span>
) : null}
</span>
</span>
<span className="tts-voice-check">
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
</span>
</button>
<Button
variant="outline"
size="sm"
disabled={!voice.previewUrl}
onClick={() => playPreview(voice)}
>
{playingVoiceId === voice.id ? '停止' : '试听'}
</Button>
</article>
)
})}
</div>
)}
{loadingVoices ? <div className="tts-voice-loading">正在加载音色…</div> : null}
{!loadingVoices && settings?.hasApiKey && !voices.length ? (
<div className="settings-card tts-voice-empty">
{appliedQuery ? `没有找到“${appliedQuery}”相关音色` : '没有获取到可用音色'}
</div>
) : null}
{hasMore ? (
<Button
variant="outline"
className="tts-load-more"
disabled={loadingVoices}
onClick={() => void loadVoices(pageNumber + 1, appliedQuery, true, selectedTags)}
>
加载更多音色
</Button>
) : null}
{error ? <p className="tts-settings-error">{error}</p> : null}
</div>
{loadingVoices ? <div className="tts-voice-loading">正在加载音色…</div> : null}
{!loadingVoices && settings?.hasApiKey && !voices.length ? (
<div className="settings-card tts-voice-empty">
{appliedQuery ? `没有找到“${appliedQuery}”相关音色` : '没有获取到可用音色'}
</div>
) : null}
{hasMore ? (
<Button
variant="outline"
className="tts-load-more"
disabled={loadingVoices}
onClick={() => void loadVoices(pageNumber + 1, appliedQuery, true, selectedTags)}
>
加载更多音色
</Button>
) : null}
{error ? <p className="tts-settings-error">{error}</p> : null}
</div>
</div>
<DialogContent
className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto"
onCloseAutoFocus={(event) => {
const trigger = wechatVersionsTriggerRef.current
if (!trigger) return
event.preventDefault()
trigger.focus()
wechatVersionsTriggerRef.current = null
}}
>
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
</div>
)
}
+62 -2
View File
@@ -275,7 +275,32 @@
}
.personal-wechat-send-mode {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 3px;
border: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
padding: 3px;
> [role='radio'] {
min-width: 0;
min-height: 34px;
border-radius: var(--wxex-radius-sm);
padding: 0 10px;
color: var(--wxex-text-secondary);
line-height: 1.2;
white-space: nowrap;
&:hover:not(:disabled):not([data-state='checked']) {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
&[data-state='checked'] {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: var(--wxex-shadow-popover);
}
}
}
.personal-wechat-send-status-dot {
@@ -944,6 +969,22 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.personal-wechat-composer-editor {
box-sizing: border-box;
height: 248px;
min-height: 248px;
min-width: 0;
overflow-y: auto;
scrollbar-gutter: stable;
}
.personal-wechat-composer-editor > .personal-wechat-text-editor,
.personal-wechat-composer-editor > .personal-wechat-voice-editor,
.personal-wechat-composer-editor > .personal-wechat-file-editor {
box-sizing: border-box;
min-height: 100%;
}
.personal-wechat-text-editor,
.personal-wechat-voice-editor,
.personal-wechat-file-editor {
@@ -951,8 +992,14 @@
gap: 8px;
}
.personal-wechat-composer-editor > .personal-wechat-text-editor {
height: 100%;
grid-template-rows: minmax(0, 1fr) auto;
}
.personal-wechat-text-editor textarea {
resize: vertical;
min-height: 0;
resize: none;
}
.personal-wechat-text-editor > span {
@@ -967,6 +1014,15 @@
gap: 10px;
}
.personal-wechat-composer-editor > .personal-wechat-file-editor {
height: 100%;
align-content: center;
}
.personal-wechat-composer-editor > .personal-wechat-voice-editor {
align-content: start;
}
.personal-wechat-file-editor > div {
min-width: 0;
}
@@ -1071,6 +1127,10 @@
.personal-wechat-generated-result {
grid-template-columns: 1fr;
}
.personal-wechat-composer-editor {
height: 280px;
min-height: 280px;
}
}
.chat-header-actions svg,
@@ -125,7 +125,8 @@
}
}
.tts-hook-warning {
.tts-hook-warning,
.tts-send-warning {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
@@ -150,7 +151,7 @@
}
}
.tts-hook-warning-icon {
.tts-send-warning-icon {
width: 24px;
height: 24px;
display: grid;
@@ -204,6 +205,24 @@
gap: 8px;
}
.tts-windows-endpoint-actions {
align-items: flex-end;
min-width: 250px;
}
.tts-windows-port-field {
display: flex;
align-items: center;
gap: 8px;
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 700;
}
.tts-windows-port-input {
width: 128px;
}
.tts-runtime-progress {
grid-column: 1 / -1;
display: grid;
@@ -9,6 +9,4 @@ export const runtimePlatform = detectRuntimePlatform()
export const isMac = runtimePlatform === 'darwin'
export const isWindows = runtimePlatform === 'win32'
// Renderer entry points use this single capability flag. The main process still
// performs the authoritative Apple Silicon and runtime checks before sending.
export const supportsPersonalWechatSend = isMac
export const supportsPersonalWechatSend = isMac || isWindows
+2
View File
@@ -87,6 +87,8 @@ export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRe
filePath: string
/** Internal comparison switch for the temporary voice regression test. */
voiceSendMode?: 'normalized' | 'legacy'
fromId?: string
durationMs?: number
}
export type PersonalWechatSendRequest =
@@ -4,6 +4,13 @@ import type React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: true,
isWindows: false,
runtimePlatform: 'darwin',
supportsPersonalWechatSend: true
}))
const getStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const downloadRuntime = vi.fn()
@@ -73,9 +80,12 @@ function renderDialog(
}
async function startComposer(): Promise<void> {
const bind = await screen.findByRole('button', { name: '绑定微信' })
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
await screen.findByText('绑定个人微信')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
const detect = await screen.findByRole('button', { name: '重新检测' })
fireEvent.click(detect)
const start = await screen.findByRole('button', { name: '开始发送' })
@@ -170,8 +180,8 @@ describe('PersonalWechatSendDialog', () => {
message: '尚未绑定当前微信'
})
renderDialog()
expect(await screen.findByText('准备语音模型')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '下载模型' })).toBeEnabled()
expect(await screen.findByText('准备 OneBot 运行时')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '下载运行时' })).toBeEnabled()
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
expect(screen.getByText('能力检测')).toBeInTheDocument()
@@ -250,7 +260,7 @@ describe('PersonalWechatSendDialog', () => {
})
fireEvent.click(screen.getByRole('button', { name: '生成语音' }))
await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '发送2' }))
fireEvent.click(screen.getByRole('button', { name: '空白语音?重新处理' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'voice',
@@ -286,8 +296,8 @@ describe('PersonalWechatSendDialog', () => {
canSendVoice: false
})
renderDialog()
await screen.findByRole('button', { name: '下载模型' })
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
await screen.findByRole('button', { name: '下载运行时' })
fireEvent.click(screen.getByRole('button', { name: '下载运行时' }))
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
await waitFor(() => expect(rebind).toHaveBeenCalledOnce())
@@ -320,8 +330,11 @@ describe('PersonalWechatSendDialog', () => {
})
renderDialog()
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
await screen.findByText('✓ 微信已绑定')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
fireEvent.click(await screen.findByRole('button', { name: '重新检测' }))
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
@@ -340,8 +353,12 @@ describe('PersonalWechatSendDialog', () => {
message: '等待消息初始化'
})
renderDialog()
fireEvent.click(await screen.findByRole('button', { name: '绑定微信' }))
await screen.findByText('✓ 微信已绑定')
await screen.findByText('绑定个人微信')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
const detect = await screen.findByRole('button', { name: '重新检测' })
expect(detect).toBeEnabled()
fireEvent.click(detect)
@@ -0,0 +1,142 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: true,
isWindows: false,
runtimePlatform: 'darwin',
supportsPersonalWechatSend: true
}))
const getCapability = vi.fn()
const getSenderStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const downloadRuntime = vi.fn()
const cancelRuntimeDownload = vi.fn()
const removeRuntime = vi.fn()
const openRuntimeDirectory = vi.fn()
const rebindSender = vi.fn()
const senderStatus = {
state: 'online' as const,
platform: 'darwin',
arch: 'arm64',
sipDisabled: true,
wechatRunning: true,
wechatPid: 4668,
boundWechatPid: 4668,
oneBotPid: 5401,
endpoint: '127.0.0.1:58080',
endpointReady: true,
wechatVersion: '4.1.11.53',
runtimeReady: true,
attachReady: true,
baseAddress: '0x114ef8000',
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true,
message: '个人微信已绑定'
}
const readyRuntime = {
version: 'v0.0.18',
state: 'ready' as const,
downloadedBytes: 66_599_785,
totalBytes: 66_599_785,
progress: 1,
platform: 'darwin' as NodeJS.Platform,
architecture: 'arm64',
supported: true,
removable: true,
directory: '/tmp/personal-wechat-runtime'
}
const capability = {
supported: true,
ready: true,
status: 'ready' as const,
capabilities: { text: true, image: true, voice: true },
senderStatus,
message: '个人微信发送能力已就绪'
}
describe('PersonalWechatSendPage on macOS', () => {
beforeEach(() => {
getCapability.mockReset().mockResolvedValue(capability)
getSenderStatus.mockReset().mockResolvedValue(senderStatus)
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
cancelRuntimeDownload.mockReset().mockResolvedValue({ success: true })
removeRuntime.mockReset().mockResolvedValue({
...readyRuntime,
state: 'missing',
downloadedBytes: 0,
progress: 0,
removable: false,
directory: undefined
})
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
rebindSender.mockReset().mockResolvedValue(senderStatus)
vi.spyOn(window, 'confirm').mockReturnValue(true)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSendCapability: getCapability,
getPersonalWechatSenderStatus: getSenderStatus,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
downloadPersonalWechatRuntime: downloadRuntime,
cancelPersonalWechatRuntimeDownload: cancelRuntimeDownload,
removePersonalWechatRuntime: removeRuntime,
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
rebindPersonalWechatSender: rebindSender
}
})
})
it('keeps OneBot runtime management on the WeChat send page', async () => {
const user = userEvent.setup()
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
expect(await screen.findByText('OneBot v0.0.18')).toBeVisible()
expect(screen.getAllByText('已就绪')).not.toHaveLength(0)
await user.click(screen.getByRole('button', { name: '打开目录' }))
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '卸载组件' }))
await waitFor(() => expect(removeRuntime).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: '支持版本' }))
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(versionsDialog).toBeVisible()
expect(versionsDialog).toHaveTextContent('4.1.11.53')
})
it('downloads a missing macOS runtime from the send page', async () => {
getRuntimeStatus.mockResolvedValue({
...readyRuntime,
state: 'missing',
downloadedBytes: 0,
progress: 0,
removable: false,
directory: undefined
})
const onNotice = vi.fn()
render(<PersonalWechatSendPage onNotice={onNotice} />)
await userEvent.setup().click(await screen.findByRole('button', { name: '下载组件' }))
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
expect(onNotice).toHaveBeenCalledWith('微信发送组件已准备好')
})
})
+2
View File
@@ -609,6 +609,8 @@ describe('daily report controls', () => {
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
const detect = screen.queryByRole('button', { name: '重新检测' })
if (detect) fireEvent.click(detect)
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('测试群日报.png')).toBeVisible()
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
@@ -0,0 +1,197 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: false,
isWindows: true,
runtimePlatform: 'win32',
supportsPersonalWechatSend: true
}))
const getTextToSpeechSettings = vi.fn()
const listTextToSpeechVoices = vi.fn()
const openFishAudioApiKeys = vi.fn()
const getSettings = vi.fn()
const setSettings = vi.fn()
const getPersonalWechatSenderStatus = vi.fn()
const getPersonalWechatRuntimeStatus = vi.fn()
const checkPersonalWechatSenderStatus = vi.fn()
const textToSpeechSettings = {
success: true,
settings: {
provider: 'fish-audio' as const,
hasApiKey: false,
hasStoredApiKey: false,
hasEnvironmentApiKey: false,
keySource: 'none' as const,
encryptionAvailable: true,
selectedVoiceId: '',
outputFormat: 'mp3' as const,
model: 's2.1-pro-free' as const,
phase: 'idle' as const
},
voices: []
}
const windowsStatus = {
state: 'online' as const,
platform: 'win32',
arch: 'x64',
sipDisabled: true,
wechatRunning: true,
runtimeReady: true,
endpoint: '127.0.0.1:4567',
endpointReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true,
message: 'Windows 微信发送能力已连接,可以发送消息'
}
describe('PersonalWechatSendPage on Windows', () => {
beforeEach(() => {
getTextToSpeechSettings.mockReset().mockResolvedValue(textToSpeechSettings)
listTextToSpeechVoices.mockReset()
openFishAudioApiKeys.mockReset().mockResolvedValue({ success: true })
getSettings.mockReset().mockResolvedValue({ settings: { windowsWechatPort: '' } })
setSettings.mockReset().mockResolvedValue({
settings: { windowsWechatPort: '4567' }
})
getPersonalWechatSenderStatus.mockReset()
getPersonalWechatRuntimeStatus.mockReset().mockResolvedValue(null)
checkPersonalWechatSenderStatus.mockReset().mockResolvedValue(windowsStatus)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getTextToSpeechSettings,
listTextToSpeechVoices,
openFishAudioApiKeys,
getSettings,
setSettings,
getPersonalWechatSenderStatus,
getPersonalWechatRuntimeStatus,
checkPersonalWechatSenderStatus
}
})
})
it('keeps an unconfigured port empty and saves it only after a successful check', async () => {
const onNotice = vi.fn()
render(<PersonalWechatSendPage onNotice={onNotice} />)
const portInput = await screen.findByRole('spinbutton', { name: '微信发送能力端口' })
expect(portInput).toHaveValue(null)
expect(screen.getByRole('button', { name: '检测' })).toBeEnabled()
expect(getPersonalWechatSenderStatus).not.toHaveBeenCalled()
fireEvent.change(portInput, { target: { value: '4567' } })
fireEvent.click(screen.getByRole('button', { name: '检测' }))
await waitFor(() => expect(checkPersonalWechatSenderStatus).toHaveBeenCalledWith('4567'))
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
expect(portInput).toHaveValue(4567)
fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(setSettings).toHaveBeenCalledWith({ windowsWechatPort: '4567' }))
expect(screen.getByRole('button', { name: '检测' })).toBeEnabled()
expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已保存')
})
it('clears a saved port and resets the connection state', async () => {
getSettings.mockResolvedValue({ settings: { windowsWechatPort: '4567' } })
getPersonalWechatSenderStatus.mockResolvedValue(windowsStatus)
const onNotice = vi.fn()
render(<PersonalWechatSendPage onNotice={onNotice} />)
const portInput = await screen.findByRole('spinbutton', { name: '微信发送能力端口' })
expect(portInput).toHaveValue(4567)
expect(screen.getByRole('button', { name: '清除端口' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '清除端口' }))
await waitFor(() => expect(setSettings).toHaveBeenCalledWith({ windowsWechatPort: '' }))
expect(portInput).toHaveValue(null)
expect(screen.getByRole('button', { name: '清除端口' })).toBeDisabled()
expect(screen.getByRole('button', { name: '检测' })).toBeEnabled()
expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已清除')
})
it('keeps the Windows send dialog free of macOS OneBot controls', async () => {
getPersonalWechatSenderStatus.mockResolvedValue({
...windowsStatus,
state: 'error',
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '未检测到 Windows 微信发送能力,请先启动并登录微信'
})
render(
<PersonalWechatSendDialog
contact={{
m_nsUsrName: 'fixture-user',
m_nsNickName: '文件传输助手',
md5: 'fixture-md5',
type: 'user'
}}
isGroupChat={false}
onClose={vi.fn()}
onOpenPersonalWechatSettings={vi.fn()}
/>
)
expect(
await screen.findByText('未检测到 Windows 微信发送能力,请先启动并登录微信')
).toBeVisible()
expect(getPersonalWechatRuntimeStatus).not.toHaveBeenCalled()
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '个人微信发送' })).toBeVisible()
expect(screen.getByRole('region', { name: '发送消息' })).toBeVisible()
})
it('opens the Windows send dialog in text mode by default', async () => {
getPersonalWechatSenderStatus.mockResolvedValue(windowsStatus)
render(
<PersonalWechatSendDialog
contact={{
m_nsUsrName: 'fixture-user',
m_nsNickName: '文件传输助手',
md5: 'fixture-md5',
type: 'user'
}}
isGroupChat={false}
onClose={vi.fn()}
/>
)
expect(await screen.findByRole('radio', { name: '文字' })).toHaveAttribute(
'data-state',
'checked'
)
expect(screen.getByRole('textbox', { name: '消息内容' })).toBeVisible()
})
it('keeps the Windows voice page focused on speech settings', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByText('API 设置')).toBeVisible()
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByText('微信发送组件')).not.toBeInTheDocument()
})
})
+6 -78
View File
@@ -14,10 +14,6 @@ const getSettings = vi.fn()
const saveSettings = vi.fn()
const listVoices = vi.fn()
const openApiKeys = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const openRuntimeDirectory = vi.fn()
const removeRuntime = vi.fn()
const response = {
success: true,
@@ -55,7 +51,6 @@ const response = {
describe('TextToSpeechPage', () => {
beforeEach(() => {
sessionStorage.clear()
getSettings.mockReset().mockResolvedValue(response)
listVoices.mockReset().mockResolvedValue({
success: true,
@@ -75,42 +70,13 @@ describe('TextToSpeechPage', () => {
}
}))
openApiKeys.mockReset().mockResolvedValue({ success: true })
getRuntimeStatus.mockReset().mockResolvedValue({
version: 'v0.0.18',
state: 'ready',
downloadedBytes: 100,
totalBytes: 100,
progress: 1,
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: true,
directory: '/tmp/personal-wechat-runtime'
})
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
removeRuntime.mockReset().mockResolvedValue({
version: 'v0.0.18',
state: 'missing',
downloadedBytes: 0,
totalBytes: 100,
progress: 0,
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: false
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getTextToSpeechSettings: getSettings,
saveTextToSpeechSettings: saveSettings,
listTextToSpeechVoices: listVoices,
openFishAudioApiKeys: openApiKeys,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
removePersonalWechatRuntime: removeRuntime
openFishAudioApiKeys: openApiKeys
}
})
})
@@ -148,7 +114,7 @@ describe('TextToSpeechPage', () => {
it('opens the official Fish Audio API key page through the main process', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
await screen.findByText('微信发送组件')
await screen.findByText('API 设置')
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
})
@@ -170,49 +136,11 @@ describe('TextToSpeechPage', () => {
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
})
it('keeps runtime directory and refresh actions wired to the existing API', async () => {
const user = userEvent.setup()
it('keeps the speech page free of personal WeChat runtime controls', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
await user.click(await screen.findByRole('button', { name: '打开目录' }))
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '重新检测' }))
expect(getRuntimeStatus).toHaveBeenCalledTimes(2)
})
it('opens supported versions from both triggers and restores focus after closing', async () => {
const user = userEvent.setup()
render(<TextToSpeechPage onNotice={vi.fn()} />)
const guideTrigger = await screen.findByRole('button', { name: '查看支持版本' })
await user.click(guideTrigger)
expect(screen.getByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(screen.getByText('请安装下列完整版本之一。')).toBeVisible()
expect(screen.getByRole('link', { name: /下载微信历史版本/ })).toHaveAttribute(
'href',
'https://github.com/zsbai/wechat-versions/releases'
)
expect(screen.getByText('4.1.6.12')).toBeVisible()
expect(screen.getByText('4.1.11.53')).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(guideTrigger).toHaveFocus()
const runtimeTrigger = screen.getByRole('button', { name: '支持版本' })
await user.click(runtimeTrigger)
const dialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(dialog).toBeVisible()
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(runtimeTrigger).toHaveFocus()
})
it('keeps the session handoff that opens supported versions automatically', async () => {
sessionStorage.setItem('wxe:show-supported-wechat-versions', '1')
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(sessionStorage.getItem('wxe:show-supported-wechat-versions')).toBeNull()
expect(await screen.findByText('API 设置')).toBeVisible()
expect(screen.queryByText(/微信|OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument()
})
})
+3 -2
View File
@@ -260,9 +260,9 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
await fixture.page.getByRole('button', { name: '微信发送' }).click()
const trigger = fixture.page.getByRole('button', { name: '查看支持版本' })
const trigger = fixture.page.getByRole('button', { name: '支持版本' })
await expect(trigger).toBeVisible()
await trigger.click()
const dialog = fixture.page.getByRole('dialog', { name: '支持的微信版本' })
@@ -278,6 +278,7 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
await modelSelect.scrollIntoViewIfNeeded()
await modelSelect.click()
@@ -98,6 +98,8 @@ describe('preload IPC contract', () => {
await api.getPersonalWechatSenderStatus()
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getStatus')
await api.checkPersonalWechatSenderStatus('4567')
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:checkStatus', '4567')
await api.rebindPersonalWechatSender()
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:rebind')
const sendRequest = {
@@ -31,12 +31,25 @@ const status = (
describe('PersonalWechatCapabilityService', () => {
it.each([
['unsupported', status({ platform: 'win32' })],
['unsupported', status({ platform: 'linux' })],
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
['needs_binding', status({ runtimeReady: true, boundWechatPid: undefined })],
['needs_verification', status({ boundWechatPid: 123 })],
['ready', status({ state: 'online', boundWechatPid: 123, canSendImage: true, canSend: true })],
['error', status({ state: 'error', boundWechatPid: 123, error: 'hook failed' })]
['error', status({ state: 'error', boundWechatPid: 123, error: 'hook failed' })],
['needs_verification', status({ platform: 'win32', endpoint: '127.0.0.1:4567' })],
[
'ready',
status({
platform: 'win32',
state: 'online',
endpoint: '127.0.0.1:4567',
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true
})
]
])('maps %s', (expected, senderStatus) => {
const service = new PersonalWechatCapabilityService({ getStatus: async () => senderStatus })
return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
@@ -13,9 +13,13 @@ vi.mock('electron', () => ({
import {
buildPersonalWechatVoiceDiagnostic,
buildWindowsWechatRequest,
buildPersonalWechatOneBotRequest,
findWechatImagePath,
findPersonalWechatRuntime,
normalizeWindowsWechatPort,
parseWindowsLoginStatus,
parseWindowsHookResponse,
parsePersonalWechatHookLog
} from '../../src/main/services/personal-wechat-send-service'
@@ -130,6 +134,91 @@ describe('personal WeChat OneBot request', () => {
})
})
describe('Windows request', () => {
it('accepts only a valid configured local port', () => {
expect(normalizeWindowsWechatPort('')).toBeNull()
expect(normalizeWindowsWechatPort(' 4567 ')).toBe('4567')
expect(normalizeWindowsWechatPort('0')).toBeNull()
expect(normalizeWindowsWechatPort('65536')).toBeNull()
expect(normalizeWindowsWechatPort('port')).toBeNull()
})
it('requires ret=0 for message responses but accepts the status shape', () => {
expect(parseWindowsHookResponse('{"ret":0,"retmsg":"success"}', true)).toEqual({
ret: 0,
retmsg: 'success'
})
expect(parseWindowsHookResponse('{"IsLogin":0,"hWeixin":123}', false)).toEqual({
IsLogin: 0,
hWeixin: 123
})
expect(() => parseWindowsHookResponse('{"ret":1,"retmsg":"fail"}', true)).toThrow('fail')
expect(() => parseWindowsHookResponse('{"retmsg":"success"}', true)).toThrow('success')
expect(() => parseWindowsHookResponse('{}', true)).toThrow('ret=undefined')
expect(() => parseWindowsHookResponse('', true)).toThrow('空响应')
})
it('allows Windows sending only for a strict logged-in status', () => {
expect(parseWindowsLoginStatus({ status: true })).toBe(true)
expect(parseWindowsLoginStatus({ status: false })).toBe(false)
expect(parseWindowsLoginStatus({ status: 1 })).toBe(false)
expect(parseWindowsLoginStatus({ status: 'true' })).toBe(false)
expect(parseWindowsLoginStatus({ IsLogin: 1, hWeixin: 123 })).toBe(false)
})
it('builds the unified text request', () => {
expect(
buildWindowsWechatRequest({
to: ' wxid_fixture ',
type: 'text',
text: ' 测试发送 ',
isGroup: false
})
).toEqual({
endpoint: '/SendMsg',
body: { toWxid: 'wxid_fixture', type: 'text', msg: '测试发送' }
})
})
it('builds the unified image request with a local path', () => {
expect(
buildWindowsWechatRequest({
to: 'room@chatroom',
type: 'image',
filePath: 'C:\\fixture\\image.png',
isGroup: true
})
).toEqual({
endpoint: '/SendMsg',
body: { toWxid: 'room@chatroom', type: 'image', msg: 'C:\\fixture\\image.png' }
})
})
it('builds the unified voice request with sender wxid and duration', () => {
expect(
buildWindowsWechatRequest(
{
to: 'wxid_fixture',
type: 'voice',
filePath: '/source/input.wav',
fromId: ' wxid_self ',
isGroup: false
},
{ filePath: 'C:\\Temp\\prepared.silk', durationMs: 1234 }
)
).toEqual({
endpoint: '/SendMsg',
body: {
toWxid: 'wxid_fixture',
type: 'voice',
msg: 'C:\\Temp\\prepared.silk',
fromWxid: 'wxid_self',
duration: 1234
}
})
})
})
describe('personal WeChat runtime discovery', () => {
it('uses the newest current-month WeChat image directory', () => {
const root = temporaryDirectory()
+1
View File
@@ -8,6 +8,7 @@ describe('settings navigation', () => {
it('places text-to-speech under intelligent capabilities', () => {
const intelligent = SETTINGS_NAVIGATION.find((group) => group.label === '智能能力')
expect(intelligent?.items.map((item) => item.id)).toEqual([
'wechat-send',
'voice-recognition',
'text-to-speech',
'ai-model'