mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-09 08:42:09 +08:00
feat: 增加windows发送能力
抽离功能到设置页面 抽离文字转语音功能
This commit is contained in:
+16
-4
@@ -1857,6 +1857,9 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
|
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
|
||||||
personalWechatSendService.setKeepOneBotProcess(Boolean(keep))
|
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:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
|
||||||
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
||||||
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
||||||
@@ -1874,9 +1877,18 @@ app.whenReady().then(async () => {
|
|||||||
return error ? { success: false, error } : { success: true }
|
return error ? { success: false, error } : { success: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
||||||
ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) =>
|
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
|
||||||
personalWechatSendService.send(request)
|
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', () =>
|
ipcMain.handle('wechat-personal:getVoiceDiagnostic', () =>
|
||||||
personalWechatSendService.getLatestVoiceDiagnostic()
|
personalWechatSendService.getLatestVoiceDiagnostic()
|
||||||
)
|
)
|
||||||
@@ -1913,7 +1925,7 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
||||||
const window = BrowserWindow.fromWebContents(event.sender)
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(window!, {
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
title: '选择要测试发送的图片',
|
title: '选择要发送的图片',
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
filters: [
|
filters: [
|
||||||
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||||
|
|||||||
@@ -37,13 +37,14 @@ export class PersonalWechatCapabilityService {
|
|||||||
message:
|
message:
|
||||||
status === 'unsupported'
|
status === 'unsupported'
|
||||||
? '微信消息发送目前仅支持 macOS'
|
? '微信消息发送目前仅支持 macOS'
|
||||||
: status === 'needs_binding' || status === 'unconfigured'
|
: senderStatus.message ||
|
||||||
|
(status === 'needs_binding' || status === 'unconfigured'
|
||||||
? '请先绑定个人微信'
|
? '请先绑定个人微信'
|
||||||
: status === 'needs_verification'
|
: status === 'needs_verification'
|
||||||
? '请先完成微信消息能力检测'
|
? '请先完成微信消息能力检测'
|
||||||
: status === 'ready'
|
: status === 'ready'
|
||||||
? '个人微信已准备好发送日报'
|
? '个人微信已准备好发送日报'
|
||||||
: senderStatus.message || senderStatus.error || '个人微信发送能力异常',
|
: senderStatus.error || '个人微信发送能力异常'),
|
||||||
...(senderStatus.error ? { error: senderStatus.error } : {})
|
...(senderStatus.error ? { error: senderStatus.error } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,12 @@ export class PersonalWechatCapabilityService {
|
|||||||
senderStatus: PersonalWechatSenderStatus,
|
senderStatus: PersonalWechatSenderStatus,
|
||||||
canSendImage: boolean
|
canSendImage: boolean
|
||||||
): PersonalWechatSendCapabilityState {
|
): 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') {
|
if (senderStatus.platform !== 'darwin' || senderStatus.state === 'unsupported_platform') {
|
||||||
return 'unsupported'
|
return 'unsupported'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { execFile, spawn, type ChildProcess } from 'child_process'
|
import { execFile, spawn, type ChildProcess } from 'child_process'
|
||||||
import { createHash, randomUUID } from 'crypto'
|
import { createHash, randomBytes, randomUUID } from 'crypto'
|
||||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||||
import { createConnection } from 'net'
|
import { createConnection } from 'net'
|
||||||
import { homedir } from 'os'
|
import { homedir, tmpdir } from 'os'
|
||||||
import { delimiter, dirname, join, sep } from 'path'
|
import { delimiter, dirname, join, sep } from 'path'
|
||||||
import ffmpegStaticPath from 'ffmpeg-static'
|
import ffmpegStaticPath from 'ffmpeg-static'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
} from '../../shared/personal-wechat'
|
} from '../../shared/personal-wechat'
|
||||||
import { isPackagedRuntime } from '../runtime-mode'
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
import { loadSettings, updateSettings } from './settings-store'
|
import { loadSettings, updateSettings } from './settings-store'
|
||||||
import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder'
|
import { SilkAudioDecoder, SilkAudioEncoder } from '../voice-pipeline/audio-decoder'
|
||||||
import {
|
import {
|
||||||
validateVoicePcm,
|
validateVoicePcm,
|
||||||
validateVoiceSilkMetadata,
|
validateVoiceSilkMetadata,
|
||||||
@@ -26,6 +26,7 @@ import { appLogger } from '../app-logger'
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
const DEFAULT_HOST = '127.0.0.1:58080'
|
const DEFAULT_HOST = '127.0.0.1:58080'
|
||||||
|
const WINDOWS_LOOPBACK_HOST = '127.0.0.1'
|
||||||
const START_TIMEOUT_MS = 20_000
|
const START_TIMEOUT_MS = 20_000
|
||||||
const REQUEST_TIMEOUT_MS = 20_000
|
const REQUEST_TIMEOUT_MS = 20_000
|
||||||
const STOP_TIMEOUT_MS = 3_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'
|
'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 {
|
export interface RuntimeLayout {
|
||||||
root: string
|
root: string
|
||||||
executable: 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]
|
baseAddress = text.match(/WeChat core module base:\s*(0x[0-9a-f]+)/i)?.[1]
|
||||||
} else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) {
|
} else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) {
|
||||||
readiness = 'initializing'
|
readiness = 'initializing'
|
||||||
error = '微信底层 Hook 尚未就绪,消息没有发出'
|
error = '微信发送能力尚未就绪,消息没有发出'
|
||||||
} else if (text.includes('捕获到 StartTask 调用')) {
|
} else if (text.includes('捕获到 StartTask 调用')) {
|
||||||
readiness = 'ready'
|
readiness = 'ready'
|
||||||
textHookReady = true
|
textHookReady = true
|
||||||
@@ -496,6 +513,71 @@ async function prepareVoiceFile(filePath: string): Promise<Buffer> {
|
|||||||
return createWavBuffer(decoded.pcm, decoded.sampleRate, decoded.channels)
|
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> {
|
async function readWechatVersion(): Promise<string> {
|
||||||
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
|
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
|
||||||
'-c',
|
'-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 {
|
export class PersonalWechatSendService {
|
||||||
private child: ChildProcess | null = null
|
private child: ChildProcess | null = null
|
||||||
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
|
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
|
||||||
@@ -657,6 +852,7 @@ export class PersonalWechatSendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(): Promise<PersonalWechatSenderStatus> {
|
async getStatus(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
if (process.platform === 'win32') return this.getWindowsStatus()
|
||||||
const preflight = await this.preflight()
|
const preflight = await this.preflight()
|
||||||
const [endpointReady, oneBot] = await Promise.all([
|
const [endpointReady, oneBot] = await Promise.all([
|
||||||
this.isEndpointOnline(),
|
this.isEndpointOnline(),
|
||||||
@@ -699,7 +895,7 @@ export class PersonalWechatSendService {
|
|||||||
canSend: false,
|
canSend: false,
|
||||||
canSendText: false,
|
canSendText: false,
|
||||||
canSendImage: false,
|
canSendImage: false,
|
||||||
message: '微信发送 Hook 初始化失败,请点击“绑定微信”',
|
message: '微信发送能力初始化失败,请尝试重新绑定',
|
||||||
...(hook.error ? { error: hook.error } : {})
|
...(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 {
|
getLatestVoiceDiagnostic(): PersonalWechatVoiceDiagnostic | null {
|
||||||
return latestVoiceDiagnostic ? { ...latestVoiceDiagnostic } : null
|
return latestVoiceDiagnostic ? { ...latestVoiceDiagnostic } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
|
async send(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
|
||||||
|
if (process.platform === 'win32') return this.sendWindows(request)
|
||||||
const requestId = randomUUID()
|
const requestId = randomUUID()
|
||||||
if (request.type !== 'voice') return this.sendInternal(request, requestId)
|
if (request.type !== 'voice') return this.sendInternal(request, requestId)
|
||||||
|
|
||||||
@@ -846,7 +1058,7 @@ export class PersonalWechatSendService {
|
|||||||
request.type === 'text'
|
request.type === 'text'
|
||||||
? '请先在微信中给任意好友手动发送一条文字,再重新检测'
|
? '请先在微信中给任意好友手动发送一条文字,再重新检测'
|
||||||
: request.type === 'voice'
|
: request.type === 'voice'
|
||||||
? '语音复用媒体上传 Hook,请先在微信中手动发送一张普通图片,再重新检测'
|
? '语音复用媒体上传能力,请先在微信中手动发送一张普通图片,再重新检测'
|
||||||
: '请先在微信中给任意好友手动发送一张普通图片,再重新检测'
|
: '请先在微信中给任意好友手动发送一张普通图片,再重新检测'
|
||||||
return { success: false, status, error: status.error || guidance }
|
return { success: false, status, error: status.error || guidance }
|
||||||
}
|
}
|
||||||
@@ -954,6 +1166,7 @@ export class PersonalWechatSendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async rebind(): Promise<PersonalWechatSenderStatus> {
|
async rebind(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
if (process.platform === 'win32') return this.getWindowsStatus()
|
||||||
const preflight = await this.preflight()
|
const preflight = await this.preflight()
|
||||||
if (!preflight.runtime || !preflight.status.configPath || !preflight.status.wechatPid) {
|
if (!preflight.runtime || !preflight.status.configPath || !preflight.status.wechatPid) {
|
||||||
return preflight.status
|
return preflight.status
|
||||||
@@ -995,6 +1208,7 @@ export class PersonalWechatSendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async terminate(force = false): Promise<void> {
|
async terminate(force = false): Promise<void> {
|
||||||
|
if (process.platform === 'win32') return
|
||||||
if (this.keepOneBotProcess && !force) {
|
if (this.keepOneBotProcess && !force) {
|
||||||
this.child = null
|
this.child = null
|
||||||
this.startPromise = null
|
this.startPromise = null
|
||||||
@@ -1022,6 +1236,150 @@ export class PersonalWechatSendService {
|
|||||||
return this.startPromise
|
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> {
|
private async startRuntime(): Promise<PersonalWechatSenderStatus> {
|
||||||
const preflight = await this.preflight()
|
const preflight = await this.preflight()
|
||||||
if (!preflight.runtime || !preflight.status.configPath) {
|
if (!preflight.runtime || !preflight.status.configPath) {
|
||||||
@@ -1166,7 +1524,7 @@ export class PersonalWechatSendService {
|
|||||||
wechatPid,
|
wechatPid,
|
||||||
wechatVersion,
|
wechatVersion,
|
||||||
state: 'runtime_missing',
|
state: 'runtime_missing',
|
||||||
message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 文字转语音”下载'
|
message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 微信发送”下载'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1183,7 +1541,7 @@ export class PersonalWechatSendService {
|
|||||||
runtimeReady: true,
|
runtimeReady: true,
|
||||||
executablePath: runtime.executable,
|
executablePath: runtime.executable,
|
||||||
state: 'unsupported_version',
|
state: 'unsupported_version',
|
||||||
message: `当前微信版本 ${wechatVersion} 暂不支持,请前往文字转语音设置查看支持的版本`,
|
message: `当前微信版本 ${wechatVersion} 暂不支持,请前往微信发送设置查看支持的版本`,
|
||||||
error: `缺少 ${configPath}`
|
error: `缺少 ${configPath}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface AppSettings {
|
|||||||
ttsModel: TextToSpeechModel
|
ttsModel: TextToSpeechModel
|
||||||
/** Keep a running personal-WeChat OneBot process across app restarts. */
|
/** Keep a running personal-WeChat OneBot process across app restarts. */
|
||||||
keepPersonalWechatProcess?: boolean
|
keepPersonalWechatProcess?: boolean
|
||||||
|
windowsWechatPort: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDbRoot(): string {
|
function getDefaultDbRoot(): string {
|
||||||
@@ -123,7 +124,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
showStartupProgress: true,
|
showStartupProgress: true,
|
||||||
ttsSelectedVoiceId: '',
|
ttsSelectedVoiceId: '',
|
||||||
ttsModel: 's2.1-pro-free',
|
ttsModel: 's2.1-pro-free',
|
||||||
keepPersonalWechatProcess: false
|
keepPersonalWechatProcess: false,
|
||||||
|
windowsWechatPort: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_FILE = path.join(
|
const SETTINGS_FILE = path.join(
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface VoiceAudioDecoder {
|
|||||||
decode(source: EncodedVoiceSource): Promise<DecodedVoiceAudio>
|
decode(source: EncodedVoiceSource): Promise<DecodedVoiceAudio>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EncodedSilkAudio {
|
||||||
|
data: Buffer
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
export type SilkWasmRuntimeLocation = {
|
export type SilkWasmRuntimeLocation = {
|
||||||
packagePath: string
|
packagePath: string
|
||||||
wasmPath: 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 {
|
export class AudioDecoderRegistry {
|
||||||
private readonly decoders = new Map<string, VoiceAudioDecoder>()
|
private readonly decoders = new Map<string, VoiceAudioDecoder>()
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -438,6 +438,7 @@ declare global {
|
|||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
|
windowsWechatPort: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -477,6 +478,7 @@ declare global {
|
|||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
|
windowsWechatPort: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -499,6 +501,7 @@ declare global {
|
|||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
|
windowsWechatPort: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -517,6 +520,7 @@ declare global {
|
|||||||
appearanceTheme: 'system' | 'light' | 'dark'
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
compactMode: boolean
|
compactMode: boolean
|
||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
|
windowsWechatPort: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -596,6 +600,7 @@ declare global {
|
|||||||
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
||||||
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
||||||
|
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
|
||||||
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
||||||
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
||||||
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ const api = {
|
|||||||
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
|
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
|
||||||
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
|
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
|
||||||
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
|
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
|
||||||
|
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:checkStatus', port),
|
||||||
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
|
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
||||||
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
||||||
|
|||||||
@@ -1753,6 +1753,7 @@ function App(): React.ReactElement {
|
|||||||
onLoadOlderMessages={handleLoadOlderMessages}
|
onLoadOlderMessages={handleLoadOlderMessages}
|
||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
||||||
|
onOpenPersonalWechatSettings={openWechatSendSettings}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
jumpToTime={archiveJumpTime}
|
jumpToTime={archiveJumpTime}
|
||||||
/>
|
/>
|
||||||
@@ -1821,6 +1822,7 @@ function App(): React.ReactElement {
|
|||||||
onReveal={handleRevealReport}
|
onReveal={handleRevealReport}
|
||||||
onSwitchTemplate={handleSwitchReportTemplate}
|
onSwitchTemplate={handleSwitchReportTemplate}
|
||||||
sendTarget={selectedReportContact}
|
sendTarget={selectedReportContact}
|
||||||
|
onOpenPersonalWechatSettings={openWechatSendSettings}
|
||||||
/>
|
/>
|
||||||
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface ChatWindowProps {
|
|||||||
onLoadOlderMessages?: () => Promise<void>
|
onLoadOlderMessages?: () => Promise<void>
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
|
onOpenPersonalWechatSettings?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
jumpToTime?: number | null
|
jumpToTime?: number | null
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onLoadOlderMessages,
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
onOpenTextToSpeechSettings,
|
onOpenTextToSpeechSettings,
|
||||||
|
onOpenPersonalWechatSettings,
|
||||||
isAiLoading = false,
|
isAiLoading = false,
|
||||||
jumpToTime
|
jumpToTime
|
||||||
}) => {
|
}) => {
|
||||||
@@ -171,6 +173,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
onClose={() => setSendDialogOpen(false)}
|
onClose={() => setSendDialogOpen(false)}
|
||||||
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||||
|
onOpenPersonalWechatSettings={onOpenPersonalWechatSettings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type ChatMessage = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PersonalWechatChatComposerProps {
|
interface PersonalWechatChatComposerProps {
|
||||||
|
className?: string
|
||||||
status: PersonalWechatSenderStatus
|
status: PersonalWechatSenderStatus
|
||||||
targetId: string
|
targetId: string
|
||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
@@ -42,6 +43,7 @@ function formatAudioTime(value: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PersonalWechatChatComposer({
|
export function PersonalWechatChatComposer({
|
||||||
|
className,
|
||||||
status,
|
status,
|
||||||
targetId,
|
targetId,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
@@ -245,7 +247,10 @@ export function PersonalWechatChatComposer({
|
|||||||
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="personal-wechat-composer" aria-label="发送消息">
|
<section
|
||||||
|
className={`personal-wechat-composer${className ? ` ${className}` : ''}`}
|
||||||
|
aria-label="发送消息"
|
||||||
|
>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
className="personal-wechat-composer-mode"
|
className="personal-wechat-composer-mode"
|
||||||
aria-label="消息类型"
|
aria-label="消息类型"
|
||||||
@@ -262,6 +267,7 @@ export function PersonalWechatChatComposer({
|
|||||||
<SegmentedControlItem value="voice">语音</SegmentedControlItem>
|
<SegmentedControlItem value="voice">语音</SegmentedControlItem>
|
||||||
</SegmentedControl>
|
</SegmentedControl>
|
||||||
|
|
||||||
|
<div className="personal-wechat-composer-editor">
|
||||||
{mode === 'text' && (
|
{mode === 'text' && (
|
||||||
<div className="personal-wechat-text-editor">
|
<div className="personal-wechat-text-editor">
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -286,7 +292,12 @@ export function PersonalWechatChatComposer({
|
|||||||
<strong>{image?.name || '还没有选择图片'}</strong>
|
<strong>{image?.name || '还没有选择图片'}</strong>
|
||||||
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
|
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => void selectImage()} disabled={isBusy}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void selectImage()}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
{image ? '重新选择' : '选择图片'}
|
{image ? '重新选择' : '选择图片'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,7 +348,9 @@ export function PersonalWechatChatComposer({
|
|||||||
src={generatedVoice.audioDataUrl}
|
src={generatedVoice.audioDataUrl}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
||||||
onTimeUpdate={(event) => setPreviewCurrentTime(event.currentTarget.currentTime)}
|
onTimeUpdate={(event) =>
|
||||||
|
setPreviewCurrentTime(event.currentTarget.currentTime)
|
||||||
|
}
|
||||||
onPause={() => setIsPreviewPlaying(false)}
|
onPause={() => setIsPreviewPlaying(false)}
|
||||||
onPlay={() => setIsPreviewPlaying(true)}
|
onPlay={() => setIsPreviewPlaying(true)}
|
||||||
onEnded={() => {
|
onEnded={() => {
|
||||||
@@ -414,6 +427,7 @@ export function PersonalWechatChatComposer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="personal-wechat-composer-footer">
|
<div className="personal-wechat-composer-footer">
|
||||||
{feedback && (
|
{feedback && (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
Switch
|
Switch
|
||||||
} from '../ui'
|
} from '../ui'
|
||||||
|
import { isMac, isWindows } from '../../utils/runtime-environment'
|
||||||
import {
|
import {
|
||||||
PersonalWechatChatComposer,
|
PersonalWechatChatComposer,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
@@ -25,14 +26,16 @@ import {
|
|||||||
} from './PersonalWechatChatComposer'
|
} from './PersonalWechatChatComposer'
|
||||||
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||||
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
||||||
|
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
|
||||||
|
|
||||||
type SelectedLocalFile = { path: string; name: string }
|
type SelectedLocalFile = { path: string; name: string }
|
||||||
|
|
||||||
interface PersonalWechatSendDialogProps {
|
export interface PersonalWechatSendDialogProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
|
onOpenPersonalWechatSettings?: () => void
|
||||||
initialMode?: PersonalWechatComposerMode
|
initialMode?: PersonalWechatComposerMode
|
||||||
initialImage?: SelectedLocalFile | null
|
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,
|
contact,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -110,7 +118,9 @@ export function PersonalWechatSendDialog({
|
|||||||
setSendError(null)
|
setSendError(null)
|
||||||
try {
|
try {
|
||||||
const [nextRuntime, nextSender] = await Promise.all([
|
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()
|
window.api.getPersonalWechatSenderStatus()
|
||||||
])
|
])
|
||||||
if (requestId !== requestIdRef.current) return
|
if (requestId !== requestIdRef.current) return
|
||||||
@@ -135,6 +145,7 @@ export function PersonalWechatSendDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus()
|
void refreshStatus()
|
||||||
|
if (!isMac) return undefined
|
||||||
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
|
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
|
||||||
setRuntimeProgress(status)
|
setRuntimeProgress(status)
|
||||||
setRuntimeStatus(status)
|
setRuntimeStatus(status)
|
||||||
@@ -144,6 +155,7 @@ export function PersonalWechatSendDialog({
|
|||||||
}, [refreshStatus])
|
}, [refreshStatus])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isMac) return undefined
|
||||||
let active = true
|
let active = true
|
||||||
const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess
|
const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess
|
||||||
if (!readKeepProcess) return undefined
|
if (!readKeepProcess) return undefined
|
||||||
@@ -156,6 +168,7 @@ export function PersonalWechatSendDialog({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
|
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
|
||||||
|
if (!isMac) return
|
||||||
const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess
|
const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess
|
||||||
if (!saveKeepProcess) {
|
if (!saveKeepProcess) {
|
||||||
setSendError('请重启 TraceMemo 后再使用“保留 OneBot 进程”')
|
setSendError('请重启 TraceMemo 后再使用“保留 OneBot 进程”')
|
||||||
@@ -252,6 +265,7 @@ export function PersonalWechatSendDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
|
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
|
||||||
|
if (!isMac) return
|
||||||
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
|
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
|
||||||
setVoiceDiagnostic(diagnostic)
|
setVoiceDiagnostic(diagnostic)
|
||||||
setVoiceDiagnosticOpen(true)
|
setVoiceDiagnosticOpen(true)
|
||||||
@@ -348,7 +362,7 @@ export function PersonalWechatSendDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{setupReady && composerStarted && (
|
{setupReady && composerStarted && isMac && (
|
||||||
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>保留 OneBot 进程</span>
|
<span>保留 OneBot 进程</span>
|
||||||
@@ -364,7 +378,10 @@ export function PersonalWechatSendDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!setupReady || !composerStarted) && (
|
{(!setupReady || !composerStarted) && (
|
||||||
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
<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">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>保留 OneBot 进程</span>
|
<span>保留 OneBot 进程</span>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -373,6 +390,7 @@ export function PersonalWechatSendDialog({
|
|||||||
aria-label="保留 OneBot 进程"
|
aria-label="保留 OneBot 进程"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
PersonalWechatRuntimeStatus
|
PersonalWechatRuntimeStatus
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
} from '../../../../shared/personal-wechat-runtime'
|
||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||||
|
import { isMac } from '../../utils/runtime-environment'
|
||||||
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
|
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
|
||||||
|
|
||||||
interface PersonalWechatSetupGuideProps {
|
interface PersonalWechatSetupGuideProps {
|
||||||
@@ -37,8 +38,10 @@ function bindingHint(status: PersonalWechatSenderStatus): string {
|
|||||||
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
|
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
|
||||||
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
|
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
|
||||||
if (status.state === 'unsupported_version')
|
if (status.state === 'unsupported_version')
|
||||||
return '当前微信版本暂不支持,请查看语音设置中的支持版本。'
|
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
|
||||||
if (status.state === 'runtime_missing') return '请先完成语音模型准备。'
|
if (status.state === 'runtime_missing') {
|
||||||
|
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
|
||||||
|
}
|
||||||
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
||||||
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
|
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
onOpenTextToSpeechSettings
|
onOpenTextToSpeechSettings
|
||||||
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
||||||
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
||||||
|
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
|
||||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
||||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
||||||
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
||||||
@@ -101,12 +105,12 @@ export function PersonalWechatSetupGuide({
|
|||||||
>
|
>
|
||||||
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>准备语音模型</strong>
|
<strong>准备 {runtimeLabel}</strong>
|
||||||
<p>发送语音消息需要本地模型,首次使用时下载一次即可。</p>
|
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
|
||||||
{runtimeDownloading ? (
|
{runtimeDownloading ? (
|
||||||
<div className="personal-wechat-download-progress" aria-live="polite">
|
<div className="personal-wechat-download-progress" aria-live="polite">
|
||||||
<div>
|
<div>
|
||||||
<span>正在准备模型</span>
|
<span>正在准备 {runtimeLabel}</span>
|
||||||
<span>{progressPercent}%</span>
|
<span>{progressPercent}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="personal-wechat-progress-track">
|
<div className="personal-wechat-progress-track">
|
||||||
@@ -114,18 +118,20 @@ export function PersonalWechatSetupGuide({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : runtimeReady ? (
|
) : runtimeReady ? (
|
||||||
<span className="personal-wechat-step-status">✓ 语音模型已准备</span>
|
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDownloadRuntime}
|
onClick={onDownloadRuntime}
|
||||||
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
|
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
|
||||||
>
|
>
|
||||||
下载模型
|
下载运行时
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
|
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
|
||||||
<p className="personal-wechat-step-error">模型准备失败,请重试或查看语音设置。</p>
|
<p className="personal-wechat-step-error">
|
||||||
|
运行时准备失败,请重试或查看微信发送设置。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -277,7 +283,9 @@ export function PersonalWechatSetupGuide({
|
|||||||
<dl>
|
<dl>
|
||||||
{[
|
{[
|
||||||
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
|
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
|
||||||
['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动'],
|
...(isMac
|
||||||
|
? [['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动']]
|
||||||
|
: []),
|
||||||
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
|
['绑定进程', 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 }>
|
) => Promise<{ success: boolean; error?: string }>
|
||||||
sendTarget?: Contact | null
|
sendTarget?: Contact | null
|
||||||
personalWechatSendSupported?: boolean
|
personalWechatSendSupported?: boolean
|
||||||
|
onOpenPersonalWechatSettings?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateFitZoom = (
|
const calculateFitZoom = (
|
||||||
@@ -50,7 +51,8 @@ export function ReportViewer({
|
|||||||
onReveal,
|
onReveal,
|
||||||
onSwitchTemplate,
|
onSwitchTemplate,
|
||||||
sendTarget = null,
|
sendTarget = null,
|
||||||
personalWechatSendSupported = supportsPersonalWechatSend
|
personalWechatSendSupported = supportsPersonalWechatSend,
|
||||||
|
onOpenPersonalWechatSettings
|
||||||
}: ReportViewerProps): React.ReactElement {
|
}: ReportViewerProps): React.ReactElement {
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [fitZoom, setFitZoom] = useState(1)
|
const [fitZoom, setFitZoom] = useState(1)
|
||||||
@@ -276,6 +278,7 @@ export function ReportViewer({
|
|||||||
name: report.pngPath.split(/[\\/]/).pop() || '群聊日报.png'
|
name: report.pngPath.split(/[\\/]/).pop() || '群聊日报.png'
|
||||||
}}
|
}}
|
||||||
onClose={() => setSendDialogOpen(false)}
|
onClose={() => setSendDialogOpen(false)}
|
||||||
|
onOpenPersonalWechatSettings={onOpenPersonalWechatSettings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ export function SettingsWorkspace({
|
|||||||
)
|
)
|
||||||
case 'text-to-speech':
|
case 'text-to-speech':
|
||||||
return <TextToSpeechPage onNotice={onNotice} />
|
return <TextToSpeechPage onNotice={onNotice} />
|
||||||
|
case 'personal-wechat-send':
|
||||||
|
return (
|
||||||
|
<PersonalWechatSendPage
|
||||||
|
onNotice={onNotice}
|
||||||
|
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||||
|
/>
|
||||||
|
)
|
||||||
case 'recall-protection':
|
case 'recall-protection':
|
||||||
return <RecallProtectionPage onNotice={onNotice} />
|
return <RecallProtectionPage onNotice={onNotice} />
|
||||||
case 'advanced':
|
case 'advanced':
|
||||||
|
|||||||
@@ -46,5 +46,6 @@ export const SETTINGS_CATEGORY_LABELS = {
|
|||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
SETTINGS_NAVIGATION.flatMap((group) => group.items.map((item) => [item.id, item.label]))
|
SETTINGS_NAVIGATION.flatMap((group) => group.items.map((item) => [item.id, item.label]))
|
||||||
),
|
),
|
||||||
|
'personal-wechat-send': '微信发送',
|
||||||
'storage-export': '存储与导出'
|
'storage-export': '存储与导出'
|
||||||
} as Record<SettingsCategoryId, string>
|
} as Record<SettingsCategoryId, string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type SettingsCategoryId =
|
|||||||
| 'voice-recognition'
|
| 'voice-recognition'
|
||||||
| 'wechat-send'
|
| 'wechat-send'
|
||||||
| 'text-to-speech'
|
| 'text-to-speech'
|
||||||
|
| 'personal-wechat-send'
|
||||||
| 'ai-model'
|
| 'ai-model'
|
||||||
| 'recall-protection'
|
| 'recall-protection'
|
||||||
| 'local-api'
|
| '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 {
|
import type {
|
||||||
PersonalWechatSendCapability,
|
PersonalWechatSendCapability,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus
|
||||||
@@ -7,8 +7,33 @@ import type {
|
|||||||
PersonalWechatRuntimeProgressEvent,
|
PersonalWechatRuntimeProgressEvent,
|
||||||
PersonalWechatRuntimeStatus
|
PersonalWechatRuntimeStatus
|
||||||
} from '../../../../../shared/personal-wechat-runtime'
|
} 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 { 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> = {
|
const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> = {
|
||||||
unsupported: '暂不支持',
|
unsupported: '暂不支持',
|
||||||
@@ -19,6 +44,13 @@ const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> =
|
|||||||
error: '异常'
|
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 {
|
function boundToCurrentWechat(status: PersonalWechatSenderStatus | null): boolean {
|
||||||
if (!status) return false
|
if (!status) return false
|
||||||
return (
|
return (
|
||||||
@@ -51,8 +83,21 @@ export function PersonalWechatSendPage({
|
|||||||
const [detecting, setDetecting] = useState(false)
|
const [detecting, setDetecting] = useState(false)
|
||||||
const [detectionAttempted, setDetectionAttempted] = useState(false)
|
const [detectionAttempted, setDetectionAttempted] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
|
if (isWindows) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const [nextCapability, nextSender, nextRuntime] = await Promise.all([
|
const [nextCapability, nextSender, nextRuntime] = await Promise.all([
|
||||||
@@ -72,19 +117,53 @@ export function PersonalWechatSendPage({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isWindows) return
|
||||||
void refresh()
|
void refresh()
|
||||||
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
|
const subscribe = window.api.onPersonalWechatRuntimeProgress
|
||||||
|
if (!subscribe) return
|
||||||
|
const unsubscribe = subscribe((status) => {
|
||||||
setRuntimeProgress(status)
|
setRuntimeProgress(status)
|
||||||
setRuntimeStatus(status)
|
setRuntimeStatus(status)
|
||||||
if (status.state === 'ready') void refresh()
|
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])
|
}, [refresh])
|
||||||
|
|
||||||
const downloadRuntime = async (): Promise<void> => {
|
const downloadRuntime = async (): Promise<void> => {
|
||||||
if (runtimeBusy) return
|
if (runtimeBusy || !personalWechatRuntimeSupported) return
|
||||||
setRuntimeBusy(true)
|
setRuntimeBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setRuntimeStatus((current) =>
|
||||||
|
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.downloadPersonalWechatRuntime()
|
const result = await window.api.downloadPersonalWechatRuntime()
|
||||||
setRuntimeStatus(result.status)
|
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> => {
|
const bindWechat = async (): Promise<void> => {
|
||||||
if (binding) return
|
if (binding) return
|
||||||
setBinding(true)
|
setBinding(true)
|
||||||
@@ -116,6 +230,10 @@ export function PersonalWechatSendPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detectCapability = async (): Promise<void> => {
|
const detectCapability = async (): Promise<void> => {
|
||||||
|
if (isWindows) {
|
||||||
|
await detectWindowsEndpoint()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (detecting) return
|
if (detecting) return
|
||||||
setDetecting(true)
|
setDetecting(true)
|
||||||
setDetectionAttempted(true)
|
setDetectionAttempted(true)
|
||||||
@@ -126,10 +244,103 @@ 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 status = capability?.status || 'error'
|
||||||
const ready = capability?.ready === true
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<div className="settings-page personal-wechat-send-page">
|
<div className="settings-page personal-wechat-send-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -137,9 +348,9 @@ export function PersonalWechatSendPage({
|
|||||||
<p>管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。</p>
|
<p>管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`settings-status-badge ${loading ? 'checking' : ready ? '' : status === 'unsupported' ? 'unavailable' : 'warning'}`}
|
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
|
||||||
>
|
>
|
||||||
{loading ? '检测中' : capabilityLabel[status]}
|
{loading ? '检测中' : capabilityLabel[pageStatus]}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="settings-page-scroll">
|
<div className="settings-page-scroll">
|
||||||
@@ -153,12 +364,12 @@ export function PersonalWechatSendPage({
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="settings-card-kicker">个人微信</span>
|
<span className="settings-card-kicker">个人微信</span>
|
||||||
<strong className="mt-1 block text-base">
|
<strong className="mt-1 block text-base">{pageMessage}</strong>
|
||||||
{capability?.message || '微信发送能力暂不可用'}
|
|
||||||
</strong>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{status === 'unsupported'
|
{pageStatus === 'unsupported' && !isWindows
|
||||||
? '微信消息发送目前仅支持 macOS。'
|
? '微信消息发送目前仅支持 macOS。'
|
||||||
|
: isWindows
|
||||||
|
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
||||||
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,9 +380,9 @@ export function PersonalWechatSendPage({
|
|||||||
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
|
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['文字', capability?.capabilities.text],
|
['文字', pageCapabilities.text],
|
||||||
['图片', capability?.capabilities.image],
|
['图片', pageCapabilities.image],
|
||||||
['语音', capability?.capabilities.voice]
|
['语音', pageCapabilities.voice]
|
||||||
] as const
|
] as const
|
||||||
).map(([label, available]) => (
|
).map(([label, available]) => (
|
||||||
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
|
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
|
||||||
@@ -189,6 +400,180 @@ export function PersonalWechatSendPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{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>
|
<h2 className="settings-section-heading">配置与检测</h2>
|
||||||
<PersonalWechatSetupGuide
|
<PersonalWechatSetupGuide
|
||||||
runtimeStatus={runtimeStatus}
|
runtimeStatus={runtimeStatus}
|
||||||
@@ -208,9 +593,30 @@ export function PersonalWechatSendPage({
|
|||||||
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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,
|
TextToSpeechSettings,
|
||||||
TextToSpeechVoice
|
TextToSpeechVoice
|
||||||
} from '../../../../../shared/text-to-speech'
|
} from '../../../../../shared/text-to-speech'
|
||||||
import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -19,20 +13,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from '../../../components/ui'
|
} from '../../../components/ui'
|
||||||
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
|
|
||||||
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
|
||||||
|
|
||||||
const VOICE_PAGE_SIZE = 24
|
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 = [
|
const VOICE_FILTERS = [
|
||||||
{ value: 'Chinese', label: '中文' },
|
{ value: 'Chinese', label: '中文' },
|
||||||
@@ -139,11 +121,6 @@ function compactCount(value?: number): string {
|
|||||||
}).format(value)
|
}).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[] {
|
function voiceMetaTags(voice: TextToSpeechVoice): string[] {
|
||||||
return Array.from(new Set([...voice.languages, ...voice.tags])).filter(Boolean)
|
return Array.from(new Set([...voice.languages, ...voice.tags])).filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -191,13 +168,8 @@ export function TextToSpeechPage({
|
|||||||
const [savingKey, setSavingKey] = useState(false)
|
const [savingKey, setSavingKey] = useState(false)
|
||||||
const [savingVoiceId, setSavingVoiceId] = useState('')
|
const [savingVoiceId, setSavingVoiceId] = useState('')
|
||||||
const [playingVoiceId, setPlayingVoiceId] = 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 [error, setError] = useState('')
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
const wechatVersionsTriggerRef = useRef<HTMLButtonElement | null>(null)
|
|
||||||
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
|
|
||||||
|
|
||||||
const loadVoices = useCallback(
|
const loadVoices = useCallback(
|
||||||
async (nextPage: number, title: string, append = false, tags: string[] = []): Promise<void> => {
|
async (nextPage: number, title: string, append = false, tags: string[] = []): Promise<void> => {
|
||||||
@@ -254,35 +226,6 @@ export function TextToSpeechPage({
|
|||||||
}
|
}
|
||||||
}, [loadVoices])
|
}, [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 selectedVoice = voices.find((voice) => voice.id === selectedVoiceId)
|
||||||
const visibleVoices = useMemo(() => voices, [voices])
|
const visibleVoices = useMemo(() => voices, [voices])
|
||||||
|
|
||||||
@@ -431,51 +374,6 @@ export function TextToSpeechPage({
|
|||||||
if (!result.success) onNotice(result.error || '无法打开 API Key 页面')
|
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
|
const keyStatusText = !settings
|
||||||
? '正在读取 API Key 状态'
|
? '正在读取 API Key 状态'
|
||||||
: settings.keySource === 'secure-storage'
|
: settings.keySource === 'secure-storage'
|
||||||
@@ -487,12 +385,11 @@ export function TextToSpeechPage({
|
|||||||
: '当前系统安全存储不可用,请从应用环境中提供 API Key'
|
: '当前系统安全存储不可用,请从应用环境中提供 API Key'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
|
|
||||||
<div className="settings-page text-to-speech-page">
|
<div className="settings-page text-to-speech-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>文字转语音</h1>
|
<h1>文字转语音</h1>
|
||||||
<p>选择喜欢的音色,把文字生成为可发送的微信语音</p>
|
<p>配置语音服务和音色,把文字生成为自然语音</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
|
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
|
||||||
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
|
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
|
||||||
@@ -507,174 +404,32 @@ export function TextToSpeechPage({
|
|||||||
<span className="tts-experimental-badge">实验性功能</span>
|
<span className="tts-experimental-badge">实验性功能</span>
|
||||||
<h2>使用说明</h2>
|
<h2>使用说明</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>语音生成与微信发送是两个独立步骤。建议先生成并试听,确认无误后再发送。</p>
|
<p>配置服务、选择音色,然后在需要使用语音的会话中生成并试听。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol className="tts-usage-steps">
|
<ol className="tts-usage-steps">
|
||||||
<li>
|
<li>
|
||||||
<span>1</span>
|
<span>1</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>配置并选择音色</strong>
|
<strong>配置语音服务</strong>
|
||||||
<p>保存 API Key,在下方音色库中选择一个音色。</p>
|
<p>保存 API Key,在下方音色库中选择一个音色。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>2</span>
|
<span>2</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>在档案中生成语音</strong>
|
<strong>输入文字生成</strong>
|
||||||
<p>打开联系人或群聊的发送,选择“语音 → 输入文字生成”。</p>
|
<p>在会话的语音工具中选择“输入文字生成”,提交需要转换的内容。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>3</span>
|
<span>3</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>试听后手动发送</strong>
|
<strong>试听生成结果</strong>
|
||||||
<p>点击“生成语音”,播放检查内容,再点击发送到当前会话。</p>
|
<p>点击“生成语音”后播放结果,确认内容和音色效果。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</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>
|
</section>
|
||||||
|
|
||||||
<h2 className="settings-section-heading">API 设置</h2>
|
<h2 className="settings-section-heading">API 设置</h2>
|
||||||
@@ -914,22 +669,5 @@ export function TextToSpeechPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.personal-wechat-send-mode {
|
.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 {
|
.personal-wechat-send-status-dot {
|
||||||
@@ -944,6 +969,22 @@
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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-text-editor,
|
||||||
.personal-wechat-voice-editor,
|
.personal-wechat-voice-editor,
|
||||||
.personal-wechat-file-editor {
|
.personal-wechat-file-editor {
|
||||||
@@ -951,8 +992,14 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.personal-wechat-composer-editor > .personal-wechat-text-editor {
|
||||||
|
height: 100%;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.personal-wechat-text-editor textarea {
|
.personal-wechat-text-editor textarea {
|
||||||
resize: vertical;
|
min-height: 0;
|
||||||
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-wechat-text-editor > span {
|
.personal-wechat-text-editor > span {
|
||||||
@@ -967,6 +1014,15 @@
|
|||||||
gap: 10px;
|
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 {
|
.personal-wechat-file-editor > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1127,10 @@
|
|||||||
.personal-wechat-generated-result {
|
.personal-wechat-generated-result {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.personal-wechat-composer-editor {
|
||||||
|
height: 280px;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-actions svg,
|
.chat-header-actions svg,
|
||||||
|
|||||||
@@ -125,7 +125,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tts-hook-warning {
|
.tts-hook-warning,
|
||||||
|
.tts-send-warning {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@@ -150,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tts-hook-warning-icon {
|
.tts-send-warning-icon {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -204,6 +205,24 @@
|
|||||||
gap: 8px;
|
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 {
|
.tts-runtime-progress {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -9,6 +9,4 @@ export const runtimePlatform = detectRuntimePlatform()
|
|||||||
export const isMac = runtimePlatform === 'darwin'
|
export const isMac = runtimePlatform === 'darwin'
|
||||||
export const isWindows = runtimePlatform === 'win32'
|
export const isWindows = runtimePlatform === 'win32'
|
||||||
|
|
||||||
// Renderer entry points use this single capability flag. The main process still
|
export const supportsPersonalWechatSend = isMac || isWindows
|
||||||
// performs the authoritative Apple Silicon and runtime checks before sending.
|
|
||||||
export const supportsPersonalWechatSend = isMac
|
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRe
|
|||||||
filePath: string
|
filePath: string
|
||||||
/** Internal comparison switch for the temporary voice regression test. */
|
/** Internal comparison switch for the temporary voice regression test. */
|
||||||
voiceSendMode?: 'normalized' | 'legacy'
|
voiceSendMode?: 'normalized' | 'legacy'
|
||||||
|
fromId?: string
|
||||||
|
durationMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersonalWechatSendRequest =
|
export type PersonalWechatSendRequest =
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import type React from 'react'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
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 getStatus = vi.fn()
|
||||||
const getRuntimeStatus = vi.fn()
|
const getRuntimeStatus = vi.fn()
|
||||||
const downloadRuntime = vi.fn()
|
const downloadRuntime = vi.fn()
|
||||||
@@ -73,9 +80,12 @@ function renderDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startComposer(): Promise<void> {
|
async function startComposer(): Promise<void> {
|
||||||
const bind = await screen.findByRole('button', { name: '绑定微信' })
|
await screen.findByText('绑定个人微信')
|
||||||
|
const bind = screen.queryByRole('button', { name: '绑定微信' })
|
||||||
|
if (bind) {
|
||||||
fireEvent.click(bind)
|
fireEvent.click(bind)
|
||||||
await screen.findByText('✓ 微信已绑定')
|
await screen.findByText('✓ 微信已绑定')
|
||||||
|
}
|
||||||
const detect = await screen.findByRole('button', { name: '重新检测' })
|
const detect = await screen.findByRole('button', { name: '重新检测' })
|
||||||
fireEvent.click(detect)
|
fireEvent.click(detect)
|
||||||
const start = await screen.findByRole('button', { name: '开始发送' })
|
const start = await screen.findByRole('button', { name: '开始发送' })
|
||||||
@@ -170,8 +180,8 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
message: '尚未绑定当前微信'
|
message: '尚未绑定当前微信'
|
||||||
})
|
})
|
||||||
renderDialog()
|
renderDialog()
|
||||||
expect(await screen.findByText('准备语音模型')).toBeInTheDocument()
|
expect(await screen.findByText('准备 OneBot 运行时')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '下载模型' })).toBeEnabled()
|
expect(screen.getByRole('button', { name: '下载运行时' })).toBeEnabled()
|
||||||
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
|
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
|
||||||
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
|
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
|
||||||
expect(screen.getByText('能力检测')).toBeInTheDocument()
|
expect(screen.getByText('能力检测')).toBeInTheDocument()
|
||||||
@@ -250,7 +260,7 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole('button', { name: '生成语音' }))
|
fireEvent.click(screen.getByRole('button', { name: '生成语音' }))
|
||||||
await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument())
|
||||||
fireEvent.click(screen.getByRole('button', { name: '发送2' }))
|
fireEvent.click(screen.getByRole('button', { name: '空白语音?重新处理' }))
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(sendMessage).toHaveBeenCalledWith({
|
expect(sendMessage).toHaveBeenCalledWith({
|
||||||
type: 'voice',
|
type: 'voice',
|
||||||
@@ -286,8 +296,8 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
canSendVoice: false
|
canSendVoice: false
|
||||||
})
|
})
|
||||||
renderDialog()
|
renderDialog()
|
||||||
await screen.findByRole('button', { name: '下载模型' })
|
await screen.findByRole('button', { name: '下载运行时' })
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
|
fireEvent.click(screen.getByRole('button', { name: '下载运行时' }))
|
||||||
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
|
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
|
||||||
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
|
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
|
||||||
await waitFor(() => expect(rebind).toHaveBeenCalledOnce())
|
await waitFor(() => expect(rebind).toHaveBeenCalledOnce())
|
||||||
@@ -320,8 +330,11 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
})
|
})
|
||||||
renderDialog()
|
renderDialog()
|
||||||
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
|
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
|
const bind = screen.queryByRole('button', { name: '绑定微信' })
|
||||||
|
if (bind) {
|
||||||
|
fireEvent.click(bind)
|
||||||
await screen.findByText('✓ 微信已绑定')
|
await screen.findByText('✓ 微信已绑定')
|
||||||
|
}
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '重新检测' }))
|
fireEvent.click(await screen.findByRole('button', { name: '重新检测' }))
|
||||||
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
|
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
|
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
|
||||||
@@ -340,8 +353,12 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
message: '等待消息初始化'
|
message: '等待消息初始化'
|
||||||
})
|
})
|
||||||
renderDialog()
|
renderDialog()
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '绑定微信' }))
|
await screen.findByText('绑定个人微信')
|
||||||
|
const bind = screen.queryByRole('button', { name: '绑定微信' })
|
||||||
|
if (bind) {
|
||||||
|
fireEvent.click(bind)
|
||||||
await screen.findByText('✓ 微信已绑定')
|
await screen.findByText('✓ 微信已绑定')
|
||||||
|
}
|
||||||
const detect = await screen.findByRole('button', { name: '重新检测' })
|
const detect = await screen.findByRole('button', { name: '重新检测' })
|
||||||
expect(detect).toBeEnabled()
|
expect(detect).toBeEnabled()
|
||||||
fireEvent.click(detect)
|
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('微信发送组件已准备好')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -609,6 +609,8 @@ describe('daily report controls', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
||||||
|
|
||||||
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
|
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.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||||
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
||||||
expect(screen.getByText('/Users/fixture/测试群日报.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,10 +14,6 @@ const getSettings = vi.fn()
|
|||||||
const saveSettings = vi.fn()
|
const saveSettings = vi.fn()
|
||||||
const listVoices = vi.fn()
|
const listVoices = vi.fn()
|
||||||
const openApiKeys = 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 = {
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -55,7 +51,6 @@ const response = {
|
|||||||
|
|
||||||
describe('TextToSpeechPage', () => {
|
describe('TextToSpeechPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionStorage.clear()
|
|
||||||
getSettings.mockReset().mockResolvedValue(response)
|
getSettings.mockReset().mockResolvedValue(response)
|
||||||
listVoices.mockReset().mockResolvedValue({
|
listVoices.mockReset().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -75,42 +70,13 @@ describe('TextToSpeechPage', () => {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
openApiKeys.mockReset().mockResolvedValue({ success: true })
|
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', {
|
Object.defineProperty(window, 'api', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
getTextToSpeechSettings: getSettings,
|
getTextToSpeechSettings: getSettings,
|
||||||
saveTextToSpeechSettings: saveSettings,
|
saveTextToSpeechSettings: saveSettings,
|
||||||
listTextToSpeechVoices: listVoices,
|
listTextToSpeechVoices: listVoices,
|
||||||
openFishAudioApiKeys: openApiKeys,
|
openFishAudioApiKeys: openApiKeys
|
||||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
|
||||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
|
||||||
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
|
|
||||||
removePersonalWechatRuntime: removeRuntime
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -148,7 +114,7 @@ describe('TextToSpeechPage', () => {
|
|||||||
|
|
||||||
it('opens the official Fish Audio API key page through the main process', async () => {
|
it('opens the official Fish Audio API key page through the main process', async () => {
|
||||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||||
await screen.findByText('微信发送组件')
|
await screen.findByText('API 设置')
|
||||||
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
|
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
|
||||||
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
|
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
|
||||||
})
|
})
|
||||||
@@ -170,49 +136,11 @@ describe('TextToSpeechPage', () => {
|
|||||||
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
|
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps runtime directory and refresh actions wired to the existing API', async () => {
|
it('keeps the speech page free of personal WeChat runtime controls', async () => {
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: '打开目录' }))
|
expect(await screen.findByText('API 设置')).toBeVisible()
|
||||||
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
|
expect(screen.queryByText(/微信|OneBot/i)).not.toBeInTheDocument()
|
||||||
await user.click(screen.getByRole('button', { name: '重新检测' }))
|
expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument()
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -260,9 +260,9 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
|
|||||||
.getByRole('navigation', { name: '一级导航' })
|
.getByRole('navigation', { name: '一级导航' })
|
||||||
.getByRole('button', { name: '设置' })
|
.getByRole('button', { name: '设置' })
|
||||||
.click()
|
.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 expect(trigger).toBeVisible()
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
const dialog = fixture.page.getByRole('dialog', { name: '支持的微信版本' })
|
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(dialog).toHaveCount(0)
|
||||||
await expect(trigger).toBeFocused()
|
await expect(trigger).toBeFocused()
|
||||||
|
|
||||||
|
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
|
||||||
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
|
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
|
||||||
await modelSelect.scrollIntoViewIfNeeded()
|
await modelSelect.scrollIntoViewIfNeeded()
|
||||||
await modelSelect.click()
|
await modelSelect.click()
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ describe('preload IPC contract', () => {
|
|||||||
|
|
||||||
await api.getPersonalWechatSenderStatus()
|
await api.getPersonalWechatSenderStatus()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getStatus')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getStatus')
|
||||||
|
await api.checkPersonalWechatSenderStatus('4567')
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:checkStatus', '4567')
|
||||||
await api.rebindPersonalWechatSender()
|
await api.rebindPersonalWechatSender()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:rebind')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:rebind')
|
||||||
const sendRequest = {
|
const sendRequest = {
|
||||||
|
|||||||
@@ -31,12 +31,25 @@ const status = (
|
|||||||
|
|
||||||
describe('PersonalWechatCapabilityService', () => {
|
describe('PersonalWechatCapabilityService', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['unsupported', status({ platform: 'win32' })],
|
['unsupported', status({ platform: 'linux' })],
|
||||||
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
|
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
|
||||||
['needs_binding', status({ runtimeReady: true, boundWechatPid: undefined })],
|
['needs_binding', status({ runtimeReady: true, boundWechatPid: undefined })],
|
||||||
['needs_verification', status({ boundWechatPid: 123 })],
|
['needs_verification', status({ boundWechatPid: 123 })],
|
||||||
['ready', status({ state: 'online', boundWechatPid: 123, canSendImage: true, canSend: true })],
|
['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) => {
|
])('maps %s', (expected, senderStatus) => {
|
||||||
const service = new PersonalWechatCapabilityService({ getStatus: async () => senderStatus })
|
const service = new PersonalWechatCapabilityService({ getStatus: async () => senderStatus })
|
||||||
return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
|
return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ vi.mock('electron', () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildPersonalWechatVoiceDiagnostic,
|
buildPersonalWechatVoiceDiagnostic,
|
||||||
|
buildWindowsWechatRequest,
|
||||||
buildPersonalWechatOneBotRequest,
|
buildPersonalWechatOneBotRequest,
|
||||||
findWechatImagePath,
|
findWechatImagePath,
|
||||||
findPersonalWechatRuntime,
|
findPersonalWechatRuntime,
|
||||||
|
normalizeWindowsWechatPort,
|
||||||
|
parseWindowsLoginStatus,
|
||||||
|
parseWindowsHookResponse,
|
||||||
parsePersonalWechatHookLog
|
parsePersonalWechatHookLog
|
||||||
} from '../../src/main/services/personal-wechat-send-service'
|
} 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', () => {
|
describe('personal WeChat runtime discovery', () => {
|
||||||
it('uses the newest current-month WeChat image directory', () => {
|
it('uses the newest current-month WeChat image directory', () => {
|
||||||
const root = temporaryDirectory()
|
const root = temporaryDirectory()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ describe('settings navigation', () => {
|
|||||||
it('places text-to-speech under intelligent capabilities', () => {
|
it('places text-to-speech under intelligent capabilities', () => {
|
||||||
const intelligent = SETTINGS_NAVIGATION.find((group) => group.label === '智能能力')
|
const intelligent = SETTINGS_NAVIGATION.find((group) => group.label === '智能能力')
|
||||||
expect(intelligent?.items.map((item) => item.id)).toEqual([
|
expect(intelligent?.items.map((item) => item.id)).toEqual([
|
||||||
|
'wechat-send',
|
||||||
'voice-recognition',
|
'voice-recognition',
|
||||||
'text-to-speech',
|
'text-to-speech',
|
||||||
'ai-model'
|
'ai-model'
|
||||||
|
|||||||
Reference in New Issue
Block a user