feat: 新增MAC微信发送消息 文字转语音 功能

This commit is contained in:
Wxw-Gu
2026-08-17 18:16:14 +08:00
parent a52da455c3
commit 15811c820c
48 changed files with 7240 additions and 22 deletions
+93 -2
View File
@@ -16,9 +16,8 @@ import {
dialog,
protocol
} from 'electron'
import { dirname, join } from 'path'
import { basename, dirname, extname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs'
import { extname } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { WechatDb } from './wechat-db'
@@ -112,6 +111,15 @@ import {
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
import { personalWechatSendService } from './services/personal-wechat-send-service'
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
import type { PersonalWechatSendRequest } from '../shared/personal-wechat'
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
import type {
ListTextToSpeechVoicesRequest,
SaveTextToSpeechSettingsRequest,
SynthesizeTextToSpeechRequest
} from '../shared/text-to-speech'
import { appLogger } from './app-logger'
import type { AppLogEntry } from '../shared/app-log'
import { appUpdateService } from './services/app-update-service'
@@ -158,6 +166,8 @@ let videoAssetService: VideoAssetService | null = null
const databaseKeyStore = new DatabaseKeyStore()
const imageKeyConfigService = new ImageKeyConfigService()
const aiProviderService = new AIProviderService()
const textToSpeechSettingsService = new TextToSpeechSettingsService()
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin()
const wechatShareConfigStore = new WechatShareConfigStore()
@@ -591,6 +601,11 @@ app.whenReady().then(async () => {
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
}
})
personalWechatRuntimeManager.setProgressListener((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wechat-personal:runtimeProgress', status)
}
})
protocol.handle('wxe-media', async (request) => {
const filePath = videoAssetService?.pathForUrl(request.url)
if (!filePath) return new Response('Not found', { status: 404 })
@@ -1135,6 +1150,28 @@ app.whenReady().then(async () => {
aiProviderService.migrateLegacy(config)
)
ipcMain.handle('tts:getSettings', () => textToSpeechSettingsService.get())
ipcMain.handle('tts:saveSettings', (_, request: SaveTextToSpeechSettingsRequest) =>
textToSpeechSettingsService.save(request)
)
ipcMain.handle('tts:listVoices', (_, request?: ListTextToSpeechVoicesRequest) =>
textToSpeechSettingsService.listVoices(request)
)
ipcMain.handle('tts:synthesize', (_, request: SynthesizeTextToSpeechRequest) =>
textToSpeechSettingsService.synthesize(request)
)
ipcMain.handle('tts:removeGeneratedAudio', (_, filePath: string) =>
textToSpeechSettingsService.removeGeneratedAudio(String(filePath || ''))
)
ipcMain.handle('tts:openApiKeys', async () => {
try {
await shell.openExternal('https://fish.audio/app/api-keys/')
return { success: true }
} catch {
return { success: false, error: '无法打开 Fish Audio API Key 页面' }
}
})
ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
try {
if (typeof imageSource !== 'string' || !imageSource) {
@@ -1702,6 +1739,59 @@ app.whenReady().then(async () => {
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
ipcMain.handle('wechat-personal:getRuntimeStatus', () =>
personalWechatRuntimeManager.getStatus()
)
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
success: personalWechatRuntimeManager.cancelDownload()
}))
ipcMain.handle('wechat-personal:removeRuntime', async () => {
await personalWechatSendService.terminate()
return personalWechatRuntimeManager.remove()
})
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
const status = await personalWechatRuntimeManager.getStatus()
const directory = status.directory || personalWechatRuntimeManager.directory
await fsPromises.mkdir(directory, { recursive: true })
const error = await shell.openPath(directory)
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:selectImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
title: '选择要通过个人微信测试发送的图片',
properties: ['openFile'],
filters: [
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
{ name: '所有文件', extensions: ['*'] }
]
})
if (result.canceled || !result.filePaths[0]) return { canceled: true }
return {
canceled: false,
path: result.filePaths[0],
name: basename(result.filePaths[0])
}
})
ipcMain.handle('wechat-personal:selectVoice', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
title: '选择要通过个人微信测试发送的语音',
properties: ['openFile'],
filters: [
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
{ name: '所有文件', extensions: ['*'] }
]
})
if (result.canceled || !result.filePaths[0]) return { canceled: true }
return { canceled: false, path: result.filePaths[0], name: basename(result.filePaths[0]) }
})
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
@@ -1761,6 +1851,7 @@ app.on('before-quit', (event) => {
void (async () => {
agentHubService.stop()
personalWechatSendService.stop()
flushBootstrapCacheWritesSync()
const [, nativeCallsDrained] = await Promise.all([
apiServer.stop().catch(() => undefined),
@@ -0,0 +1,364 @@
import { createHash } from 'crypto'
import { execFile } from 'child_process'
import { app, net } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { promisify } from 'util'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeStatus
} from '../../shared/personal-wechat-runtime'
import { findPersonalWechatRuntime } from './personal-wechat-send-service'
const execFileAsync = promisify(execFile)
const RUNTIME_VERSION = 'v0.0.18'
const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz'
const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}`
const ARCHIVE_SIZE = 66_599_785
const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab'
function patchPerSendPayload(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
const declarations = 'var triggerX1Payload;\nvar triggerX0;'
const patchedDeclarations =
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
if (!source.includes(declarations) || !source.includes(originalSend)) {
throw new Error('下载的发送组件与当前应用不兼容')
}
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
source = source.replace(
' MMStartTask(triggerX0, triggerX1Payload);',
' MMStartTask(triggerX0, activeTriggerX1Payload);'
)
source = source.replace(
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
)
source = source.replace(
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
)
writeFileSync(scriptPath, source)
}
function patchImageHookReadiness(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
source.includes('图片上传 Hook Setup Complete')
) {
return
}
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0" + capturedUploadX0);
\t\t\t}
\t\t\tuploadGlobalX0 = capturedUploadX0;`
if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容')
source = source.replace(original, patched)
source = source.replace(
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
)
writeFileSync(scriptPath, source)
}
function patchWechatCoreModuleBase(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return
const initMarker = 'function initAddresses() {'
const initIndex = source.indexOf(initMarker)
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
throw new Error('下载的微信版本配置与当前应用不兼容')
}
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
var module = Process.enumerateModules().find(function(m) {
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
});
if (!module) {
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
}
var moduleBase = module.base;
var baseAddr = moduleBase;
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
setImmediate(initAddresses);
`
source = patchedHeader + source.slice(initIndex)
writeFileSync(scriptPath, source)
}
function addModifiedWorkNotice(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
source = `/*
* TraceMemo wechat_chatter compatibility modifications
* Modified: 2026-08-17
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
* These modifications are not provided by the upstream author.
*/
${source}`
writeFileSync(scriptPath, source)
}
export class PersonalWechatRuntimeManager {
private downloadController: AbortController | null = null
private downloadPromise: Promise<PersonalWechatRuntimeDownloadResult> | null = null
private downloadedBytes = 0
private lastProgressAt = 0
private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null
get directory(): string {
return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64')
}
private get archivePath(): string {
return join(
app.getPath('userData'),
'downloads',
`wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}`
)
}
setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void {
this.progressListener = listener
}
async getStatus(): Promise<PersonalWechatRuntimeStatus> {
if (!this.isSupported()) {
return this.buildStatus(
'unsupported',
0,
process.platform === 'win32'
? 'Windows 暂不支持个人微信发送组件'
: `当前系统暂不支持个人微信发送组件:${process.platform} ${process.arch}`
)
}
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
const runtime = findPersonalWechatRuntime()
if (runtime) {
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
}
const hasPartialInstall = await this.hasPartialInstall()
return this.buildStatus(
hasPartialInstall ? 'invalid' : 'missing',
0,
hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined,
hasPartialInstall ? this.directory : undefined
)
}
download(): Promise<PersonalWechatRuntimeDownloadResult> {
if (!this.isSupported()) {
return this.getStatus().then((status) => ({ success: false, status, error: status.error }))
}
if (this.downloadPromise) return this.downloadPromise
this.downloadedBytes = 0
this.downloadController = new AbortController()
this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => {
this.downloadPromise = null
this.downloadController = null
})
return this.downloadPromise
}
cancelDownload(): boolean {
if (!this.downloadController) return false
this.downloadController.abort()
return true
}
async remove(): Promise<PersonalWechatRuntimeStatus> {
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
await Promise.all([
rm(this.directory, { recursive: true, force: true }),
rm(this.archivePath, { force: true }),
rm(`${this.archivePath}.partial`, { force: true })
])
return this.getStatus()
}
private async runDownload(signal: AbortSignal): Promise<PersonalWechatRuntimeDownloadResult> {
const archive = this.archivePath
const downloadsDirectory = dirname(archive)
const partial = `${archive}.partial`
let extractionDirectory = ''
let stagedDirectory = ''
try {
await mkdir(downloadsDirectory, { recursive: true })
await rm(partial, { force: true })
const response = await net.fetch(ARCHIVE_URL, { signal })
if (!response.ok || !response.body) {
throw new Error(`发送组件下载失败:HTTP ${response.status}`)
}
const handle = await open(partial, 'w')
const hash = createHash('sha256')
try {
const reader = response.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError')
const chunk = Buffer.from(value)
await handle.write(chunk)
hash.update(chunk)
this.downloadedBytes += chunk.length
this.reportProgress(this.buildStatus('downloading', this.downloadedBytes))
}
} finally {
await handle.close()
}
if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) {
throw new Error('发送组件校验失败,请重新下载')
}
await rm(archive, { force: true })
await rename(partial, archive)
extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-'))
await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory])
stagedDirectory = `${this.directory}.installing-${process.pid}`
await rm(stagedDirectory, { recursive: true, force: true })
await mkdir(dirname(stagedDirectory), { recursive: true })
await mkdir(join(stagedDirectory, 'onebot'), { recursive: true })
const sourceOneBot = join(extractionDirectory, 'onebot')
const sourceVersions = join(extractionDirectory, 'wechat_version')
await Promise.all([
cp(sourceVersions, join(stagedDirectory, 'wechat_version'), {
recursive: true,
force: true
}),
copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')),
copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js'))
])
const executable = join(stagedDirectory, 'onebot', 'onebot')
const script = join(stagedDirectory, 'onebot', 'script.js')
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchImageHookReadiness(script)
addModifiedWorkNotice(script)
await chmod(executable, 0o755)
const pythonPackages = join(stagedDirectory, 'python')
await mkdir(pythonPackages, { recursive: true })
try {
await execFileAsync('/usr/bin/env', [
'python3',
'-m',
'pip',
'install',
'--disable-pip-version-check',
'--no-compile',
'--target',
pythonPackages,
'pilk==0.2.4'
])
} catch (error) {
console.warn(
'[PersonalWechatRuntime] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器:',
error
)
}
for (const required of [
executable,
script,
join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
]) {
if (!existsSync(required)) throw new Error('发送组件解压后文件不完整')
}
await rm(this.directory, { recursive: true, force: true })
await rename(stagedDirectory, this.directory)
stagedDirectory = ''
const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory)
this.reportProgress(status, true)
return { success: true, status }
} catch (error) {
const cancelled = signal.aborted
const message = cancelled
? '发送组件下载已取消'
: error instanceof Error
? error.message
: String(error)
await rm(partial, { force: true })
const status = this.buildStatus(
cancelled ? 'missing' : 'error',
this.downloadedBytes,
message
)
this.reportProgress(status, true)
return { success: false, status, error: message }
} finally {
if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true })
if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true })
}
}
private async hasPartialInstall(): Promise<boolean> {
try {
await stat(this.directory)
return true
} catch {
return false
}
}
private isSupported(): boolean {
return process.platform === 'darwin' && process.arch === 'arm64'
}
private buildStatus(
state: PersonalWechatRuntimeStatus['state'],
downloadedBytes: number,
error?: string,
directory?: string
): PersonalWechatRuntimeStatus {
return {
version: RUNTIME_VERSION,
state,
downloadedBytes,
totalBytes: ARCHIVE_SIZE,
progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0,
platform: process.platform,
architecture: process.arch,
supported: this.isSupported(),
removable: directory === this.directory,
...(directory ? { directory } : {}),
...(error ? { error } : {})
}
}
private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void {
const now = Date.now()
if (!force && now - this.lastProgressAt < 100) return
this.lastProgressAt = now
this.progressListener?.(status)
}
}
@@ -0,0 +1,880 @@
import { app } from 'electron'
import { execFile, spawn, type ChildProcess } from 'child_process'
import { createHash } from 'crypto'
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import { createConnection } from 'net'
import { homedir } from 'os'
import { delimiter, dirname, join, sep } from 'path'
import ffmpegStaticPath from 'ffmpeg-static'
import { promisify } from 'util'
import type {
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
} from '../../shared/personal-wechat'
import { isPackagedRuntime } from '../runtime-mode'
import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder'
const execFileAsync = promisify(execFile)
const DEFAULT_HOST = '127.0.0.1:58080'
const START_TIMEOUT_MS = 20_000
const REQUEST_TIMEOUT_MS = 20_000
const STOP_TIMEOUT_MS = 3_000
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_VOICE_BYTES = 20 * 1024 * 1024
const WECHAT_APP_PATH = '/Applications/WeChat.app'
const WECHAT_FILES_ROOT = join(
homedir(),
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
)
export interface RuntimeLayout {
root: string
executable: string
workingDirectory: string
configDirectory: string
logPath: string
}
export type PersonalWechatHookReadiness = 'unknown' | 'initializing' | 'ready' | 'failed'
export function parsePersonalWechatHookLog(log: string): {
readiness: PersonalWechatHookReadiness
attached: boolean
baseAddress?: string
textHookInstalled: boolean
textHookReady: boolean
imageHookInstalled: boolean
imageHookReady: boolean
messageListenerReady: boolean
boundWechatPid?: number
error?: string
} {
let readiness: PersonalWechatHookReadiness = 'unknown'
let attached = false
let baseAddress: string | undefined
let textHookInstalled = false
let textHookReady = false
let imageHookInstalled = false
let imageHookReady = false
let messageListenerReady = false
let boundWechatPid: number | undefined
let error: string | undefined
for (const line of log.split(/\r?\n/)) {
if (!line.trim()) continue
try {
const entry = JSON.parse(line) as {
payload?: string
err?: string
message?: string
PID?: number
type?: string
result?: string | number
}
const text = `${entry.payload || ''} ${entry.err || ''} ${entry.message || ''}`
// wechat_chatter writes the task receipt and its result as two separate
// JSON log records: the first has type=send_image, while the next has
// the result and the Chinese result message but no type field.
if (
(entry.type === 'send_image' || text.includes('发送图片任务执行结果')) &&
String(entry.result) === '1'
) {
imageHookInstalled = true
imageHookReady = true
}
if (
(entry.type === 'image' || text.includes('上传图片任务执行结果')) &&
String(entry.result) === '0'
) {
imageHookInstalled = true
}
if (text.includes('使用指定的微信进程 PID')) {
readiness = 'unknown'
attached = false
baseAddress = undefined
textHookInstalled = false
textHookReady = false
imageHookInstalled = false
imageHookReady = false
messageListenerReady = false
boundWechatPid = Number(entry.PID) || undefined
error = undefined
} else if (
text.includes("Cannot find 'req2buf' keyword") ||
text.includes('Attach 失败') ||
text.includes('unable to intercept function')
) {
readiness = 'failed'
error = entry.err || entry.message
} else if (text.includes('成功 Attach 微信进程')) {
attached = true
boundWechatPid = Number(entry.PID) || boundWechatPid
} else if (text.includes('Base address from range:')) {
baseAddress = text.match(/Base address from range:\s*(0x[0-9a-f]+)/i)?.[1]
} else if (text.includes('WeChat core module base:')) {
baseAddress = text.match(/WeChat core module base:\s*(0x[0-9a-f]+)/i)?.[1]
} else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) {
readiness = 'initializing'
error = '微信底层 Hook 尚未就绪,消息没有发出'
} else if (text.includes('捕获到 StartTask 调用')) {
readiness = 'ready'
textHookReady = true
error = undefined
} else if (text.includes('Dynamic Text Message Setup Complete')) {
textHookInstalled = true
if (readiness === 'unknown') readiness = 'initializing'
} else if (text.includes('捕获到图片上传上下文')) {
imageHookReady = true
} else if (text.includes('图片上传 Hook Setup Complete')) {
imageHookInstalled = true
} else if (text.includes('HTTP 服务启动在')) {
messageListenerReady = true
} else if (text.includes('发送数据')) {
messageListenerReady = true
}
} catch {
// Ignore non-JSON or partially written log lines.
}
}
return {
readiness,
attached,
...(baseAddress ? { baseAddress } : {}),
textHookInstalled,
textHookReady,
imageHookInstalled,
imageHookReady,
messageListenerReady,
...(boundWechatPid ? { boundWechatPid } : {}),
...(error ? { error } : {})
}
}
interface PreflightResult {
status: PersonalWechatSenderStatus
runtime?: RuntimeLayout
}
function toConfigFileName(version: string): string {
return `${version.replace(/\./g, '_')}_mac.json`
}
function runtimeCandidates(): string[] {
const override = String(process.env['WECHAT_CHATTER_RUNTIME_DIR'] || '').trim()
const relative = ['connectors', 'wechat-personal', `${process.platform}-${process.arch}`]
const downloaded = join(app.getPath('userData'), ...relative)
const packaged = join(process.resourcesPath, 'resources', ...relative)
const development = join(app.getAppPath(), 'resources', ...relative)
const ordered = isPackagedRuntime()
? [downloaded, packaged, development]
: [downloaded, development, packaged]
return override ? [override, ...ordered] : ordered
}
export function findPersonalWechatRuntime(candidates = runtimeCandidates()): RuntimeLayout | null {
for (const root of candidates) {
const nestedExecutable = join(root, 'onebot', 'onebot')
const flatExecutable = join(root, 'onebot')
if (existsSync(nestedExecutable) && existsSync(join(root, 'onebot', 'script.js'))) {
return {
root,
executable: nestedExecutable,
workingDirectory: join(root, 'onebot'),
configDirectory: join(root, 'wechat_version'),
logPath: join(root, 'onebot', 'log', 'macos.log')
}
}
if (existsSync(flatExecutable) && existsSync(join(root, 'script.js'))) {
return {
root,
executable: flatExecutable,
workingDirectory: root,
configDirectory: join(root, 'wechat_version'),
logPath: join(root, 'log', 'macos.log')
}
}
}
return null
}
export function buildPersonalWechatOneBotRequest(
request: PersonalWechatSendRequest,
fileBase64?: string
): {
endpoint: string
body: Record<string, unknown>
} {
const target = request.to.trim()
const isGroup = request.isGroup || target.endsWith('@chatroom')
const message =
request.type === 'text'
? [{ type: 'text', data: { text: request.text.trim() } }]
: [
{
type: request.type === 'voice' ? 'record' : 'image',
data: { file: `base64://${fileBase64 || ''}` }
}
]
return {
endpoint: isGroup ? '/send_group_msg' : '/send_private_msg',
body: {
...(isGroup ? { group_id: target } : { user_id: target }),
message
}
}
}
function buildRuntimePath(): string {
const existing = String(process.env['PATH'] || '')
const bundledFfmpeg = String(ffmpegStaticPath || '')
.replace('app.asar', 'app.asar.unpacked')
.trim()
if (!bundledFfmpeg || !existsSync(bundledFfmpeg)) return existing
return [dirname(bundledFfmpeg), existing].filter(Boolean).join(delimiter)
}
function buildRuntimePythonPath(runtimeRoot: string): string {
return [join(runtimeRoot, 'python'), String(process.env['PYTHONPATH'] || '')]
.filter(Boolean)
.join(delimiter)
}
function createWavBuffer(pcm: Buffer, sampleRate: number, channels: number): Buffer {
const header = Buffer.alloc(44)
header.write('RIFF', 0)
header.writeUInt32LE(36 + pcm.length, 4)
header.write('WAVE', 8)
header.write('fmt ', 12)
header.writeUInt32LE(16, 16)
header.writeUInt16LE(1, 20)
header.writeUInt16LE(channels, 22)
header.writeUInt32LE(sampleRate, 24)
header.writeUInt32LE(sampleRate * channels * 2, 28)
header.writeUInt16LE(channels * 2, 32)
header.writeUInt16LE(16, 34)
header.write('data', 36)
header.writeUInt32LE(pcm.length, 40)
return Buffer.concat([header, pcm])
}
async function prepareVoiceFile(filePath: string): Promise<Buffer> {
const data = readFileSync(filePath)
if (!data.subarray(0, 10).equals(Buffer.from('\x02#!SILK_V3'))) return data
const decoded = await new SilkAudioDecoder().decode({
data,
codec: 'silk',
sourceHash: createHash('sha256').update(data).digest('hex')
})
return createWavBuffer(decoded.pcm, decoded.sampleRate, decoded.channels)
}
async function readWechatVersion(): Promise<string> {
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
'-c',
'Print :WeChatBundleVersion',
join(WECHAT_APP_PATH, 'Contents', 'Info.plist')
])
return stdout.trim()
}
async function readWechatPid(): Promise<number | undefined> {
try {
const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat'])
const pid = Number(stdout.trim().split(/\s+/)[0])
return Number.isInteger(pid) && pid > 0 ? pid : undefined
} catch {
return undefined
}
}
/** Locate the current account's temporary image directory required by the upstream hook. */
export function findWechatImagePath(
root = WECHAT_FILES_ROOT,
now = new Date()
): string | undefined {
try {
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const candidates: Array<{ path: string; mtime: number }> = []
const fallbacks: Array<{ path: string; mtime: number }> = []
for (const account of readdirSync(root)) {
const tempRoot = join(root, account, 'temp')
if (!existsSync(tempRoot)) continue
fallbacks.push({
path: join(tempRoot, 'ImageTemp', month),
mtime: statSync(tempRoot).mtimeMs
})
const imageTempPath = join(tempRoot, 'ImageTemp', month)
if (existsSync(imageTempPath)) {
candidates.push({ path: imageTempPath, mtime: statSync(imageTempPath).mtimeMs })
}
for (const tempId of readdirSync(tempRoot)) {
const imagePath = join(tempRoot, tempId, month, 'Img')
if (existsSync(imagePath)) {
candidates.push({ path: imagePath, mtime: statSync(imagePath).mtimeMs })
}
}
}
candidates.sort((a, b) => b.mtime - a.mtime)
fallbacks.sort((a, b) => b.mtime - a.mtime)
const selected = candidates[0]?.path || fallbacks[0]?.path
return selected ? `${selected}${sep}` : undefined
} catch {
return undefined
}
}
async function isSipDisabled(): Promise<boolean> {
try {
const { stdout, stderr } = await execFileAsync('/usr/bin/csrutil', ['status'])
return `${stdout}\n${stderr}`.toLowerCase().includes('disabled')
} catch {
return false
}
}
interface OneBotProcessInfo {
pid: number
boundWechatPid?: number
imagePath?: string
command: string
}
async function readOneBotProcessInfo(): Promise<OneBotProcessInfo | undefined> {
try {
const { stdout } = await execFileAsync('/usr/sbin/lsof', [
'-nP',
'-t',
`-iTCP:${DEFAULT_HOST.split(':')[1]}`,
'-sTCP:LISTEN'
])
const pid = Number(stdout.trim().split(/\s+/)[0])
if (!Number.isInteger(pid) || pid <= 0) return undefined
const { stdout: commandOutput } = await execFileAsync('/bin/ps', [
'-p',
String(pid),
'-o',
'command='
])
const command = commandOutput.trim()
if (!/(^|\/)onebot(?:\s|$)/.test(command)) return undefined
const boundWechatPid = Number(command.match(/-wechat_pid=(\d+)/)?.[1]) || undefined
const imagePath = command.match(/-image_path=(\S+)/)?.[1]
return {
pid,
...(boundWechatPid ? { boundWechatPid } : {}),
...(imagePath ? { imagePath } : {}),
command
}
} catch {
return undefined
}
}
async function terminateOneBot(info: OneBotProcessInfo): Promise<void> {
if (!/(^|\/)onebot(?:\s|$)/.test(info.command)) return
try {
process.kill(info.pid, 'SIGTERM')
} catch {
return
}
const startedAt = Date.now()
while (Date.now() - startedAt < STOP_TIMEOUT_MS) {
try {
process.kill(info.pid, 0)
await new Promise((resolve) => setTimeout(resolve, 100))
} catch {
return
}
}
}
async function requestWithTimeout(
url: string,
init?: RequestInit,
timeoutMs = 2_000
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, { ...init, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
export class PersonalWechatSendService {
private child: ChildProcess | null = null
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
private lastError = ''
async getStatus(): Promise<PersonalWechatSenderStatus> {
const preflight = await this.preflight()
const [endpointReady, oneBot] = await Promise.all([
this.isEndpointOnline(),
readOneBotProcessInfo()
])
const hook = this.readHookReadiness(preflight.runtime)
const boundWechatPid = oneBot?.boundWechatPid || hook.boundWechatPid
const boundToCurrentWechat = Boolean(
preflight.status.wechatPid && boundWechatPid === preflight.status.wechatPid
)
const common = {
...preflight.status,
endpointReady,
...(oneBot?.pid ? { oneBotPid: oneBot.pid } : {}),
...(boundWechatPid ? { boundWechatPid } : {}),
attachReady: hook.attached,
...(hook.baseAddress ? { baseAddress: hook.baseAddress } : {}),
baseAddressReady: Boolean(hook.baseAddress),
textHookInstalled: hook.textHookInstalled,
textHookReady: hook.textHookReady,
imageHookInstalled: hook.imageHookInstalled,
imageHookReady: hook.imageHookReady,
messageListenerReady: hook.messageListenerReady
}
if (!endpointReady) return common
if (!boundToCurrentWechat) {
return {
...common,
state: 'hook_not_ready',
canSend: false,
canSendText: false,
canSendImage: false,
message: 'OneBot 仍绑定旧微信进程,请点击“尝试重新绑定”'
}
}
if (hook.readiness === 'failed') {
return {
...common,
state: 'error',
canSend: false,
canSendText: false,
canSendImage: false,
message: '微信发送 Hook 初始化失败,请尝试重新绑定',
...(hook.error ? { error: hook.error } : {})
}
}
const baseReady = hook.attached && Boolean(hook.baseAddress) && hook.textHookInstalled
const canSendText = baseReady && hook.textHookReady
const imagePathBound = Boolean(oneBot?.imagePath)
const canSendImage = baseReady && hook.imageHookReady && imagePathBound
const canSendVoice = baseReady && hook.imageHookReady
return {
...common,
state: canSendText || canSendImage || canSendVoice ? 'online' : 'hook_not_ready',
canSend: canSendText || canSendImage || canSendVoice,
canSendText,
canSendImage,
canSendVoice,
message:
hook.imageHookReady && preflight.status.imagePath && !imagePathBound
? 'OneBot 尚未绑定微信图片目录,请点击“尝试重新绑定”'
: canSendText || canSendImage || canSendVoice
? '个人微信已绑定,可使用已初始化的消息类型'
: '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型',
...(hook.error ? { error: hook.error } : {})
}
}
async send(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
const to = String(request?.to || '').trim()
if (!to) {
const status = await this.getStatus()
return { success: false, status, error: '接收者不能为空' }
}
let fileBase64: string | undefined
if (request.type === 'text') {
const text = String(request.text || '').trim()
if (!text) {
const status = await this.getStatus()
return { success: false, status, error: '文字内容不能为空' }
}
if (text.length > 2_000) {
const status = await this.getStatus()
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.getStatus()
return {
success: false,
status,
error: `请选择有效的${request.type === 'voice' ? '语音' : '图片'}文件`
}
}
const size = statSync(filePath).size
const maxBytes = request.type === 'voice' ? MAX_VOICE_BYTES : MAX_IMAGE_BYTES
if (size <= 0 || size > maxBytes) {
const status = await this.getStatus()
return {
success: false,
status,
error: `测试${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
}
}
fileBase64 = (
request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath)
).toString('base64')
request = { ...request, to, filePath }
}
const status = await this.ensureRunning()
const typeReady =
request.type === 'text'
? status.canSendText
: request.type === 'voice'
? status.canSendVoice
: status.canSendImage
if (!typeReady) {
const guidance =
request.type === 'text'
? '请先在微信中给任意好友手动发送一条文字,再重新检测'
: request.type === 'voice'
? '语音复用媒体上传 Hook,请先在微信中手动发送一张普通图片,再重新检测'
: '请先在微信中给任意好友手动发送一张普通图片,再重新检测'
return { success: false, status, error: status.error || guidance }
}
const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64)
try {
const response = await requestWithTimeout(
`http://${DEFAULT_HOST}${oneBot.endpoint}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(oneBot.body)
},
request.type === 'voice' ? 60_000 : REQUEST_TIMEOUT_MS
)
const responseText = await response.text()
if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`)
const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {}
if (parsed.status && parsed.status !== 'ok') throw new Error(responseText)
return { success: true, status: await this.getStatus() }
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error)
const failedStatus = await this.getStatus()
return {
success: false,
status: { ...failedStatus, state: 'error', error: this.lastError },
error: `发送失败:${this.lastError}`
}
}
}
async rebind(): Promise<PersonalWechatSenderStatus> {
const preflight = await this.preflight()
if (!preflight.runtime || !preflight.status.configPath || !preflight.status.wechatPid) {
return preflight.status
}
const currentStatus = await this.getStatus()
const oneBot = await readOneBotProcessInfo()
if (
oneBot?.boundWechatPid === preflight.status.wechatPid &&
currentStatus.attachReady &&
currentStatus.baseAddressReady &&
currentStatus.textHookInstalled &&
currentStatus.imageHookInstalled &&
(!preflight.status.imagePath || Boolean(oneBot.imagePath)) &&
currentStatus.state !== 'error'
) {
return {
...currentStatus,
message: '当前 OneBot 已绑定此微信进程,无需重复注入'
}
}
if (oneBot) await terminateOneBot(oneBot)
this.child = null
this.startPromise = null
this.lastError = ''
let status = await this.startRuntime()
const retryableHookFailure =
status.state === 'error' &&
/unable to intercept function|cannot find ['"]req2buf|hook 初始化失败/i.test(
`${status.error || ''} ${status.message || ''}`
)
if (!retryableHookFailure) return status
const failedOneBot = await readOneBotProcessInfo()
if (failedOneBot) await terminateOneBot(failedOneBot)
this.child = null
this.lastError = ''
await new Promise((resolve) => setTimeout(resolve, 1_500))
status = await this.startRuntime()
return status
}
stop(): void {
const child = this.child
this.child = null
this.startPromise = null
// A second Attach to the same WeChat process is unstable. Leave OneBot alive;
// it monitors the WeChat PID and exits when that process ends.
if (child && child.exitCode === null) child.unref()
}
async terminate(): Promise<void> {
const oneBot = await readOneBotProcessInfo()
if (oneBot) await terminateOneBot(oneBot)
this.child = null
this.startPromise = null
this.lastError = ''
}
private async ensureRunning(): Promise<PersonalWechatSenderStatus> {
const currentStatus = await this.getStatus()
if (currentStatus.state === 'online' || currentStatus.state === 'hook_not_ready') {
return currentStatus
}
if (this.startPromise) return this.startPromise
this.startPromise = this.startRuntime().finally(() => {
this.startPromise = null
})
return this.startPromise
}
private async startRuntime(): Promise<PersonalWechatSenderStatus> {
const preflight = await this.preflight()
if (!preflight.runtime || !preflight.status.configPath) {
return preflight.status
}
const pid = preflight.status.wechatPid
const imagePath = findWechatImagePath()
this.lastError = ''
const child = spawn(
preflight.runtime.executable,
[
'-type=local',
`-receive_host=${DEFAULT_HOST}`,
`-wechat_conf=${preflight.status.configPath}`,
`-wechat_pid=${pid}`,
...(imagePath ? [`-image_path=${imagePath}`] : []),
'-send_interval=1000',
'-log_level=info'
],
{
cwd: preflight.runtime.workingDirectory,
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
PATH: buildRuntimePath(),
PYTHONPATH: buildRuntimePythonPath(preflight.runtime.root)
}
}
)
this.child = child
child.unref()
child.once('error', (error) => {
this.lastError = error.message
})
child.once('exit', (code) => {
if (this.child === child) this.child = null
if (code && !this.lastError) this.lastError = `发送服务退出(code=${code}`
})
const startedAt = Date.now()
while (Date.now() - startedAt < START_TIMEOUT_MS) {
const status = await this.getStatus()
if (status.state === 'online') return status
if (status.state === 'hook_not_ready' && status.attachReady && status.baseAddressReady) {
return status
}
if (status.state === 'error') return status
if (this.child?.exitCode !== null && this.child?.exitCode !== undefined) break
await new Promise((resolve) => setTimeout(resolve, 300))
}
const status = await this.preflight()
return {
...status.status,
state: 'error',
canSend: false,
message: '个人微信发送服务启动失败',
error: this.lastError || '启动超时,请查看应用日志'
}
}
private async preflight(): Promise<PreflightResult> {
const base = {
platform: process.platform,
arch: process.arch,
sipDisabled: false,
wechatRunning: false,
runtimeReady: false,
endpoint: DEFAULT_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
}
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
return {
status: {
...base,
state: 'unsupported_platform',
message: '个人微信测试发送当前仅支持 Apple Silicon Mac'
}
}
}
const [sipDisabled, wechatPid] = await Promise.all([isSipDisabled(), readWechatPid()])
if (!wechatPid) {
return {
status: {
...base,
sipDisabled,
state: 'wechat_not_running',
message: '请先启动并登录 macOS 微信'
}
}
}
if (!sipDisabled) {
return {
status: {
...base,
wechatRunning: true,
wechatPid,
state: 'sip_enabled',
message: '当前 SIP 未关闭,无法直接连接微信进程'
}
}
}
let wechatVersion = ''
try {
wechatVersion = await readWechatVersion()
} catch (error) {
return {
status: {
...base,
sipDisabled,
wechatRunning: true,
wechatPid,
state: 'error',
message: '无法读取微信精确版本',
error: error instanceof Error ? error.message : String(error)
}
}
}
const runtime = findPersonalWechatRuntime()
if (!runtime) {
return {
status: {
...base,
sipDisabled,
wechatRunning: true,
wechatPid,
wechatVersion,
state: 'runtime_missing',
message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 文字转语音”下载'
}
}
}
const configPath = join(runtime.configDirectory, toConfigFileName(wechatVersion))
if (!existsSync(configPath)) {
return {
runtime,
status: {
...base,
sipDisabled,
wechatRunning: true,
wechatPid,
wechatVersion,
runtimeReady: true,
executablePath: runtime.executable,
state: 'unsupported_version',
message: `当前微信版本 ${wechatVersion} 暂不支持,请前往文字转语音设置查看支持的版本`,
error: `缺少 ${configPath}`
}
}
}
const imagePath = findWechatImagePath()
return {
runtime,
status: {
...base,
sipDisabled,
wechatRunning: true,
wechatPid,
wechatVersion,
runtimeReady: true,
executablePath: runtime.executable,
configPath,
...(imagePath ? { imagePath } : {}),
state: this.child && this.child.exitCode === null ? 'starting' : 'stopped',
message: this.lastError || '尚未绑定当前微信,可点击“尝试重新绑定”'
}
}
}
private async isEndpointOnline(): Promise<boolean> {
try {
const [host, portText] = DEFAULT_HOST.split(':')
const port = Number(portText)
await new Promise<void>((resolve, reject) => {
const socket = createConnection({ host, port })
const timer = setTimeout(() => {
socket.destroy()
reject(new Error('timeout'))
}, 800)
socket.once('connect', () => {
clearTimeout(timer)
socket.destroy()
resolve()
})
socket.once('error', (error) => {
clearTimeout(timer)
socket.destroy()
reject(error)
})
})
return true
} catch {
return false
}
}
private readHookReadiness(
runtime?: RuntimeLayout
): ReturnType<typeof parsePersonalWechatHookLog> {
if (!runtime || !existsSync(runtime.logPath)) {
return {
readiness: 'unknown',
attached: false,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false
}
}
try {
return parsePersonalWechatHookLog(readFileSync(runtime.logPath, 'utf8'))
} catch {
return {
readiness: 'unknown',
attached: false,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false
}
}
}
}
export const personalWechatSendService = new PersonalWechatSendService()
+6 -1
View File
@@ -2,6 +2,7 @@ import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import type { TextToSpeechModel } from '../../shared/text-to-speech'
/**
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
@@ -36,6 +37,8 @@ export interface AppSettings {
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
ttsSelectedVoiceId: string
ttsModel: TextToSpeechModel
}
function getDefaultDbRoot(): string {
@@ -135,7 +138,9 @@ const DEFAULT_SETTINGS: AppSettings = {
autoLoginPreferenceSet: false,
appearanceTheme: 'system',
compactMode: false,
showStartupProgress: true
showStartupProgress: true,
ttsSelectedVoiceId: '',
ttsModel: 's2.1-pro-free'
}
const SETTINGS_FILE = path.join(
@@ -0,0 +1,358 @@
import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import { randomUUID } from 'crypto'
import type {
ListTextToSpeechVoicesRequest,
ListTextToSpeechVoicesResult,
SaveTextToSpeechSettingsRequest,
SynthesizeTextToSpeechRequest,
SynthesizeTextToSpeechResult,
TextToSpeechKeySource,
TextToSpeechModel,
TextToSpeechSettingsResult,
TextToSpeechVoice
} from '../../shared/text-to-speech'
import { AIProviderKeyStore } from '../ai-provider-key-store'
import { loadSettings, updateSettings } from './settings-store'
const FISH_AUDIO_KEY_ID = 'fish-audio-tts'
const FISH_AUDIO_BASE_URL = 'https://api.fish.audio'
const FISH_AUDIO_PUBLIC_ASSET_URL = 'https://public-platform.r2.fish.audio/'
const DEFAULT_PAGE_SIZE = 24
const MAX_PAGE_SIZE = 100
const GENERATED_AUDIO_MAX_AGE_MS = 24 * 60 * 60 * 1000
interface FishAudioModelEntity {
_id: string
type: 'svc' | 'tts'
title: string
description?: string
cover_image?: string
state: 'created' | 'training' | 'trained' | 'failed'
tags?: string[]
languages?: string[]
default_text?: string
samples?: Array<{ title: string; text: string; task_id: string; audio: string }>
task_count?: number
like_count?: number
mark_count?: number
author?: { _id: string; nickname: string; avatar: string }
}
interface FishAudioModelListResponse {
total: number
items: FishAudioModelEntity[]
has_more?: boolean | null
}
export class TextToSpeechSettingsService {
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
get(): TextToSpeechSettingsResult {
const resolved = this.resolveKey()
const settings = loadSettings()
return {
success: resolved.success,
settings: {
provider: 'fish-audio',
hasApiKey: Boolean(resolved.key),
hasStoredApiKey: resolved.hasStoredApiKey,
hasEnvironmentApiKey: resolved.hasEnvironmentApiKey,
keySource: resolved.source,
encryptionAvailable: resolved.encryptionAvailable,
selectedVoiceId: normalizeSelectedVoiceId(settings.ttsSelectedVoiceId),
outputFormat: 'mp3',
model: normalizeModel(settings.ttsModel),
phase: 'ready'
},
voices: [],
error: resolved.error
}
}
save(request: SaveTextToSpeechSettingsRequest): TextToSpeechSettingsResult {
const apiKey = request.apiKey?.trim()
if (request.clearApiKey) {
const cleared = this.keyStore.clear(FISH_AUDIO_KEY_ID)
if (!cleared.success) return { ...this.get(), success: false, error: cleared.error }
} else if (apiKey) {
const saved = this.keyStore.save(FISH_AUDIO_KEY_ID, apiKey)
if (!saved.success) return { ...this.get(), success: false, error: saved.error }
}
const patch: { ttsSelectedVoiceId?: string; ttsModel?: TextToSpeechModel } = {}
if (request.selectedVoiceId !== undefined) {
patch.ttsSelectedVoiceId = request.selectedVoiceId.trim()
}
if (request.model !== undefined) patch.ttsModel = normalizeModel(request.model)
if (Object.keys(patch).length) updateSettings(patch)
return this.get()
}
async listVoices(
request: ListTextToSpeechVoicesRequest = {}
): Promise<ListTextToSpeechVoicesResult> {
const pageNumber = clampInteger(request.pageNumber, 1, Number.MAX_SAFE_INTEGER, 1)
const pageSize = clampInteger(request.pageSize, 1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE)
const resolved = this.resolveKey()
if (!resolved.key) {
return {
success: false,
items: [],
total: 0,
pageNumber,
pageSize,
hasMore: false,
error: resolved.error || '请先配置语音服务 API Key'
}
}
try {
const url = new URL('/model', FISH_AUDIO_BASE_URL)
url.searchParams.set('page_size', String(pageSize))
url.searchParams.set('page_number', String(pageNumber))
url.searchParams.set('sort_by', 'score')
if (request.title?.trim()) url.searchParams.set('title', request.title.trim())
if (request.language?.trim()) url.searchParams.set('language', request.language.trim())
for (const tag of request.tags || []) {
if (tag.trim()) url.searchParams.append('tag', tag.trim())
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${resolved.key}` },
signal: AbortSignal.timeout(30_000)
})
if (!response.ok) throw await fishAudioError(response)
const payload = (await response.json()) as FishAudioModelListResponse
let items = payload.items
.filter((model) => model.type === 'tts' && model.state === 'trained')
.map(toVoice)
const selectedVoiceId = normalizeSelectedVoiceId(loadSettings().ttsSelectedVoiceId)
if (
pageNumber === 1 &&
!request.title?.trim() &&
!(request.tags || []).length &&
selectedVoiceId
) {
if (!items.some((item) => item.id === selectedVoiceId)) {
const selected = await this.getVoice(selectedVoiceId, resolved.key)
if (selected) items = [selected, ...items]
}
}
return {
success: true,
items,
total: payload.total,
pageNumber,
pageSize,
hasMore: payload.has_more ?? pageNumber * pageSize < payload.total
}
} catch (error) {
return {
success: false,
items: [],
total: 0,
pageNumber,
pageSize,
hasMore: false,
error: safeFishAudioError(error)
}
}
}
async synthesize(request: SynthesizeTextToSpeechRequest): Promise<SynthesizeTextToSpeechResult> {
const text = request.text.trim()
const referenceId = request.referenceId.trim()
if (!text) return { success: false, error: '请输入要生成语音的文字' }
if (text.length > 1000) return { success: false, error: '单次生成文字不能超过 1000 个字符' }
if (!referenceId) return { success: false, error: '请先选择音色' }
const resolved = this.resolveKey()
if (!resolved.key) {
return {
success: false,
error: resolved.error || '请先配置语音服务 API Key'
}
}
try {
const response = await fetch(`${FISH_AUDIO_BASE_URL}/v1/tts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${resolved.key}`,
'Content-Type': 'application/json',
model: normalizeModel(loadSettings().ttsModel)
},
body: JSON.stringify({
text,
reference_id: referenceId,
format: 'mp3',
mp3_bitrate: 128,
latency: 'normal',
normalize: true
}),
signal: AbortSignal.timeout(120_000)
})
if (!response.ok) throw await fishAudioError(response)
const audio = Buffer.from(await response.arrayBuffer())
if (audio.length < 128) throw new Error('语音服务返回的音频为空')
const directory = this.generatedAudioDirectory()
fs.ensureDirSync(directory)
this.cleanupGeneratedAudio(directory)
const filePath = path.join(directory, `fish-audio-${Date.now()}-${randomUUID()}.mp3`)
fs.writeFileSync(filePath, audio, { mode: 0o600 })
return {
success: true,
filePath,
audioDataUrl: `data:audio/mpeg;base64,${audio.toString('base64')}`
}
} catch (error) {
return { success: false, error: safeFishAudioError(error) }
}
}
removeGeneratedAudio(filePath: string): { success: boolean; error?: string } {
const directory = this.generatedAudioDirectory()
const resolvedPath = path.resolve(filePath)
if (!resolvedPath.startsWith(`${path.resolve(directory)}${path.sep}`)) {
return { success: false, error: '拒绝删除非 Fish Audio 临时文件' }
}
try {
fs.removeSync(resolvedPath)
return { success: true }
} catch {
return { success: false, error: '临时语音文件清理失败' }
}
}
private resolveKey(): {
success: boolean
key?: string
source: TextToSpeechKeySource
hasStoredApiKey: boolean
hasEnvironmentApiKey: boolean
encryptionAvailable: boolean
error?: string
} {
const stored = this.keyStore.get(FISH_AUDIO_KEY_ID)
const environmentKey = String(process.env.FISH_API_KEY || '').trim()
const storedKey = stored.key?.trim()
const key = storedKey || environmentKey || undefined
return {
success: stored.success || Boolean(environmentKey),
key,
source: storedKey ? 'secure-storage' : environmentKey ? 'environment' : 'missing',
hasStoredApiKey: Boolean(storedKey),
hasEnvironmentApiKey: Boolean(environmentKey),
encryptionAvailable: stored.available,
error: key ? undefined : stored.error
}
}
private async getVoice(id: string, key: string): Promise<TextToSpeechVoice | null> {
try {
const response = await fetch(`${FISH_AUDIO_BASE_URL}/model/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${key}` },
signal: AbortSignal.timeout(15_000)
})
if (!response.ok) return null
const model = (await response.json()) as FishAudioModelEntity
return model.type === 'tts' && model.state === 'trained' ? toVoice(model) : null
} catch {
return null
}
}
private generatedAudioDirectory(): string {
return path.join(app.getPath('temp'), 'wechatexplorer-fish-audio')
}
private cleanupGeneratedAudio(directory: string): void {
try {
const now = Date.now()
for (const name of fs.readdirSync(directory)) {
const candidate = path.join(directory, name)
if (!name.startsWith('fish-audio-') || !name.endsWith('.mp3')) continue
if (now - fs.statSync(candidate).mtimeMs > GENERATED_AUDIO_MAX_AGE_MS)
fs.removeSync(candidate)
}
} catch {
// 临时文件清理失败不应阻断当前语音生成。
}
}
}
function toVoice(model: FishAudioModelEntity): TextToSpeechVoice {
const sample = model.samples?.find((item) => item.audio) || model.samples?.[0]
return {
id: model._id,
name: model.title || '未命名音色',
description: model.description || sample?.text || '公开音色',
tags: Array.isArray(model.tags) ? model.tags : [],
languages: Array.isArray(model.languages) ? model.languages : [],
source: 'fish-audio',
coverImage: resolvePublicAssetUrl(model.cover_image || model.author?.avatar),
previewUrl: sample?.audio || undefined,
previewText: sample?.text || model.default_text || undefined,
authorName: model.author?.nickname || undefined,
taskCount: model.task_count,
likeCount: model.like_count,
markCount: model.mark_count
}
}
function resolvePublicAssetUrl(value?: string): string | undefined {
const normalized = String(value || '').trim()
if (!normalized) return undefined
if (/^https?:\/\//i.test(normalized)) return normalized
if (normalized.startsWith('//')) return `https:${normalized}`
return new URL(normalized.replace(/^\/+/, ''), FISH_AUDIO_PUBLIC_ASSET_URL).toString()
}
function normalizeSelectedVoiceId(value: string): string {
const normalized = String(value || '').trim()
return normalized.startsWith('demo-') ? '' : normalized
}
function normalizeModel(value: unknown): TextToSpeechModel {
return value === 's2.1-pro' ? 's2.1-pro' : 's2.1-pro-free'
}
function clampInteger(value: unknown, min: number, max: number, fallback: number): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return fallback
return Math.min(max, Math.max(min, Math.floor(parsed)))
}
async function fishAudioError(response: Response): Promise<Error> {
let detail = ''
try {
const payload = (await response.json()) as
| { status?: number; message?: string }
| Array<{ msg?: string }>
detail = Array.isArray(payload)
? payload
.map((item) => item.msg)
.filter(Boolean)
.join('')
: payload.message || ''
} catch {
detail = await response.text().catch(() => '')
}
if (response.status === 401) return new Error('API Key 无效或已失效')
if (response.status === 402) return new Error('语音服务余额不足')
if (response.status === 404) return new Error('所选音色不存在或不可访问')
if (response.status === 422) return new Error(detail || '语音生成参数不正确')
if (response.status === 503) return new Error('语音服务暂时不可用,请稍后重试')
return new Error(detail || `语音服务请求失败(HTTP ${response.status}`)
}
function safeFishAudioError(error: unknown): string {
if (error instanceof DOMException && error.name === 'TimeoutError') return '语音服务请求超时'
if (error instanceof Error) return error.message
return '语音服务请求失败'
}
+2 -2
View File
@@ -74,12 +74,12 @@ export class SilkAudioDecoder implements VoiceAudioDecoder {
decode?: (data: Buffer, sampleRate: number) => Promise<{ data: Uint8Array }>
}
if (!silkWasm.decode) throw new Error('silk-wasm 运行时无效')
const result = await silkWasm.decode(source.data, 24000)
const result = await silkWasm.decode(source.data, 16000)
const pcm = Buffer.from(result.data)
if (!pcm.length) throw new Error('Silk 解码结果为空')
return {
pcm,
sampleRate: 24000,
sampleRate: 16000,
channels: 1,
sourceHash: source.sourceHash
}
+5 -1
View File
@@ -62,7 +62,11 @@ export class VoiceService {
if (!pcmResult.success) return pcmResult
const pcmData = pcmResult.audio.pcm
const wavData = this.createWavBuffer(pcmData, 24000)
const wavData = this.createWavBuffer(
pcmData,
pcmResult.audio.sampleRate,
pcmResult.audio.channels
)
console.log(
'[VoiceService] wavData length:',
wavData.length,
+55
View File
@@ -53,6 +53,18 @@ import type {
ImageInsight
} from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type {
PersonalWechatImageSelectionResult,
PersonalWechatVoiceSelectionResult,
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
} from '../shared/personal-wechat'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../shared/personal-wechat-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
import type { CacheSummary } from '../shared/cache'
@@ -86,6 +98,14 @@ import type {
WechatShareServiceConfig,
WechatShareServiceConfigResult
} from '../shared/wechat-share-card'
import type {
ListTextToSpeechVoicesRequest,
ListTextToSpeechVoicesResult,
SaveTextToSpeechSettingsRequest,
SynthesizeTextToSpeechRequest,
SynthesizeTextToSpeechResult,
TextToSpeechSettingsResult
} from '../shared/text-to-speech'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -250,6 +270,20 @@ declare global {
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
testAIVision: (request: AIVisionTestRequest) => Promise<AIVisionTestResult>
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
getTextToSpeechSettings: () => Promise<TextToSpeechSettingsResult>
saveTextToSpeechSettings: (
request: SaveTextToSpeechSettingsRequest
) => Promise<TextToSpeechSettingsResult>
listTextToSpeechVoices: (
request?: ListTextToSpeechVoicesRequest
) => Promise<ListTextToSpeechVoicesResult>
synthesizeTextToSpeech: (
request: SynthesizeTextToSpeechRequest
) => Promise<SynthesizeTextToSpeechResult>
removeGeneratedTextToSpeechAudio: (
filePath: string
) => Promise<{ success: boolean; error?: string }>
openFishAudioApiKeys: () => Promise<{ success: boolean; error?: string }>
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
sessionId: string,
@@ -383,6 +417,8 @@ declare global {
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
ttsSelectedVoiceId: string
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
imageXorKey: string
imageAesKey: string
}
@@ -420,6 +456,8 @@ declare global {
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
ttsSelectedVoiceId: string
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
imageXorKey: string
imageAesKey: string
}
@@ -440,6 +478,8 @@ declare global {
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
ttsSelectedVoiceId: string
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
imageXorKey: string
imageAesKey: string
}>
@@ -533,6 +573,21 @@ declare global {
sessionId: string,
limit?: number
) => Promise<{ success: boolean; insights: ImageInsight[] }>
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
removePersonalWechatRuntime: () => Promise<PersonalWechatRuntimeStatus>
openPersonalWechatRuntimeDirectory: () => Promise<{ success: boolean; error?: string }>
onPersonalWechatRuntimeProgress: (
callback: (status: PersonalWechatRuntimeProgressEvent) => void
) => () => void
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
selectPersonalWechatImage: () => Promise<PersonalWechatImageSelectionResult>
selectPersonalWechatVoice: () => Promise<PersonalWechatVoiceSelectionResult>
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
) => Promise<PersonalWechatSendResult>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
clearAgentHubLogs: () => Promise<void>
+58
View File
@@ -25,6 +25,18 @@ import type {
ImageInsight
} from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type {
PersonalWechatImageSelectionResult,
PersonalWechatVoiceSelectionResult,
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
} from '../shared/personal-wechat'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../shared/personal-wechat-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { CacheSummary } from '../shared/cache'
@@ -58,6 +70,11 @@ import type {
PublishWechatShareCardRequest,
WechatShareServiceConfig
} from '../shared/wechat-share-card'
import type {
ListTextToSpeechVoicesRequest,
SaveTextToSpeechSettingsRequest,
SynthesizeTextToSpeechRequest
} from '../shared/text-to-speech'
// 渲染器的自定义 API
const api = {
@@ -138,6 +155,16 @@ const api = {
testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId),
testAIVision: (request: AIVisionTestRequest) => ipcRenderer.invoke('ai:testVision', request),
migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config),
getTextToSpeechSettings: () => ipcRenderer.invoke('tts:getSettings'),
saveTextToSpeechSettings: (request: SaveTextToSpeechSettingsRequest) =>
ipcRenderer.invoke('tts:saveSettings', request),
listTextToSpeechVoices: (request?: ListTextToSpeechVoicesRequest) =>
ipcRenderer.invoke('tts:listVoices', request),
synthesizeTextToSpeech: (request: SynthesizeTextToSpeechRequest) =>
ipcRenderer.invoke('tts:synthesize', request),
removeGeneratedTextToSpeechAudio: (filePath: string) =>
ipcRenderer.invoke('tts:removeGeneratedAudio', filePath),
openFishAudioApiKeys: () => ipcRenderer.invoke('tts:openApiKeys'),
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
@@ -322,6 +349,37 @@ const api = {
limit?: number
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
ipcRenderer.invoke('image:listInsights', sessionId, limit),
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:getStatus'),
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
ipcRenderer.invoke('wechat-personal:downloadRuntime'),
cancelPersonalWechatRuntimeDownload: (): Promise<{ success: boolean }> =>
ipcRenderer.invoke('wechat-personal:cancelRuntimeDownload'),
removePersonalWechatRuntime: (): Promise<PersonalWechatRuntimeStatus> =>
ipcRenderer.invoke('wechat-personal:removeRuntime'),
openPersonalWechatRuntimeDirectory: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('wechat-personal:openRuntimeDirectory'),
onPersonalWechatRuntimeProgress: (
callback: (status: PersonalWechatRuntimeProgressEvent) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
status: PersonalWechatRuntimeProgressEvent
): void => callback(status)
ipcRenderer.on('wechat-personal:runtimeProgress', listener)
return () => ipcRenderer.removeListener('wechat-personal:runtimeProgress', listener)
},
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:rebind'),
selectPersonalWechatImage: (): Promise<PersonalWechatImageSelectionResult> =>
ipcRenderer.invoke('wechat-personal:selectImage'),
selectPersonalWechatVoice: (): Promise<PersonalWechatVoiceSelectionResult> =>
ipcRenderer.invoke('wechat-personal:selectVoice'),
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
+1 -1
View File
@@ -7,7 +7,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' https: blob: data: wxe-media:;"
/>
</head>
+12 -1
View File
@@ -37,6 +37,7 @@ import {
import { enrichQuotedMessages } from './utils/quoted-messages'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
import { runtimePlatform } from './utils/runtime-environment'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -1470,6 +1471,11 @@ function App(): React.ReactElement {
setActivePage('settings')
}
const openTextToSpeechSettings = (): void => {
setSettingsCategory('text-to-speech')
setActivePage('settings')
}
const dismissFirstUseWelcome = (): void => {
try {
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
@@ -1619,6 +1625,9 @@ function App(): React.ReactElement {
])
const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
const selectedReportContact = selectedReport
? contacts.find((contact) => contact.md5 === selectedReport.contactId) || null
: null
const openReportResult = (): void => {
if (isSavingGeneratedReport) {
@@ -1727,6 +1736,7 @@ function App(): React.ReactElement {
onReloadAvatars={handleReloadCurrentAvatars}
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
onOpenTextToSpeechSettings={openTextToSpeechSettings}
isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime}
/>
@@ -1755,6 +1765,7 @@ function App(): React.ReactElement {
onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport}
onSwitchTemplate={handleSwitchReportTemplate}
sendTarget={selectedReportContact}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div>
@@ -2001,7 +2012,7 @@ function App(): React.ReactElement {
if (!isAuthenticated) {
return (
<DatabaseConnectionPage
platform={window.electron.process.platform}
platform={runtimePlatform}
mode={databaseConnectionMode}
dbKey={dbKey}
dbRoot={dbRootInput}
@@ -5,6 +5,7 @@ import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState'
import { MessageList } from './chat/MessageList'
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
interface ChatWindowProps {
contact: Contact | null
@@ -18,6 +19,7 @@ interface ChatWindowProps {
onReloadAvatars?: () => Promise<void>
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
onOpenTextToSpeechSettings?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
}
@@ -34,6 +36,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
onOpenTextToSpeechSettings,
isAiLoading = false,
jumpToTime
}) => {
@@ -53,6 +56,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const [showAvatar, setShowAvatar] = useState(true)
const [isAtLatest, setIsAtLatest] = useState(true)
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
const [sendDialogOpen, setSendDialogOpen] = useState(false)
const previousScrollTopRef = useRef(0)
const scrollToBottom = useCallback((): void => {
@@ -199,6 +203,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
onTestSend={() => setSendDialogOpen(true)}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
/>
<DataTrustBar messageCount={messages.length} />
@@ -227,6 +232,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onJumpToLatest={scrollToBottom}
/>
{sendDialogOpen && (
<PersonalWechatSendDialog
contact={contact}
isGroupChat={isGroupChat}
onClose={() => setSendDialogOpen(false)}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
)}
{previewImage && (
<div className="image-viewer-overlay" onClick={closeImagePreview}>
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
@@ -1,6 +1,7 @@
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
import React, { useEffect, useState } from 'react'
import { isWindows } from '../utils/runtime-environment'
interface SelfInfo {
wxid: string
@@ -66,7 +67,6 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
onSaveAiModelConfig,
onDbRootChanged
}) => {
const isWindows = window.electron.process.platform === 'win32'
const dbRootPlaceholder = isWindows
? 'C:\\Users\\你\\Documents\\WeChat Files'
: '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
@@ -1,7 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'
import { Contact } from '../../../../shared/types'
import { ConversationContentSearch } from './ConversationContentSearch'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
interface ChatHeaderProps {
contact: Contact
@@ -13,6 +14,7 @@ interface ChatHeaderProps {
onContentFilterChange: (value: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onTestSend: () => void
onOpenAiSettings: () => void
}
@@ -26,6 +28,7 @@ export function ChatHeader({
onContentFilterChange,
onRefresh,
onRefreshData,
onTestSend,
onOpenAiSettings
}: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
@@ -111,6 +114,22 @@ export function ChatHeader({
</div>
)}
</div>
<span
className="chat-tool-button-wrapper"
title={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
aria-label={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
tabIndex={supportsPersonalWechatSend ? -1 : 0}
>
<button
type="button"
className="chat-tool-button"
onClick={onTestSend}
disabled={!supportsPersonalWechatSend}
>
<SendIcon />
<span></span>
</button>
</span>
<button
type="button"
className="chat-ai-button"
@@ -0,0 +1,682 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Contact } from '../../../../shared/types'
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
type SendMode = 'image' | 'voice'
type VoiceSource = 'generated' | 'file'
type SelectedLocalFile = { path: string; name: string }
interface PersonalWechatSendDialogProps {
contact: Contact
isGroupChat: boolean
onClose: () => void
onOpenTextToSpeechSettings?: () => void
initialMode?: SendMode
initialImage?: SelectedLocalFile | null
}
type GeneratedVoice = { filePath: string; audioDataUrl: string }
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
function formatAudioTime(value: number): string {
if (!Number.isFinite(value) || value < 0) return '0:00'
const seconds = Math.floor(value)
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function statusLabel(status: PersonalWechatSenderStatus | null): string {
if (!status) return '正在检测'
if (status.state === 'online') return '个人微信已绑定'
if (status.state === 'stopped') return '尚未绑定'
if (status.state === 'runtime_missing') return '运行时未安装'
if (status.state === 'hook_not_ready') return '已绑定,等待消息初始化'
if (status.state === 'unsupported_version') return '微信版本不匹配'
if (status.state === 'wechat_not_running') return '微信未运行'
if (status.state === 'sip_enabled') return 'SIP 未关闭'
if (status.state === 'unsupported_platform') return '当前平台不支持'
if (status.state === 'starting') return '正在连接'
if (status.state === 'rebinding') return '正在重新绑定'
if (status.state === 'error') return '绑定异常'
return '正在检测'
}
function readyText(ready: boolean, readyLabel = '正常', waitingLabel = '等待初始化'): string {
return ready ? readyLabel : waitingLabel
}
function statusDescription(status: PersonalWechatSenderStatus | null): string {
if (!status) return '正在检查微信版本、进程和 OneBot Hook…'
if (status.state === 'unsupported_version') {
return `当前微信版本 ${status.wechatVersion || '未知'} 暂不支持。请在设置中查看支持的微信版本,安装完全一致的版本后再试。`
}
if (status.state === 'runtime_missing') {
return '微信发送组件尚未安装。请前往文字转语音设置下载组件后再试。'
}
return status.message
}
export function PersonalWechatSendDialog({
contact,
isGroupChat,
onClose,
onOpenTextToSpeechSettings,
initialMode = 'image',
initialImage = null
}: PersonalWechatSendDialogProps): React.ReactElement {
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [mode, setMode] = useState<SendMode>(initialMode)
const [image, setImage] = useState<SelectedLocalFile | null>(initialImage)
const [voice, setVoice] = useState<SelectedLocalFile | null>(null)
const [voiceSource, setVoiceSource] = useState<VoiceSource>('generated')
const [voiceText, setVoiceText] = useState('1')
const [ttsSettings, setTtsSettings] = useState<TextToSpeechSettings | null>(null)
const [ttsVoices, setTtsVoices] = useState<TextToSpeechVoice[]>([])
const [generatedVoice, setGeneratedVoice] = useState<GeneratedVoice | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
const [previewCurrentTime, setPreviewCurrentTime] = useState(0)
const [previewDuration, setPreviewDuration] = useState(0)
const [isSending, setIsSending] = useState(false)
const [isRebinding, setIsRebinding] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName
const selectedTypeReady = mode === 'voice' ? status?.canSendVoice : status?.canSendImage
const hasContent =
mode === 'voice'
? voiceSource === 'file'
? Boolean(voice?.path)
: Boolean(voiceText.trim())
: Boolean(image?.path)
const isBusy = isSending || isGenerating || isRebinding
const generatedVoiceReady = Boolean(
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
)
const canSubmit = Boolean(
selectedTypeReady && hasContent && !isBusy && (mode !== 'voice' || voiceSource !== 'generated')
)
const canGenerate = Boolean(generatedVoiceReady && !isBusy)
const canSendGenerated = Boolean(status?.canSendVoice && generatedVoice && !isBusy)
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
const statusTone = useMemo(() => {
if (status?.attachReady && status.baseAddressReady) return 'ready'
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
return 'blocked'
}, [status])
const clearGeneratedVoice = useCallback((removeFile = true): void => {
const current = generatedVoiceRef.current
generatedVoiceRef.current = null
generatedAudioRef.current?.pause()
setGeneratedVoice(null)
setIsPreviewPlaying(false)
setPreviewCurrentTime(0)
setPreviewDuration(0)
if (removeFile && current?.filePath) {
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
}
}, [])
const handleClose = useCallback((): void => {
if (isBusy) return
clearGeneratedVoice()
onClose()
}, [clearGeneratedVoice, isBusy, onClose])
const handleOpenTextToSpeechSettings = (showSupportedVersions = false): void => {
if (!onOpenTextToSpeechSettings || isBusy) return
if (showSupportedVersions) {
try {
sessionStorage.setItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY, '1')
} catch {
// The settings page can still open if session storage is unavailable.
}
}
clearGeneratedVoice()
onClose()
onOpenTextToSpeechSettings()
}
const refreshStatus = useCallback(async (): Promise<void> => {
setResult(null)
try {
setStatus(await window.api.getPersonalWechatSenderStatus())
} catch (error) {
setStatus({
state: 'error',
platform: 'unknown',
arch: 'unknown',
sipDisabled: false,
wechatRunning: false,
runtimeReady: false,
endpoint: '127.0.0.1:58080',
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: '无法检测个人微信发送服务',
error: error instanceof Error ? error.message : String(error)
})
}
}, [])
useEffect(() => {
void refreshStatus()
}, [refreshStatus])
useEffect(() => {
let active = true
void window.api
.getTextToSpeechSettings()
.then(async (response) => {
if (!active) return
setTtsSettings(response.settings)
if (!response.settings.hasApiKey) return
const voicesResponse = await window.api.listTextToSpeechVoices({
pageNumber: 1,
pageSize: 24
})
if (!active || !voicesResponse.success) return
setTtsVoices(voicesResponse.items)
})
.catch(() => undefined)
return () => {
active = false
}
}, [])
useEffect(() => {
return () => {
const current = generatedVoiceRef.current
generatedVoiceRef.current = null
if (current?.filePath) {
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
}
}
}, [])
useEffect(() => {
const audio = generatedAudioRef.current
return () => audio?.pause()
}, [generatedVoice])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !isBusy) handleClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleClose, isBusy])
const handleRebind = async (): Promise<void> => {
if (isBusy) return
setIsRebinding(true)
setResult(null)
try {
const nextStatus = await window.api.rebindPersonalWechatSender()
setStatus(nextStatus)
setResult({
success: nextStatus.attachReady && nextStatus.baseAddressReady,
message: nextStatus.message
})
} catch (error) {
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
} finally {
setIsRebinding(false)
}
}
const handleSelectImage = async (): Promise<void> => {
if (isBusy) return
const selection = await window.api.selectPersonalWechatImage()
if (!selection.canceled && selection.path) {
setImage({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
setResult(null)
}
}
const handleSelectVoice = async (): Promise<void> => {
if (isBusy) return
const selection = await window.api.selectPersonalWechatVoice()
if (!selection.canceled && selection.path) {
setVoice({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
setResult(null)
}
}
const changeMode = (nextMode: SendMode): void => {
if (mode === 'voice' && voiceSource === 'generated' && nextMode !== 'voice') {
clearGeneratedVoice()
}
setMode(nextMode)
setResult(null)
}
const changeVoiceSource = (nextSource: VoiceSource): void => {
if (voiceSource === 'generated' && nextSource !== 'generated') clearGeneratedVoice()
setVoiceSource(nextSource)
setResult(null)
}
const changeVoiceText = (nextText: string): void => {
if (generatedVoiceRef.current) clearGeneratedVoice()
setVoiceText(nextText)
setResult(null)
}
const handleGenerateVoice = async (): Promise<void> => {
if (!canGenerate) return
clearGeneratedVoice()
setIsGenerating(true)
setResult(null)
try {
const generated = await window.api.synthesizeTextToSpeech({
text: voiceText.trim(),
referenceId: ttsSettings!.selectedVoiceId
})
if (!generated.success || !generated.filePath || !generated.audioDataUrl) {
setResult({ success: false, message: generated.error || '语音生成失败' })
return
}
const nextVoice = {
filePath: generated.filePath,
audioDataUrl: generated.audioDataUrl
}
generatedVoiceRef.current = nextVoice
setGeneratedVoice(nextVoice)
setResult({ success: true, message: '语音已生成,可以先试听,确认后再发送' })
} catch (error) {
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
} finally {
setIsGenerating(false)
}
}
const handleToggleGeneratedPreview = async (): Promise<void> => {
const audio = generatedAudioRef.current
if (!audio) return
if (!audio.paused) {
audio.pause()
setIsPreviewPlaying(false)
return
}
try {
await audio.play()
setIsPreviewPlaying(true)
} catch {
setResult({ success: false, message: '语音试听播放失败' })
}
}
const handleSendGeneratedVoice = async (): Promise<void> => {
const current = generatedVoiceRef.current
if (!canSendGenerated || !current) return
setIsSending(true)
setResult(null)
generatedAudioRef.current?.pause()
setIsPreviewPlaying(false)
try {
const response = await window.api.sendPersonalWechatMessage({
type: 'voice',
to: targetId,
filePath: current.filePath,
isGroup: isGroupChat
})
setStatus(response.status)
setResult({
success: response.success,
message: response.success
? '语音已提交给微信;请从另一设备或群成员处确认实际送达'
: response.error || '发送失败'
})
if (response.success) clearGeneratedVoice()
} catch (error) {
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
} finally {
setIsSending(false)
}
}
const handleSend = async (): Promise<void> => {
if (!canSubmit || (mode === 'voice' && voiceSource === 'generated')) return
setIsSending(true)
setResult(null)
try {
const response = await window.api.sendPersonalWechatMessage(
mode === 'voice'
? { type: 'voice', to: targetId, filePath: voice!.path, isGroup: isGroupChat }
: { type: 'image', to: targetId, filePath: image!.path, isGroup: isGroupChat }
)
setStatus(response.status)
setResult({
success: response.success,
message: response.success
? `${mode === 'voice' ? '语音' : '图片'}已提交给微信;请从另一设备或群成员处确认实际送达`
: response.error || '发送失败'
})
} catch (error) {
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
} finally {
setIsSending(false)
}
}
const statusItems = [
['微信进程', status?.wechatPid ? `PID ${status.wechatPid}` : '未检测到'],
[
'OneBot',
status?.oneBotPid
? `PID ${status.oneBotPid}${status.boundWechatPid ? ` · 绑定 ${status.boundWechatPid}` : ''}`
: '未启动'
],
[
'接口',
`${status?.endpoint || '127.0.0.1:58080'} · ${readyText(Boolean(status?.endpointReady), '监听中', '未监听')}`
],
['基址扫描', status?.baseAddress || readyText(Boolean(status?.baseAddressReady))],
['图片 Hook', readyText(Boolean(status?.imageHookReady), '已捕获,可发送', '等待手动发图片')],
['语音能力', readyText(Boolean(status?.canSendVoice), '可发送', '等待媒体 Hook 初始化')],
['消息监听', readyText(Boolean(status?.messageListenerReady), '正常', '等待收到微信消息')]
]
return (
<div className="personal-wechat-send-backdrop" role="presentation" onMouseDown={handleClose}>
<section
className="personal-wechat-send-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="personal-wechat-send-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<span className="personal-wechat-send-kicker"></span>
<h2 id="personal-wechat-send-title"></h2>
</div>
<button type="button" className="personal-wechat-send-close" onClick={handleClose}>
×
</button>
</header>
<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?.wechatVersion && <small>{status.wechatVersion}</small>}
{status?.error &&
status.state !== 'unsupported_version' &&
status.state !== 'runtime_missing' && <small className="error">{status.error}</small>}
</div>
<div className="personal-wechat-send-status-actions">
{(status?.state === 'unsupported_version' || status?.state === 'runtime_missing') &&
onOpenTextToSpeechSettings ? (
<button
type="button"
onClick={() =>
handleOpenTextToSpeechSettings(status.state === 'unsupported_version')
}
disabled={isBusy}
>
{status.state === 'runtime_missing' ? '前往下载组件' : '查看支持版本'}
</button>
) : null}
<button type="button" onClick={() => void refreshStatus()} disabled={isBusy}>
</button>
<button type="button" onClick={() => void handleRebind()} disabled={isBusy}>
{isRebinding ? '绑定中…' : '尝试重新绑定'}
</button>
</div>
</div>
<dl className="personal-wechat-send-diagnostics">
{statusItems.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="personal-wechat-send-mode" role="radiogroup" aria-label="测试消息类型">
<button
type="button"
role="radio"
aria-checked={mode === 'image'}
className={mode === 'image' ? 'active' : ''}
onClick={() => changeMode('image')}
disabled={isBusy}
>
</button>
<button
type="button"
role="radio"
aria-checked={mode === 'voice'}
className={mode === 'voice' ? 'active' : ''}
onClick={() => changeMode('voice')}
disabled={isBusy}
>
</button>
</div>
{mode === 'image' ? (
<div className="personal-wechat-send-image-picker">
<span></span>
<button type="button" onClick={() => void handleSelectImage()} disabled={isBusy}>
{image ? '重新选择图片' : '选择图片'}
</button>
{image ? (
<div>
<strong>{image.name}</strong>
<small>{image.path}</small>
</div>
) : (
<small> PNGJPGGIF WebP 20 MB</small>
)}
</div>
) : (
<div className="personal-wechat-voice-composer">
<div className="personal-wechat-voice-source" role="radiogroup" aria-label="语音来源">
<button
type="button"
role="radio"
aria-checked={voiceSource === 'generated'}
className={voiceSource === 'generated' ? 'active' : ''}
onClick={() => changeVoiceSource('generated')}
disabled={isBusy}
>
</button>
<button
type="button"
role="radio"
aria-checked={voiceSource === 'file'}
className={voiceSource === 'file' ? 'active' : ''}
onClick={() => changeVoiceSource('file')}
disabled={isBusy}
>
</button>
</div>
{voiceSource === 'generated' ? (
<div className="personal-wechat-generated-voice">
<div className="personal-wechat-generated-voice-heading">
<div>
<span></span>
<strong>{selectedTtsVoice?.name || '尚未选择音色'}</strong>
</div>
{onOpenTextToSpeechSettings ? (
<button type="button" onClick={() => handleOpenTextToSpeechSettings()}>
</button>
) : null}
</div>
<label className="personal-wechat-send-editor">
<span></span>
<textarea
aria-label="要生成的文字"
value={voiceText}
maxLength={1000}
rows={3}
disabled={isBusy}
onChange={(event) => changeVoiceText(event.target.value)}
/>
<small>{voiceText.length} / 1000</small>
</label>
<div
className={`personal-wechat-tts-readiness ${ttsSettings?.hasApiKey ? 'ready' : ''}`}
>
<strong>
{generatedVoiceReady
? 'API Key 与音色已准备'
: ttsSettings?.hasApiKey
? '还需要在设置中选择音色'
: '还需要配置语音服务 API Key'}
</strong>
<span></span>
</div>
{isGenerating ? (
<div className="personal-wechat-generation-progress" aria-live="polite">
<div>
<strong></strong>
<span></span>
</div>
<div className="personal-wechat-generation-track">
<span />
</div>
</div>
) : 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 className="personal-wechat-generated-result-copy">
<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
type="button"
onClick={() => void handleToggleGeneratedPreview()}
disabled={isSending}
>
{isPreviewPlaying ? '暂停' : '播放'}
</button>
<button
type="button"
className="primary"
onClick={() => void handleSendGeneratedVoice()}
disabled={!canSendGenerated}
>
{isSending ? '正在发送…' : `发送到${isGroupChat ? '群聊' : '联系人'}`}
</button>
</div>
</div>
) : null}
</div>
) : (
<div className="personal-wechat-send-image-picker">
<span></span>
<button type="button" onClick={() => void handleSelectVoice()} disabled={isBusy}>
{voice ? '重新选择语音' : '选择语音'}
</button>
{voice ? (
<div>
<strong>{voice.name}</strong>
<small>{voice.path}</small>
</div>
) : (
<small> SILKMP3WAVM4AAACOGG FLAC 20 MB</small>
)}
</div>
)}
</div>
)}
<p className="personal-wechat-send-note">
{mode === 'voice' && voiceSource === 'generated'
? '文字生成语音会复用现有媒体上传能力;请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
: mode === 'voice'
? '本地语音复用媒体上传 Hook,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
: '如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'}
PID
</p>
{result && (
<div className={`personal-wechat-send-result ${result.success ? 'success' : 'error'}`}>
{result.message}
</div>
)}
<footer>
<button type="button" className="secondary" onClick={handleClose} disabled={isBusy}>
</button>
{mode === 'voice' && voiceSource === 'generated' ? (
<button
type="button"
className="primary"
disabled={!canGenerate}
onClick={() => void handleGenerateVoice()}
>
{isGenerating ? '正在生成语音…' : generatedVoice ? '重新生成语音' : '生成语音'}
</button>
) : (
<button type="button" className="primary" disabled={!canSubmit} onClick={handleSend}>
{isSending
? '正在发送…'
: `测试发送${mode === 'voice' ? '语音' : '图片'}${isGroupChat ? '群聊' : '联系人'}`}
</button>
)}
</footer>
</section>
</div>
)
}
@@ -53,6 +53,15 @@ export function AiIcon({ className }: IconProps): React.ReactElement {
)
}
export function SendIcon({ className }: IconProps): React.ReactElement {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="m4 5 16 7-16 7 3-7-3-7Z" />
<path d="M7 12h13" />
</svg>
)
}
export function CloseIcon({ className }: IconProps): React.ReactElement {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -9,6 +9,8 @@ interface ReportToolbarProps {
canReveal: boolean
canShare: boolean
canSwitchTemplate: boolean
canSendToGroup?: boolean
sendToGroupHint?: string
currentTemplateId?: SelectableReportTemplateId
isSwitchingTemplate: boolean
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
@@ -16,6 +18,7 @@ interface ReportToolbarProps {
onCopyImage: () => void
onReveal: () => void
onShare: () => void
onSendToGroup?: () => void
}
export function ReportToolbar({
@@ -23,13 +26,16 @@ export function ReportToolbar({
canReveal,
canShare,
canSwitchTemplate,
canSendToGroup = false,
sendToGroupHint = '当前报告暂时无法发送',
currentTemplateId,
isSwitchingTemplate,
onSwitchTemplate,
onRegenerate,
onCopyImage,
onReveal,
onShare
onShare,
onSendToGroup
}: ReportToolbarProps): React.ReactElement {
const [moreOpen, setMoreOpen] = useState(false)
const [templateOpen, setTemplateOpen] = useState(false)
@@ -89,6 +95,16 @@ export function ReportToolbar({
<button type="button" disabled={!canCopyImage} onClick={onCopyImage}>
</button>
<span
className="report-toolbar-button-hint"
title={sendToGroupHint}
aria-label={sendToGroupHint}
tabIndex={canSendToGroup ? -1 : 0}
>
<button type="button" disabled={!canSendToGroup} onClick={() => onSendToGroup?.()}>
</button>
</span>
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
</button>
@@ -4,7 +4,10 @@ import { ReportEmptyState } from './ReportEmptyState'
import { ReportToolbar } from './ReportToolbar'
import { ReportZoomBar } from './ReportZoomBar'
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
import type { Contact } from '../../../../shared/types'
import { WechatShareCardDialog } from './WechatShareCardDialog'
import { PersonalWechatSendDialog } from '../chat/PersonalWechatSendDialog'
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
interface ReportViewerProps {
report: GeneratedReportRecord | null
@@ -17,6 +20,8 @@ interface ReportViewerProps {
report: GeneratedReportRecord,
templateId: SelectableReportTemplateId
) => Promise<{ success: boolean; error?: string }>
sendTarget?: Contact | null
personalWechatSendSupported?: boolean
}
const calculateFitZoom = (
@@ -43,7 +48,9 @@ export function ReportViewer({
onRegenerate,
onCopyImage,
onReveal,
onSwitchTemplate
onSwitchTemplate,
sendTarget = null,
personalWechatSendSupported = supportsPersonalWechatSend
}: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1)
const [fitZoom, setFitZoom] = useState(1)
@@ -51,6 +58,7 @@ export function ReportViewer({
const [imageError, setImageError] = useState('')
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
const [shareDialogOpen, setShareDialogOpen] = useState(false)
const [sendDialogOpen, setSendDialogOpen] = useState(false)
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
const viewportRef = useRef<HTMLDivElement>(null)
@@ -60,6 +68,7 @@ export function ReportViewer({
setImageError('')
setIsSwitchingTemplate(false)
setShareDialogOpen(false)
setSendDialogOpen(false)
setZoom(1)
setFitZoom(1)
setNaturalSize(null)
@@ -68,6 +77,14 @@ export function ReportViewer({
}, [report?.id])
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
const sendToGroupHint = !personalWechatSendSupported
? '仅支持 macOS'
: !sendTarget
? '未找到这份日报对应的群聊'
: !report?.pngPath
? '当前报告缺少可发送的 PNG 文件'
: '打开确认窗口,将日报图片发送到当前群聊'
const canSendToGroup = Boolean(personalWechatSendSupported && sendTarget && report?.pngPath)
const measureFitZoom = (): number | null => {
const viewport = viewportRef.current
@@ -169,6 +186,8 @@ export function ReportViewer({
canCopyImage={Boolean(report.generatedImage)}
canReveal={Boolean(report.pngPath || report.htmlPath)}
canShare={Boolean(report.pngPath)}
canSendToGroup={canSendToGroup}
sendToGroupHint={sendToGroupHint}
canSwitchTemplate={Boolean(
(report.reportSnapshot && report.reportMetadata) ||
report.reportRenderSnapshot ||
@@ -181,6 +200,7 @@ export function ReportViewer({
onCopyImage={() => void handleCopy()}
onReveal={() => void handleReveal()}
onShare={() => setShareDialogOpen(true)}
onSendToGroup={() => setSendDialogOpen(true)}
/>
</header>
{status && <div className="report-viewer-status">{status}</div>}
@@ -246,6 +266,18 @@ export function ReportViewer({
onClose={() => setShareDialogOpen(false)}
/>
)}
{sendDialogOpen && report.pngPath && sendTarget && (
<PersonalWechatSendDialog
contact={sendTarget}
isGroupChat
initialMode="image"
initialImage={{
path: report.pngPath,
name: report.pngPath.split(/[\\/]/).pop() || '群聊日报.png'
}}
onClose={() => setSendDialogOpen(false)}
/>
)}
</main>
)
}
@@ -12,6 +12,7 @@ import { CacheCleanupPage } from './pages/CacheCleanupPage'
import { AppearancePage } from './pages/AppearancePage'
import { AboutPage } from './pages/AboutPage'
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
import { TextToSpeechPage } from './pages/TextToSpeechPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
@@ -91,6 +92,8 @@ export function SettingsWorkspace({
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
case 'voice-recognition':
return <VoiceRecognitionPage onNotice={onNotice} />
case 'text-to-speech':
return <TextToSpeechPage onNotice={onNotice} />
case 'recall-protection':
return <RecallProtectionPage onNotice={onNotice} />
case 'advanced':
@@ -1,4 +1,5 @@
import type { DatabaseKeyState } from './types'
import { runtimePlatform } from '../../../utils/runtime-environment'
const PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成']
@@ -14,7 +15,7 @@ export function DatabaseKeyAutoDetect({
onRefresh: () => void
}): React.ReactElement {
const environment = state.environment
const platform = environment?.platform || window.electron.process.platform
const platform = environment?.platform || runtimePlatform
if (platform !== 'win32') {
return (
<section className="settings-card database-key-auto database-key-auto-manual">
@@ -18,6 +18,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
label: '智能能力',
items: [
{ id: 'voice-recognition', label: '语音转文字' },
{ id: 'text-to-speech', label: '文字转语音' },
{ id: 'ai-model', label: 'AI 模型' }
]
},
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
| 'database-key'
| 'image-key'
| 'voice-recognition'
| 'text-to-speech'
| 'ai-model'
| 'recall-protection'
| 'local-api'
@@ -0,0 +1,931 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
TextToSpeechModel,
TextToSpeechSettings,
TextToSpeechVoice
} from '../../../../../shared/text-to-speech'
import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime'
import { isMac, isWindows } from '../../../utils/runtime-environment'
const VOICE_PAGE_SIZE = 24
const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
const BUNDLED_WECHAT_VERSIONS = [
'4.1.6.12',
'4.1.6.46',
'4.1.6.47',
'4.1.7.31',
'4.1.7.55',
'4.1.7.57',
'4.1.8.28',
'4.1.8.29',
'4.1.8.104',
'4.1.8.107',
'4.1.9.52',
'4.1.9.55',
'4.1.9.58',
'4.1.10.53',
'4.1.11.53'
] as const
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
missing: '未下载',
downloading: '下载中',
ready: '已就绪',
invalid: '需要修复',
error: '下载失败',
unsupported: '暂不支持'
}
const VOICE_FILTERS = [
{ value: 'male', label: '男性' },
{ value: 'female', label: '女性' },
{ value: 'neutral', label: '中性' },
{ value: 'young', label: '年轻' },
{ value: 'middle-aged', label: '中年' },
{ value: 'narration', label: '旁白' },
{ value: 'social-media', label: '社交媒体' },
{ value: 'sexy', label: '性感' },
{ value: 'documentary', label: '纪录片' },
{ value: 'deep', label: '深沉' },
{ value: 'soft', label: '柔和' },
{ value: 'dramatic', label: '戏剧感' },
{ value: 'mysterious', label: '神秘' },
{ value: 'anime', label: '动漫' }
] as const
const VOICE_FILTER_GROUPS = [
['male', 'female', 'neutral'],
['young', 'middle-aged']
] as const
const VOICE_TAG_LABELS: Record<string, string> = {
zh: '中文',
Chinese: '中文',
en: '英语',
English: '英语',
male: '男性',
female: '女性',
neutral: '中性',
young: '年轻',
'middle-aged': '中年',
old: '年长',
conversational: '对话',
narration: '旁白',
'character-voice': '角色声音',
'social-media': '社交媒体',
educational: '教育',
advertisement: '广告',
entertainment: '娱乐',
deep: '深沉',
low: '低沉',
medium: '中等',
high: '高亢',
soft: '柔和',
bright: '明亮',
warm: '温暖',
dark: '暗沉',
raspy: '沙哑',
smooth: '顺滑',
breathy: '气声',
husky: '烟嗓',
energetic: '有活力',
calm: '沉稳',
relaxed: '放松',
fast: '快速',
slow: '缓慢',
measured: '从容',
dynamic: '动态',
sexy: '性感',
friendly: '亲切',
professional: '专业',
serious: '严肃',
cheerful: '欢快',
enthusiastic: '热情',
confident: '自信',
authoritative: '权威',
gentle: '温柔',
empathetic: '共情',
playful: '活泼',
dramatic: '戏剧感',
intimate: '亲密',
mysterious: '神秘',
sad: '悲伤',
angry: '愤怒',
clear: '清晰',
crisp: '清脆',
'neutral-tone': '中性语气',
expressive: '有表现力',
monotone: '平稳',
animated: '生动',
storytelling: '故事感',
narrative: '叙事',
character: '角色',
announcer: '播音',
host: '主持',
teacher: '教师',
coach: '教练',
anime: '动漫',
gaming: '游戏',
cinematic: '电影感',
documentary: '纪录片',
radio: '电台',
podcast: '播客'
}
function compactCount(value?: number): string {
if (!value) return '0'
return new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1
}).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)
}
function voiceTagLabel(tag: string): string {
return VOICE_TAG_LABELS[tag] || tag
}
function VoiceAvatar({ voice }: { voice: TextToSpeechVoice }): React.ReactElement {
return (
<span className="tts-voice-media" aria-hidden>
<span className="tts-voice-avatar">{voice.name.slice(0, 1)}</span>
{voice.coverImage ? (
<img
src={voice.coverImage}
alt=""
className="tts-voice-cover"
onError={(event) => {
event.currentTarget.hidden = true
}}
/>
) : null}
</span>
)
}
export function TextToSpeechPage({
onNotice
}: {
onNotice: (message: string) => void
}): React.ReactElement {
const [settings, setSettings] = useState<TextToSpeechSettings | null>(null)
const [voices, setVoices] = useState<TextToSpeechVoice[]>([])
const [selectedVoiceId, setSelectedVoiceId] = useState('')
const [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
const [query, setQuery] = useState('')
const [appliedQuery, setAppliedQuery] = useState('')
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [pageNumber, setPageNumber] = useState(1)
const [total, setTotal] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true)
const [loadingVoices, setLoadingVoices] = useState(false)
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 personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
const loadVoices = useCallback(
async (nextPage: number, title: string, append = false, tags: string[] = []): Promise<void> => {
setLoadingVoices(true)
setError('')
try {
const result = await window.api.listTextToSpeechVoices({
pageNumber: nextPage,
pageSize: VOICE_PAGE_SIZE,
title: title || undefined,
tags
})
if (!result.success) {
setError(result.error || '音色加载失败')
return
}
setVoices((current) => {
const merged = append ? [...current, ...result.items] : result.items
return Array.from(new Map(merged.map((voice) => [voice.id, voice])).values())
})
setPageNumber(result.pageNumber)
setTotal(result.total)
setHasMore(result.hasMore)
setAppliedQuery(title)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '音色加载失败')
} finally {
setLoadingVoices(false)
}
},
[]
)
useEffect(() => {
let active = true
void window.api
.getTextToSpeechSettings()
.then(async (result) => {
if (!active) return
setSettings(result.settings)
setSelectedVoiceId(result.settings.selectedVoiceId)
setError(result.success ? '' : result.error || '文字转语音配置读取失败')
setLoading(false)
if (result.settings.hasApiKey) await loadVoices(1, '')
})
.catch((reason) => {
if (!active) return
setError(reason instanceof Error ? reason.message : '文字转语音配置读取失败')
setLoading(false)
})
return () => {
active = false
audioRef.current?.pause()
}
}, [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])
const saveApiKey = async (): Promise<void> => {
if (!apiKey.trim() || savingKey) return
setSavingKey(true)
setError('')
try {
const result = await window.api.saveTextToSpeechSettings({ apiKey: apiKey.trim() })
setSettings(result.settings)
if (!result.success) {
setError(result.error || 'API Key 保存失败')
onNotice(result.error || 'API Key 保存失败')
return
}
setApiKey('')
onNotice('API Key 已安全保存')
await loadVoices(1, '')
} catch (reason) {
const message = reason instanceof Error ? reason.message : 'API Key 保存失败'
setError(message)
onNotice(message)
} finally {
setSavingKey(false)
}
}
const clearApiKey = async (): Promise<void> => {
if (!settings?.hasStoredApiKey || savingKey) return
setSavingKey(true)
setError('')
try {
const result = await window.api.saveTextToSpeechSettings({ clearApiKey: true })
setSettings(result.settings)
setApiKey('')
if (!result.success) {
setError(result.error || 'API Key 清除失败')
return
}
onNotice(
result.settings.hasEnvironmentApiKey
? '已清除应用内 Key,将继续使用应用环境中的 Key'
: 'API Key 已清除'
)
if (!result.settings.hasApiKey) {
setVoices([])
setTotal(0)
setHasMore(false)
}
} finally {
setSavingKey(false)
}
}
const selectVoice = async (voice: TextToSpeechVoice): Promise<void> => {
if (savingVoiceId) return
const previous = selectedVoiceId
setSelectedVoiceId(voice.id)
setSavingVoiceId(voice.id)
setError('')
try {
const result = await window.api.saveTextToSpeechSettings({ selectedVoiceId: voice.id })
setSettings(result.settings)
if (!result.success) {
setSelectedVoiceId(previous)
setError(result.error || '音色保存失败')
return
}
onNotice(`已选择音色:${voice.name}`)
} catch (reason) {
setSelectedVoiceId(previous)
setError(reason instanceof Error ? reason.message : '音色保存失败')
} finally {
setSavingVoiceId('')
}
}
const changeModel = async (model: TextToSpeechModel): Promise<void> => {
if (!settings) return
const previous = settings.model
setSettings({ ...settings, model })
const result = await window.api.saveTextToSpeechSettings({ model })
if (!result.success) {
setSettings({ ...settings, model: previous })
setError(result.error || '合成模型保存失败')
return
}
setSettings(result.settings)
onNotice(model === 's2.1-pro-free' ? '已切换到标准模型' : '已切换到高质量模型')
}
const searchVoices = async (): Promise<void> => {
await loadVoices(1, query.trim(), false, selectedTags)
}
const toggleVoiceFilter = async (tag: string): Promise<void> => {
const isSelected = selectedTags.includes(tag)
const exclusiveGroup = VOICE_FILTER_GROUPS.find((group) => group.includes(tag as never))
const nextTags = isSelected
? selectedTags.filter((item) => item !== tag)
: [
...(exclusiveGroup
? selectedTags.filter((item) => !exclusiveGroup.includes(item as never))
: selectedTags),
tag
]
setSelectedTags(nextTags)
await loadVoices(1, appliedQuery, false, nextTags)
}
const clearVoiceFilters = async (): Promise<void> => {
setSelectedTags([])
await loadVoices(1, appliedQuery, false, [])
}
const playPreview = (voice: TextToSpeechVoice): void => {
if (!voice.previewUrl) {
onNotice('这个音色暂时没有公开试听片段')
return
}
audioRef.current?.pause()
if (playingVoiceId === voice.id) {
setPlayingVoiceId('')
return
}
const audio = new Audio(voice.previewUrl)
audioRef.current = audio
setPlayingVoiceId(voice.id)
audio.addEventListener('ended', () => setPlayingVoiceId(''), { once: true })
audio.addEventListener(
'error',
() => {
setPlayingVoiceId('')
onNotice('音色试听加载失败')
},
{ once: true }
)
void audio.play().catch(() => {
setPlayingVoiceId('')
onNotice('音色试听播放失败')
})
}
const openApiKeys = async (): Promise<void> => {
const result = await window.api.openFishAudioApiKeys()
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'
? 'Key 已保存在系统安全存储中,页面不会回显完整内容'
: settings.keySource === 'environment'
? '已从应用环境中读取 API Key'
: settings.encryptionAvailable
? '还没有配置 API Key,可在这里直接保存'
: '当前系统安全存储不可用,请从应用环境中提供 API Key'
return (
<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>
</ul>
{personalWechatRuntimeSupported ? (
<button
type="button"
className="tts-supported-versions-button"
onClick={() => 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 type="button" onClick={() => void cancelRuntimeDownload()}>
</button>
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
<>
{runtimeStatus.directory ? (
<button type="button" onClick={() => void openRuntimeDirectory()}>
</button>
) : null}
{runtimeStatus.removable ? (
<button
type="button"
className="settings-danger-button"
disabled={runtimeBusy}
onClick={() => void removeRuntime()}
>
</button>
) : null}
</>
) : personalWechatRuntimeSupported ? (
<button
type="button"
className="settings-primary-button"
disabled={runtimeBusy}
onClick={() => void downloadRuntime()}
>
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
? '重新下载'
: '下载组件'}
</button>
) : null}
<button type="button" disabled={runtimeBusy} onClick={() => void refreshRuntime()}>
</button>
{personalWechatRuntimeSupported ? (
<button type="button" onClick={() => 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 type="button" 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 type="button" onClick={() => setShowApiKey((current) => !current)}>
{showApiKey ? '隐藏' : '显示'}
</button>
<button
type="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
type="button"
className="settings-danger-button"
onClick={() => void clearApiKey()}
>
Key
</button>
) : null}
</div>
<label className="tts-model-select">
<span></span>
<select
value={settings?.model || 's2.1-pro-free'}
disabled={!settings}
onChange={(event) => void changeModel(event.target.value as TextToSpeechModel)}
>
<option value="s2.1-pro-free">s2.1-pro-free</option>
<option value="s2.1-pro">s2.1-pro</option>
</select>
</label>
</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}
placeholder="按音色名称搜索"
disabled={!settings?.hasApiKey || loadingVoices}
onChange={(event) => setQuery(event.target.value)}
/>
<button type="submit" 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>
) : (
<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)}
>
<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>
</span>
<span className="tts-voice-check">
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
</span>
</button>
<button
type="button"
className="tts-voice-preview"
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
type="button"
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>
{showWechatVersions ? (
<div
className="tts-version-modal-backdrop"
role="presentation"
onMouseDown={() => setShowWechatVersions(false)}
>
<section
className="tts-version-modal"
role="dialog"
aria-modal="true"
aria-labelledby="tts-version-modal-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<h2 id="tts-version-modal-title"></h2>
</div>
<div className="tts-version-modal-actions">
<a href={WECHAT_VERSION_DOWNLOAD_URL} target="_blank" rel="noreferrer">
</a>
<button
type="button"
className="tts-version-modal-close"
onClick={() => setShowWechatVersions(false)}
aria-label="关闭"
>
×
</button>
</div>
</header>
<p className="tts-version-modal-intro"></p>
<div className="tts-version-grid">
{BUNDLED_WECHAT_VERSIONS.map((version) => (
<span key={version}>{version}</span>
))}
</div>
</section>
</div>
) : null}
</div>
)
}
+693
View File
@@ -129,6 +129,46 @@
background: var(--wxex-bg-elevated);
}
.chat-tool-button-wrapper {
position: relative;
display: inline-flex;
}
.chat-tool-button-wrapper .chat-tool-button:disabled {
pointer-events: none;
}
.chat-tool-button-wrapper::after {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: max-content;
max-width: min(240px, calc(100vw - 24px));
padding: 6px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-text-primary);
color: var(--wxex-bg-elevated);
content: attr(title);
font: 12px/17px var(--wxex-font);
opacity: 0;
pointer-events: none;
text-align: left;
transform: translateY(-3px);
transition:
opacity 0.15s ease,
transform 0.15s ease;
white-space: normal;
}
.chat-tool-button-wrapper:hover::after,
.chat-tool-button-wrapper:focus-visible::after,
.chat-tool-button-wrapper:focus-within::after {
opacity: 1;
transform: translateY(0);
}
.chat-icon-button:hover,
.chat-tool-button:hover {
background: var(--wxex-brand-soft);
@@ -153,6 +193,659 @@
background: #5a60c6;
}
.personal-wechat-send-backdrop {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(18, 29, 25, 0.42);
backdrop-filter: blur(3px);
-webkit-app-region: no-drag;
}
.personal-wechat-send-dialog {
box-sizing: border-box;
width: min(100%, 620px);
max-height: calc(100vh - 48px);
overflow-y: auto;
display: grid;
gap: 16px;
border: 1px solid var(--wxex-border);
border-radius: 16px;
background: var(--wxex-bg-elevated);
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
padding: 22px;
> header,
> footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 2px 0 0;
color: var(--wxex-text-primary);
font-size: 19px;
line-height: 26px;
}
> footer {
justify-content: flex-end;
button {
min-height: 36px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
padding: 0 15px;
cursor: pointer;
font: 13px/18px var(--wxex-font);
}
button.secondary {
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
}
button.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
font-weight: 600;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
}
.personal-wechat-send-kicker {
color: var(--wxex-brand);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.personal-wechat-send-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
font-size: 24px;
line-height: 28px;
&:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
.personal-wechat-send-device-note {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 9px;
border: 1px solid rgba(36, 122, 99, 0.2);
border-radius: var(--wxex-radius-md);
background: rgba(36, 122, 99, 0.065);
padding: 10px 12px;
> span {
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 11px;
font-weight: 750;
}
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 17px;
}
strong {
display: block;
margin-bottom: 1px;
color: var(--wxex-brand);
font-size: 11px;
}
}
.personal-wechat-send-target {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 10px;
border-radius: var(--wxex-radius-md);
background: #f3f6f4;
padding: 12px 14px;
span {
grid-row: 1 / span 2;
align-self: center;
color: var(--wxex-text-muted);
font-size: 12px;
}
strong {
color: var(--wxex-text-primary);
font-size: 14px;
}
code {
overflow: hidden;
color: var(--wxex-text-secondary);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.personal-wechat-send-status {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
padding: 12px;
&.ready {
border-color: rgba(36, 122, 99, 0.28);
background: rgba(36, 122, 99, 0.06);
}
&.blocked {
border-color: rgba(190, 96, 60, 0.24);
background: rgba(190, 96, 60, 0.06);
}
strong,
p,
small {
display: block;
}
strong {
color: var(--wxex-text-primary);
font-size: 13px;
}
p {
margin: 3px 0 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 1.55;
}
small {
margin-top: 3px;
color: var(--wxex-text-muted);
font-size: 11px;
}
small.error {
color: #b15338;
word-break: break-all;
}
button {
border: 0;
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
font: 12px/18px var(--wxex-font);
white-space: nowrap;
}
}
.personal-wechat-send-status-actions {
display: grid;
justify-items: end;
gap: 4px;
}
.personal-wechat-send-diagnostics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin: 0;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-border);
> div {
min-width: 0;
background: var(--wxex-bg-main);
padding: 9px 11px;
}
dt {
margin-bottom: 3px;
color: var(--wxex-text-muted);
font-size: 10px;
font-weight: 600;
}
dd {
overflow: hidden;
margin: 0;
color: var(--wxex-text-secondary);
font: 11px/1.45 var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
}
.personal-wechat-send-mode {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-main);
padding: 4px;
button {
min-height: 34px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
button.active {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
.personal-wechat-send-status-dot {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 50%;
background: var(--wxex-text-muted);
.ready & {
background: var(--wxex-success);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.12);
}
.blocked & {
background: #be603c;
}
}
.personal-wechat-send-editor {
display: grid;
gap: 7px;
> span {
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
}
textarea {
box-sizing: border-box;
width: 100%;
resize: vertical;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
outline: 0;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
padding: 10px 12px;
font: 14px/1.6 var(--wxex-font);
&:focus {
border-color: var(--wxex-brand);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
}
}
> small {
color: var(--wxex-text-muted);
font-size: 11px;
text-align: right;
}
}
.personal-wechat-send-image-picker {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px 12px;
> span {
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
}
> button {
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
padding: 7px 12px;
font: 12px/18px var(--wxex-font);
}
> div,
> small {
grid-column: 1 / -1;
}
> div {
min-width: 0;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-main);
padding: 10px 12px;
strong,
small {
display: block;
}
strong {
color: var(--wxex-text-primary);
font-size: 12px;
}
small {
overflow: hidden;
margin-top: 3px;
color: var(--wxex-text-muted);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
> small {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.personal-wechat-voice-composer {
display: grid;
gap: 12px;
}
.personal-wechat-voice-source {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-main);
padding: 4px;
button {
min-height: 32px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
button.active {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
}
}
.personal-wechat-generated-voice {
display: grid;
gap: 12px;
}
.personal-wechat-generated-voice-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
span,
strong {
display: block;
}
span {
color: var(--wxex-text-muted);
font-size: 11px;
}
strong {
margin-top: 3px;
color: var(--wxex-text-primary);
font-size: 13px;
}
button {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 7px 10px;
font: 11px/17px var(--wxex-font);
}
}
.personal-wechat-tts-readiness {
display: grid;
gap: 3px;
border-radius: var(--wxex-radius-md);
background: rgba(190, 96, 60, 0.08);
padding: 10px 12px;
&.ready {
background: rgba(36, 122, 99, 0.08);
}
strong {
color: var(--wxex-text-primary);
font-size: 11px;
}
span {
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 16px;
}
}
.personal-wechat-generation-progress {
display: grid;
gap: 9px;
border: 1px solid rgba(36, 122, 99, 0.2);
border-radius: var(--wxex-radius-md);
background: rgba(36, 122, 99, 0.045);
padding: 11px 12px;
> div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
strong {
color: var(--wxex-text-primary);
font-size: 11px;
}
span {
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.personal-wechat-generation-track,
.personal-wechat-preview-track {
position: relative;
overflow: hidden;
height: 5px;
border-radius: 999px;
background: rgba(36, 122, 99, 0.12);
> span {
position: absolute;
inset-block: 0;
left: 0;
border-radius: inherit;
background: var(--wxex-brand);
}
}
.personal-wechat-generation-track > span {
width: 38%;
animation: personal-wechat-generation-slide 1.15s ease-in-out infinite;
}
@keyframes personal-wechat-generation-slide {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(285%);
}
}
.personal-wechat-generated-result {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
border: 1px solid rgba(36, 122, 99, 0.3);
border-radius: var(--wxex-radius-md);
background: rgba(36, 122, 99, 0.055);
padding: 11px 12px;
audio {
display: none;
}
}
.personal-wechat-generated-result-copy {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px 10px;
strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
> span {
color: var(--wxex-text-muted);
font: 10px/16px var(--wxex-font);
white-space: nowrap;
}
.personal-wechat-preview-track {
grid-column: 1 / -1;
}
}
.personal-wechat-generated-result-actions {
display: flex;
align-items: center;
gap: 7px;
button {
min-height: 32px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 0 11px;
font: 11px/17px var(--wxex-font);
white-space: nowrap;
}
button.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
font-weight: 600;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
@media (max-width: 560px) {
.personal-wechat-generated-result {
grid-template-columns: 1fr;
}
.personal-wechat-generated-result-actions {
justify-content: flex-end;
}
}
.personal-wechat-send-note {
margin: 0;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 1.65;
}
.personal-wechat-send-result {
border-radius: var(--wxex-radius-md);
padding: 9px 11px;
font-size: 12px;
line-height: 1.5;
&.success {
background: rgba(36, 122, 99, 0.1);
color: var(--wxex-success);
}
&.error {
background: rgba(190, 96, 60, 0.1);
color: #a84e32;
}
}
.chat-icon-button svg,
.chat-tool-button svg,
.chat-ai-button svg,
+1
View File
@@ -15,4 +15,5 @@
@use './archive';
@use './settings-advanced';
@use './settings-preferences';
@use './settings-text-to-speech';
@use './theme';
@@ -298,6 +298,46 @@
opacity: 0.72;
}
.report-toolbar-button-hint {
position: relative;
display: inline-flex;
}
.report-toolbar-button-hint > button:disabled {
pointer-events: none;
}
.report-toolbar-button-hint::after {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: max-content;
max-width: min(280px, calc(100vw - 24px));
padding: 6px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-text-primary);
color: var(--wxex-bg-elevated);
content: attr(title);
font: 12px/17px var(--wxex-font);
opacity: 0;
pointer-events: none;
text-align: left;
transform: translateY(-3px);
transition:
opacity 0.15s ease,
transform 0.15s ease;
white-space: normal;
}
.report-toolbar-button-hint:hover::after,
.report-toolbar-button-hint:focus-visible::after,
.report-toolbar-button-hint:focus-within::after {
opacity: 1;
transform: translateY(0);
}
.report-more-menu {
position: relative;
}
@@ -0,0 +1,903 @@
.text-to-speech-content {
width: min(100%, 1040px);
}
.tts-usage-guide {
display: grid;
gap: 16px;
margin-bottom: 28px;
border: 1px solid rgba(190, 96, 60, 0.24);
border-radius: var(--wxex-radius-md);
background: linear-gradient(135deg, rgba(190, 96, 60, 0.055), rgba(36, 122, 99, 0.025));
padding: 18px;
}
.tts-usage-guide-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
> div {
display: flex;
align-items: center;
gap: 9px;
}
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 16px;
}
> p {
max-width: 520px;
margin: 0;
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 18px;
text-align: right;
}
}
.tts-experimental-badge {
border-radius: 999px;
background: rgba(190, 96, 60, 0.12);
color: #aa5135;
padding: 4px 8px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
}
.tts-usage-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
li {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 9px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
padding: 11px;
> span {
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 10px;
font-weight: 700;
}
}
strong {
display: block;
color: var(--wxex-text-primary);
font-size: 12px;
}
p {
margin: 3px 0 0;
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 16px;
}
}
.tts-hook-warning {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 11px;
border-radius: var(--wxex-radius-md);
background: rgba(190, 96, 60, 0.085);
padding: 12px 13px;
strong {
color: #9c4a32;
font-size: 12px;
}
ul {
display: grid;
gap: 4px;
margin: 7px 0 0;
padding-left: 17px;
color: var(--wxex-text-secondary);
font-size: 10px;
line-height: 16px;
}
}
.tts-hook-warning-icon {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(190, 96, 60, 0.16);
color: #a84e32;
font-size: 13px;
font-weight: 800;
}
.tts-supported-versions-button {
margin-top: 10px;
border: 1px solid rgba(190, 96, 60, 0.3);
border-radius: 999px;
background: var(--wxex-bg-elevated);
color: #9c4a32;
cursor: pointer;
padding: 6px 11px;
font: 11px/17px var(--wxex-font);
}
.tts-version-modal-backdrop {
position: fixed;
z-index: 110;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(18, 29, 25, 0.46);
backdrop-filter: blur(3px);
-webkit-app-region: no-drag;
}
.tts-version-modal {
box-sizing: border-box;
width: min(100%, 620px);
max-height: calc(100vh - 48px);
overflow-y: auto;
display: grid;
gap: 16px;
border: 1px solid var(--wxex-border);
border-radius: 16px;
background: var(--wxex-bg-elevated);
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
padding: 22px;
> header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
> div {
display: flex;
align-items: center;
gap: 9px;
}
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 18px;
}
}
}
.tts-version-modal-actions {
display: flex;
align-items: center;
gap: 10px;
> a {
border: 1px solid rgba(36, 122, 99, 0.28);
border-radius: 999px;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
padding: 6px 10px;
font-size: 10px;
font-weight: 650;
text-decoration: none;
&:hover {
border-color: var(--wxex-brand);
}
}
}
.tts-version-modal-close {
width: 30px;
height: 30px;
flex: 0 0 auto;
border: 0;
border-radius: 50%;
background: var(--wxex-bg-main);
color: var(--wxex-text-secondary);
cursor: pointer;
font: 22px/28px var(--wxex-font);
}
.tts-version-modal-intro {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 19px;
code {
display: inline;
margin: 0 4px;
border-radius: 4px;
background: var(--wxex-bg-main);
color: var(--wxex-brand);
padding: 2px 5px;
font-size: 10px;
word-break: break-all;
}
}
.tts-version-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
span {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-main);
color: var(--wxex-text-secondary);
padding: 8px 9px;
font: 11px/17px var(--wxex-font);
text-align: center;
}
}
.tts-runtime-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 18px 28px;
margin-bottom: 28px;
}
.tts-runtime-summary {
min-width: 0;
strong,
small {
display: block;
}
strong {
color: var(--wxex-text-primary);
font-size: 18px;
}
small {
margin-top: 5px;
color: var(--wxex-text-secondary);
font-size: 11px;
}
}
.tts-runtime-error {
margin: 8px 0 0;
color: var(--wxex-danger, #c85a5a);
font-size: 11px;
line-height: 17px;
}
.tts-runtime-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
> button {
min-height: 34px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
padding: 7px 12px;
font: 12px/18px var(--wxex-font);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
> .settings-primary-button {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
.tts-runtime-progress {
grid-column: 1 / -1;
display: grid;
gap: 7px;
> div {
display: flex;
justify-content: space-between;
color: var(--wxex-text-secondary);
font-size: 11px;
}
progress {
width: 100%;
height: 7px;
accent-color: var(--wxex-brand);
}
}
.tts-version-modal-warning {
display: grid;
gap: 6px;
border-radius: var(--wxex-radius-md);
background: rgba(190, 96, 60, 0.08);
padding: 12px 13px;
strong {
color: #9c4a32;
font-size: 12px;
}
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 10px;
line-height: 17px;
}
}
.tts-api-card {
display: grid;
gap: 18px;
}
.tts-api-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 22px;
strong {
color: var(--wxex-text-primary);
font-size: 16px;
}
p {
max-width: 600px;
margin: 6px 0 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 19px;
}
}
.tts-api-heading > button,
.tts-api-footer button,
.tts-api-input button,
.tts-voice-search button,
.tts-load-more {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 8px 12px;
font: 12px/18px var(--wxex-font);
&:disabled {
cursor: default;
opacity: 0.5;
}
}
.tts-api-input {
display: grid;
gap: 8px;
> span {
color: var(--wxex-text-primary);
font-size: 12px;
font-weight: 600;
}
> div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
}
input {
min-width: 0;
border: 1px solid var(--wxex-border);
border-right: 0;
border-radius: var(--wxex-radius-sm) 0 0 var(--wxex-radius-sm);
outline: 0;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
padding: 10px 12px;
font: 13px/20px var(--wxex-font);
&:focus {
border-color: var(--wxex-brand);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
}
}
button {
border-radius: 0;
}
.tts-save-key-button {
border-radius: 0 var(--wxex-radius-sm) var(--wxex-radius-sm) 0;
background: var(--wxex-brand);
color: #fff;
}
}
.tts-api-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
span {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.tts-model-select {
display: grid;
grid-template-columns: 82px minmax(0, 360px);
align-items: center;
gap: 12px;
> span {
color: var(--wxex-text-primary);
font-size: 12px;
font-weight: 600;
}
select {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
outline: 0;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
padding: 9px 11px;
font: 12px/18px var(--wxex-font);
}
}
.tts-voice-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-top: 30px;
.settings-section-heading {
margin: 0 0 5px;
}
p {
margin: 0;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
}
.tts-voice-search {
width: min(350px, 46%);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-elevated);
padding: 3px 4px 3px 12px;
color: var(--wxex-text-muted);
input {
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
padding: 7px 0;
font: 12px/18px var(--wxex-font);
}
button {
border-radius: 999px;
padding: 5px 12px;
}
}
.tts-voice-filters {
display: flex;
align-items: center;
gap: 7px;
overflow-x: auto;
margin-top: 13px;
padding: 1px 1px 5px;
scrollbar-width: thin;
button {
flex: 0 0 auto;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
padding: 5px 11px;
font: 11px/17px var(--wxex-font);
transition:
border-color 120ms ease,
background 120ms ease,
color 120ms ease;
&:hover:not(:disabled) {
border-color: rgba(36, 122, 99, 0.38);
color: var(--wxex-text-primary);
}
&.active {
border-color: rgba(36, 122, 99, 0.5);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-weight: 600;
}
&:disabled {
cursor: default;
opacity: 0.5;
}
}
}
.tts-current-voice {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
margin-top: 15px;
border: 1px solid rgba(36, 122, 99, 0.32);
border-radius: var(--wxex-radius-md);
background: rgba(36, 122, 99, 0.055);
padding: 13px 15px;
strong {
display: block;
color: var(--wxex-text-primary);
font-size: 14px;
}
p {
overflow: hidden;
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.tts-current-tags,
.tts-voice-tags,
.tts-voice-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
}
.tts-current-tags {
justify-content: flex-end;
span {
border-radius: 999px;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
padding: 4px 8px;
font-size: 10px;
}
}
.tts-voice-media,
.tts-voice-avatar,
.tts-voice-cover {
width: 48px;
height: 48px;
flex: 0 0 auto;
border-radius: 50%;
}
.tts-voice-media {
position: relative;
display: block;
overflow: hidden;
}
.tts-voice-cover {
position: absolute;
inset: 0;
display: block;
background: var(--wxex-bg-sidebar);
object-fit: cover;
}
.tts-voice-avatar {
display: grid;
place-items: center;
background: linear-gradient(145deg, #d9eee6, #edf7f3);
color: var(--wxex-brand);
font-size: 17px;
font-weight: 700;
}
.tts-voice-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 26px;
row-gap: 2px;
margin-top: 14px;
}
.tts-voice-card {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
border: 1px solid transparent;
border-bottom-color: var(--wxex-border);
border-radius: var(--wxex-radius-md);
padding: 12px 8px;
transition:
background 120ms ease,
border-color 120ms ease;
&:hover {
border-color: rgba(36, 122, 99, 0.18);
background: rgba(36, 122, 99, 0.035);
}
&.selected {
border-color: rgba(36, 122, 99, 0.38);
background: rgba(36, 122, 99, 0.065);
}
}
.tts-voice-select {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) 20px;
align-items: center;
gap: 11px;
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
text-align: left;
}
.tts-voice-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.tts-voice-title-row {
min-width: 0;
display: flex;
align-items: baseline;
gap: 7px;
strong {
overflow: hidden;
color: var(--wxex-text-primary);
font: 600 13px/18px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
em {
overflow: hidden;
color: var(--wxex-text-muted);
font: 10px/16px var(--wxex-font);
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.tts-voice-copy > small {
display: -webkit-box;
overflow: hidden;
min-height: 17px;
color: var(--wxex-text-secondary);
font: 11px/17px var(--wxex-font);
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.tts-voice-tags {
min-height: 18px;
> span {
max-width: 92px;
overflow: hidden;
border: 1px solid var(--wxex-border);
border-radius: 999px;
color: var(--wxex-text-muted);
padding: 1px 6px;
font: 9px/14px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
}
.tts-voice-stats {
color: var(--wxex-text-muted);
font: 9px/14px var(--wxex-font);
> span {
margin-right: 5px;
}
}
.tts-voice-check {
width: 18px;
height: 18px;
display: grid;
place-items: center;
border: 1px solid var(--wxex-border);
border-radius: 50%;
color: #fff;
font-size: 10px;
.selected & {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
}
}
.tts-voice-preview {
min-width: 42px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 5px 9px;
font: 10px/16px var(--wxex-font);
&:disabled {
cursor: default;
color: var(--wxex-text-muted);
opacity: 0.5;
}
}
.tts-voice-empty {
display: grid;
gap: 5px;
margin-top: 12px;
color: var(--wxex-text-muted);
font-size: 12px;
text-align: center;
strong {
color: var(--wxex-text-primary);
}
}
.tts-voice-loading,
.tts-settings-error {
margin: 14px 0 0;
font-size: 12px;
text-align: center;
}
.tts-voice-loading {
color: var(--wxex-text-muted);
}
.tts-settings-error {
color: var(--wxex-danger);
}
.tts-load-more {
display: block;
margin: 18px auto 0;
border-radius: 999px;
padding-inline: 24px;
}
@media (max-width: 920px) {
.tts-usage-steps {
grid-template-columns: 1fr;
}
.tts-voice-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.tts-api-heading,
.tts-voice-heading,
.tts-api-footer,
.tts-usage-guide-heading {
align-items: stretch;
flex-direction: column;
}
.tts-usage-guide-heading > p {
text-align: left;
}
.tts-api-input > div {
grid-template-columns: minmax(0, 1fr) auto;
}
.tts-api-input .tts-save-key-button {
grid-column: 1 / -1;
margin-top: 7px;
border-radius: var(--wxex-radius-sm);
}
.tts-voice-search {
width: auto;
}
.tts-current-voice {
grid-template-columns: auto minmax(0, 1fr);
}
.tts-current-tags {
grid-column: 1 / -1;
justify-content: flex-start;
}
.tts-model-select {
grid-template-columns: 1fr;
}
.tts-version-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tts-runtime-card {
grid-template-columns: 1fr;
}
.tts-runtime-actions {
justify-content: flex-start;
}
.tts-version-modal > header {
align-items: stretch;
flex-direction: column;
}
.tts-version-modal-actions {
justify-content: space-between;
}
}
@@ -0,0 +1,14 @@
export type RuntimePlatform = NodeJS.Platform | 'unknown'
function detectRuntimePlatform(): RuntimePlatform {
if (typeof window === 'undefined') return 'unknown'
return (window.electron?.process?.platform as NodeJS.Platform | undefined) || 'unknown'
}
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
+29
View File
@@ -0,0 +1,29 @@
export type PersonalWechatRuntimeState =
| 'missing'
| 'downloading'
| 'ready'
| 'invalid'
| 'error'
| 'unsupported'
export interface PersonalWechatRuntimeStatus {
version: string
state: PersonalWechatRuntimeState
downloadedBytes: number
totalBytes: number
progress: number
platform: NodeJS.Platform
architecture: string
supported: boolean
removable: boolean
directory?: string
error?: string
}
export interface PersonalWechatRuntimeDownloadResult {
success: boolean
status: PersonalWechatRuntimeStatus
error?: string
}
export interface PersonalWechatRuntimeProgressEvent extends PersonalWechatRuntimeStatus {}
+84
View File
@@ -0,0 +1,84 @@
export type PersonalWechatSenderState =
| 'checking'
| 'unsupported_platform'
| 'wechat_not_running'
| 'sip_enabled'
| 'unsupported_version'
| 'runtime_missing'
| 'hook_not_ready'
| 'stopped'
| 'starting'
| 'rebinding'
| 'online'
| 'error'
export interface PersonalWechatSenderStatus {
state: PersonalWechatSenderState
platform: string
arch: string
sipDisabled: boolean
wechatRunning: boolean
wechatPid?: number
boundWechatPid?: number
oneBotPid?: number
endpoint: string
endpointReady: boolean
wechatVersion?: string
runtimeReady: boolean
executablePath?: string
configPath?: string
imagePath?: string
attachReady: boolean
baseAddress?: string
baseAddressReady: boolean
textHookInstalled: boolean
textHookReady: boolean
imageHookInstalled: boolean
imageHookReady: boolean
messageListenerReady: boolean
canSend: boolean
canSendText: boolean
canSendImage: boolean
canSendVoice: boolean
message: string
error?: string
}
interface PersonalWechatSendBaseRequest {
to: string
isGroup: boolean
}
export interface PersonalWechatSendTextRequest extends PersonalWechatSendBaseRequest {
type: 'text'
text: string
}
export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRequest {
type: 'image'
filePath: string
}
export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest {
type: 'voice'
filePath: string
}
export type PersonalWechatSendRequest =
| PersonalWechatSendTextRequest
| PersonalWechatSendImageRequest
| PersonalWechatSendVoiceRequest
export interface PersonalWechatSendResult {
success: boolean
status: PersonalWechatSenderStatus
error?: string
}
export interface PersonalWechatImageSelectionResult {
canceled: boolean
path?: string
name?: string
}
export type PersonalWechatVoiceSelectionResult = PersonalWechatImageSelectionResult
+75
View File
@@ -0,0 +1,75 @@
export type TextToSpeechKeySource = 'secure-storage' | 'environment' | 'missing'
export type TextToSpeechModel = 's2.1-pro-free' | 's2.1-pro'
export interface TextToSpeechVoice {
id: string
name: string
description: string
tags: string[]
languages: string[]
source: 'fish-audio'
coverImage?: string
previewUrl?: string
previewText?: string
authorName?: string
taskCount?: number
likeCount?: number
markCount?: number
}
export interface TextToSpeechSettings {
provider: 'fish-audio'
hasApiKey: boolean
hasStoredApiKey: boolean
hasEnvironmentApiKey: boolean
keySource: TextToSpeechKeySource
encryptionAvailable: boolean
selectedVoiceId: string
outputFormat: 'mp3'
model: TextToSpeechModel
phase: 'ready'
}
export interface TextToSpeechSettingsResult {
success: boolean
settings: TextToSpeechSettings
voices: TextToSpeechVoice[]
error?: string
}
export interface SaveTextToSpeechSettingsRequest {
apiKey?: string
clearApiKey?: boolean
selectedVoiceId?: string
model?: TextToSpeechModel
}
export interface ListTextToSpeechVoicesRequest {
pageNumber?: number
pageSize?: number
title?: string
language?: string
tags?: string[]
}
export interface ListTextToSpeechVoicesResult {
success: boolean
items: TextToSpeechVoice[]
total: number
pageNumber: number
pageSize: number
hasMore: boolean
error?: string
}
export interface SynthesizeTextToSpeechRequest {
text: string
referenceId: string
}
export interface SynthesizeTextToSpeechResult {
success: boolean
filePath?: string
audioDataUrl?: string
error?: string
}