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