feat: 修改发送消息流程

This commit is contained in:
Wxw-Gu
2026-08-26 11:23:45 +08:00
parent d26af6462f
commit 1e25ae4065
16 changed files with 1746 additions and 823 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+2 -2
View File
@@ -1800,7 +1800,7 @@ app.whenReady().then(async () => {
ipcMain.handle('wechat-personal:selectImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
title: '选择要通过个人微信测试发送的图片',
title: '选择要通过个人微信发送的图片',
properties: ['openFile'],
filters: [
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
@@ -1817,7 +1817,7 @@ app.whenReady().then(async () => {
ipcMain.handle('wechat-personal:selectVoice', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
title: '选择要通过个人微信测试发送的语音',
title: '选择要通过个人微信发送的语音',
properties: ['openFile'],
filters: [
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
@@ -500,7 +500,7 @@ export class PersonalWechatSendService {
}
if (text.length > 2_000) {
const status = await this.getStatus()
return { success: false, status, error: '测试消息不能超过 2000 个字符' }
return { success: false, status, error: '消息不能超过 2000 个字符' }
}
request = { ...request, to, text }
} else {
@@ -520,7 +520,7 @@ export class PersonalWechatSendService {
return {
success: false,
status,
error: `测试${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
}
}
fileBase64 = (
@@ -725,7 +725,7 @@ export class PersonalWechatSendService {
status: {
...base,
state: 'unsupported_platform',
message: '个人微信测试发送当前仅支持 Apple Silicon Mac'
message: '个人微信发送当前仅支持 Apple Silicon Mac'
}
}
}
+5
View File
@@ -979,6 +979,11 @@ export class Wcdb4Client {
this.cachedSessions = null
this.cachedChatTables = null
this.sessionsInFlight = null
// Session rows can change their folded/muted flags independently of the
// contact list. Invalidate the status snapshot together with sessions so
// the next hydrated contact load reads the current WeChat state.
this.sessionStatusCache.clear()
this.sessionStatusesUpdatedAt = 0
this.sessionDisplayNamesHydrated = false
}
@@ -0,0 +1,411 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
PersonalWechatSendRequest,
PersonalWechatSenderStatus
} from '../../../../shared/personal-wechat'
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
import { Button, SegmentedControl, SegmentedControlItem, Textarea } from '../ui'
export type PersonalWechatComposerMode = 'text' | 'image' | 'voice'
type VoiceSource = 'generated' | 'file'
type SelectedLocalFile = { path: string; name: string }
type GeneratedVoice = { filePath: string; audioDataUrl: string }
export type ChatMessage = {
id: string
type: PersonalWechatComposerMode | 'system'
text?: string
fileName?: string
outgoing: boolean
}
interface PersonalWechatChatComposerProps {
status: PersonalWechatSenderStatus
targetId: string
isGroupChat: boolean
initialMode?: PersonalWechatComposerMode
initialImage?: SelectedLocalFile | null
onOpenTextToSpeechSettings?: () => void
onCancel: () => void
onSend: (
request: PersonalWechatSendRequest,
displayText: string
) => Promise<{ success: boolean; error?: string }>
onMessage: (message: ChatMessage) => void
busy: boolean
}
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')}`
}
export function PersonalWechatChatComposer({
status,
targetId,
isGroupChat,
initialMode = 'text',
initialImage = null,
onOpenTextToSpeechSettings,
onCancel,
onSend,
onMessage,
busy
}: PersonalWechatChatComposerProps): React.ReactElement {
const [mode, setMode] = useState<PersonalWechatComposerMode>(initialMode)
const [text, setText] = useState('')
const [image, setImage] = useState<SelectedLocalFile | null>(initialImage)
const [voice, setVoice] = useState<SelectedLocalFile | null>(null)
const [voiceSource, setVoiceSource] = useState<VoiceSource>('generated')
const [voiceText, setVoiceText] = useState('')
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 [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
const selectedTypeReady =
mode === 'text'
? status.canSendText
: mode === 'image'
? status.canSendImage
: status.canSendVoice
const isBusy = busy || isGenerating
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
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) setTtsVoices(voicesResponse.items)
})
.catch(() => undefined)
return () => {
active = false
}
}, [])
const clearGeneratedVoice = useCallback((): void => {
const current = generatedVoiceRef.current
generatedVoiceRef.current = null
generatedAudioRef.current?.pause()
setGeneratedVoice(null)
setIsPreviewPlaying(false)
setPreviewCurrentTime(0)
setPreviewDuration(0)
if (current?.filePath)
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
}, [])
useEffect(() => () => clearGeneratedVoice(), [clearGeneratedVoice])
const selectImage = 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() || '图片'
})
setFeedback(null)
}
}
const selectVoice = 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() || '语音'
})
setFeedback(null)
}
}
const send = async (
request: PersonalWechatSendRequest,
displayText: string
): Promise<boolean> => {
if (isBusy || !selectedTypeReady) return false
setFeedback(null)
const result = await onSend(request, displayText)
setFeedback({
success: result.success,
message: result.success ? '已发送' : result.error || '发送失败'
})
if (result.success)
onMessage({
id: `${Date.now()}-${Math.random()}`,
type: request.type,
text: request.type === 'text' ? request.text : displayText,
fileName: request.type === 'text' ? undefined : displayText,
outgoing: true
})
return result.success
}
const generateVoice = async (): Promise<void> => {
if (isBusy || !ttsSettings?.hasApiKey || !ttsSettings.selectedVoiceId || !voiceText.trim())
return
clearGeneratedVoice()
setIsGenerating(true)
setFeedback(null)
try {
const generated = await window.api.synthesizeTextToSpeech({
text: voiceText.trim(),
referenceId: ttsSettings.selectedVoiceId
})
if (!generated.success || !generated.filePath || !generated.audioDataUrl) {
setFeedback({ success: false, message: generated.error || '语音生成失败' })
return
}
const nextVoice = { filePath: generated.filePath, audioDataUrl: generated.audioDataUrl }
generatedVoiceRef.current = nextVoice
setGeneratedVoice(nextVoice)
} catch (error) {
setFeedback({
success: false,
message: error instanceof Error ? error.message : String(error)
})
} finally {
setIsGenerating(false)
}
}
const sendCurrent = async (): Promise<void> => {
if (mode === 'text') {
const value = text.trim()
if (value)
await send({ type: 'text', to: targetId, text: value, isGroup: isGroupChat }, value)
} else if (mode === 'image' && image) {
await send(
{ type: 'image', to: targetId, filePath: image.path, isGroup: isGroupChat },
image.name
)
} else if (mode === 'voice' && voiceSource === 'file' && voice) {
await send(
{ type: 'voice', to: targetId, filePath: voice.path, isGroup: isGroupChat },
voice.name
)
}
}
const sendGenerated = async (): Promise<void> => {
if (!generatedVoiceRef.current) return
const result = await send(
{
type: 'voice',
to: targetId,
filePath: generatedVoiceRef.current.filePath,
isGroup: isGroupChat
},
voiceText.trim() || '语音消息'
)
if (result) clearGeneratedVoice()
}
const canSend = Boolean(
selectedTypeReady &&
!isBusy &&
((mode === 'text' && text.trim()) ||
(mode === 'image' && image) ||
(mode === 'voice' &&
((voiceSource === 'file' && voice) || (voiceSource === 'generated' && generatedVoice))))
)
const generatedReady = Boolean(
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
)
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
return (
<section className="personal-wechat-composer" aria-label="发送消息">
<SegmentedControl
className="personal-wechat-composer-mode"
aria-label="消息类型"
value={mode}
onValueChange={(value) => {
clearGeneratedVoice()
setMode(value as PersonalWechatComposerMode)
setFeedback(null)
}}
disabled={isBusy}
>
<SegmentedControlItem value="text">文字</SegmentedControlItem>
<SegmentedControlItem value="image">图片</SegmentedControlItem>
<SegmentedControlItem value="voice">语音</SegmentedControlItem>
</SegmentedControl>
{mode === 'text' && (
<div className="personal-wechat-text-editor">
<Textarea
aria-label="消息内容"
placeholder="输入消息"
value={text}
maxLength={2000}
rows={3}
disabled={isBusy}
onChange={(event) => {
setText(event.target.value)
setFeedback(null)
}}
/>
<span>{text.length} / 2000</span>
</div>
)}
{mode === 'image' && (
<div className="personal-wechat-file-editor">
<div>
<strong>{image?.name || '还没有选择图片'}</strong>
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
</div>
<Button variant="outline" size="sm" onClick={() => void selectImage()} disabled={isBusy}>
{image ? '重新选择' : '选择图片'}
</Button>
</div>
)}
{mode === 'voice' && (
<div className="personal-wechat-voice-editor">
<SegmentedControl
aria-label="语音来源"
value={voiceSource}
onValueChange={(value) => {
clearGeneratedVoice()
setVoiceSource(value as VoiceSource)
setFeedback(null)
}}
disabled={isBusy}
>
<SegmentedControlItem value="generated">输入文字生成</SegmentedControlItem>
<SegmentedControlItem value="file">选择本地文件</SegmentedControlItem>
</SegmentedControl>
{voiceSource === 'generated' ? (
<>
<div className="personal-wechat-voice-heading">
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
{onOpenTextToSpeechSettings && (
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings}>
语音设置
</Button>
)}
</div>
<Textarea
aria-label="语音文字"
placeholder="输入要生成的文字"
value={voiceText}
maxLength={1000}
rows={2}
disabled={isBusy}
onChange={(event) => {
clearGeneratedVoice()
setVoiceText(event.target.value)
setFeedback(null)
}}
/>
{generatedVoice ? (
<div className="personal-wechat-generated-result">
<audio
ref={generatedAudioRef}
src={generatedVoice.audioDataUrl}
preload="metadata"
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
onTimeUpdate={(event) => setPreviewCurrentTime(event.currentTarget.currentTime)}
onPause={() => setIsPreviewPlaying(false)}
onPlay={() => setIsPreviewPlaying(true)}
onEnded={() => {
setIsPreviewPlaying(false)
setPreviewCurrentTime(0)
}}
/>
<div>
<strong>语音已生成</strong>
<span>
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
</span>
<div className="personal-wechat-preview-track">
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
</div>
</div>
<div>
<Button
variant="outline"
size="sm"
onClick={async () => {
const audio = generatedAudioRef.current
if (!audio) return
if (audio.paused) {
await audio.play()
setIsPreviewPlaying(true)
} else {
audio.pause()
}
}}
disabled={busy}
>
{isPreviewPlaying ? '暂停' : '试听'}
</Button>
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
{busy ? '发送中…' : '发送'}
</Button>
</div>
</div>
) : (
<Button
size="sm"
onClick={() => void generateVoice()}
disabled={!generatedReady || isBusy}
>
{isGenerating ? '正在生成…' : '生成语音'}
</Button>
)}
</>
) : (
<div className="personal-wechat-file-editor">
<div>
<strong>{voice?.name || '还没有选择语音文件'}</strong>
<small>{voice ? voice.path : '支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC'}</small>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void selectVoice()}
disabled={isBusy}
>
{voice ? '重新选择' : '选择语音'}
</Button>
</div>
)}
</div>
)}
<div className="personal-wechat-composer-footer">
{feedback && (
<span className={feedback.success ? 'is-success' : 'is-error'}>{feedback.message}</span>
)}
<Button variant="outline" size="sm" onClick={onCancel} disabled={isBusy}>
取消
</Button>
{!(mode === 'voice' && voiceSource === 'generated' && generatedVoice) && (
<Button size="sm" onClick={() => void sendCurrent()} disabled={!canSend}>
{busy ? '正在发送…' : '发送消息'}
</Button>
)}
</div>
</section>
)
}
@@ -1,21 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, 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'
import type {
PersonalWechatSendRequest,
PersonalWechatSenderStatus
} from '../../../../shared/personal-wechat'
import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
SegmentedControl,
SegmentedControlItem,
Textarea
} from '../ui'
PersonalWechatChatComposer,
type ChatMessage,
type PersonalWechatComposerMode
} from './PersonalWechatChatComposer'
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
type SendMode = 'image' | 'voice'
type VoiceSource = 'generated' | 'file'
type SelectedLocalFile = { path: string; name: string }
interface PersonalWechatSendDialogProps {
@@ -23,48 +23,34 @@ interface PersonalWechatSendDialogProps {
isGroupChat: boolean
onClose: () => void
onOpenTextToSpeechSettings?: () => void
initialMode?: SendMode
initialMode?: PersonalWechatComposerMode
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 || '未知'} 暂不支持。请在设置中查看支持的微信版本,安装完全一致的版本后再试。`
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
return {
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)
}
if (status.state === 'runtime_missing') {
return '微信发送组件尚未安装。请前往文字转语音设置下载组件后再试。'
}
return status.message
}
export function PersonalWechatSendDialog({
@@ -72,338 +58,137 @@ export function PersonalWechatSendDialog({
isGroupChat,
onClose,
onOpenTextToSpeechSettings,
initialMode = 'image',
initialMode = 'text',
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 [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
null
)
const [detecting, setDetecting] = useState(true)
const [binding, setBinding] = useState(false)
const [runtimeBusy, setRuntimeBusy] = useState(false)
const [sendBusy, setSendBusy] = useState(false)
const [detectionAttempted, setDetectionAttempted] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [sendError, setSendError] = useState<string | null>(null)
const [composerStarted, setComposerStarted] = useState(Boolean(initialImage))
const requestIdRef = useRef(0)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName
const 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 isBusy = binding || runtimeBusy || sendBusy
const setupReady = Boolean(
senderStatus?.canSendText && (senderStatus?.canSendImage || senderStatus?.canSendVoice)
)
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 || closingRef.current) return
closingRef.current = true
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
clearGeneratedVoice()
onClose()
queueMicrotask(() => restoreFocus?.focus())
}, [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)
const requestId = ++requestIdRef.current
setDetecting(true)
setSendError(null)
try {
setStatus(await window.api.getPersonalWechatSenderStatus())
const [nextRuntime, nextSender] = await Promise.all([
window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null),
window.api.getPersonalWechatSenderStatus()
])
if (requestId !== requestIdRef.current) return
setRuntimeStatus(nextRuntime)
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
setSenderStatus(nextSender)
} 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)
})
if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error))
} finally {
if (requestId === requestIdRef.current) setDetecting(false)
}
}, [])
useEffect(() => {
void refreshStatus()
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
setRuntimeProgress(status)
setRuntimeStatus(status)
if (status.state === 'ready') void refreshStatus()
})
return unsubscribe
}, [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
const handleDownloadRuntime = async (): Promise<void> => {
if (runtimeBusy) return
setRuntimeBusy(true)
setSendError(null)
try {
const result = await window.api.downloadPersonalWechatRuntime()
setRuntimeStatus(result.status)
if (!result.success && result.error) setSendError(result.error)
if (result.success) await refreshStatus()
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error))
} finally {
setRuntimeBusy(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])
const handleRebind = async (): Promise<void> => {
if (isBusy) return
setIsRebinding(true)
setResult(null)
const handleBind = async (): Promise<void> => {
if (binding) return
setBinding(true)
setDetectionAttempted(false)
setSendError(null)
try {
const nextStatus = await window.api.rebindPersonalWechatSender()
setStatus(nextStatus)
setResult({
success: nextStatus.attachReady && nextStatus.baseAddressReady,
message: nextStatus.message
})
setSenderStatus(nextStatus)
if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message)
} catch (error) {
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
setSendError(error instanceof Error ? error.message : String(error))
} finally {
setIsRebinding(false)
setBinding(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 handleDetect = async (): Promise<void> => {
setDetectionAttempted(true)
await refreshStatus()
}
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 handleClose = (): void => {
if (isBusy || closingRef.current) return
closingRef.current = true
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
onClose()
queueMicrotask(() => restoreFocus?.focus())
}
const changeMode = (nextMode: SendMode): void => {
if (mode === 'voice' && voiceSource === 'generated' && nextMode !== 'voice') {
clearGeneratedVoice()
}
setMode(nextMode)
setResult(null)
const handleOpenSettings = (): void => {
if (!onOpenTextToSpeechSettings || isBusy) return
handleClose()
onOpenTextToSpeechSettings()
}
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)
const handleSend = async (
request: PersonalWechatSendRequest
): Promise<{ success: boolean; error?: string }> => {
setSendBusy(true)
setSendError(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: '语音已生成,可以先试听,确认后再发送' })
const response = await window.api.sendPersonalWechatMessage({ ...request, to: targetId })
setSenderStatus(response.status)
if (!response.success) setSendError(response.error || '发送失败')
return { success: response.success, error: response.error }
} catch (error) {
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
const message = error instanceof Error ? error.message : String(error)
setSendError(message)
return { success: false, error: message }
} finally {
setIsGenerating(false)
setSendBusy(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 (
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="personal-wechat-send-dialog max-h-[calc(100vh-3rem)] max-w-[620px] gap-4 overflow-y-auto p-[22px]"
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
onOpenAutoFocus={() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
@@ -411,291 +196,91 @@ export function PersonalWechatSendDialog({
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
>
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
<div>
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
个人微信测试发送
</DialogTitle>
<DialogHeader className="personal-wechat-chat-header">
<div className="personal-wechat-chat-avatar" aria-hidden>
{displayName.slice(0, 1)}
</div>
<DialogDescription className="sr-only">
向当前微信联系人或群聊测试发送图片和语音。
</DialogDescription>
<div className="personal-wechat-chat-heading">
<DialogTitle>{displayName}</DialogTitle>
<DialogDescription>
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
</DialogDescription>
</div>
<span
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
/>
</DialogHeader>
<div className="personal-wechat-send-device-note" role="note">
<span aria-hidden>i</span>
<p>
<strong>请在其他设备确认发送结果</strong>
通过测试功能发送的消息不会显示在本机微信会话中,请切换到手机、平板等其他设备确认是否送达。
</p>
</div>
<div className="personal-wechat-send-target">
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
<strong>{displayName}</strong>
<code>{targetId}</code>
</div>
<div className={`personal-wechat-send-status ${statusTone}`}>
<span className="personal-wechat-send-status-dot" />
<div>
<strong>{statusLabel(status)}</strong>
<p>{statusDescription(status)}</p>
{status?.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
variant="link"
size="sm"
className="h-auto p-0"
onClick={() =>
handleOpenTextToSpeechSettings(status.state === 'unsupported_version')
}
disabled={isBusy}
>
{status.state === 'runtime_missing' ? '前往下载组件' : '查看支持版本'}
</Button>
) : null}
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={() => void refreshStatus()}
disabled={isBusy}
>
重新检测
</Button>
<Button
variant="link"
size="sm"
className="h-auto p-0"
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 className="personal-wechat-chat-body">
{setupReady && composerStarted && (
<div className="personal-wechat-message-list" aria-label="消息列表">
{messages.length === 0 ? (
<div className="personal-wechat-empty-message">还没有发送消息。</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
>
<span className="personal-wechat-message-kind">
{message.type === 'text'
? '文字'
: message.type === 'image'
? '图片'
: '语音'}
</span>
<span>{message.text || message.fileName}</span>
</div>
))
)}
</div>
))}
</dl>
<SegmentedControl
className="personal-wechat-send-mode grid w-full grid-cols-2"
aria-label="测试消息类型"
value={mode}
onValueChange={(value) => changeMode(value as SendMode)}
disabled={isBusy}
>
<SegmentedControlItem value="image" className="w-full">
图片
</SegmentedControlItem>
<SegmentedControlItem value="voice" className="w-full">
语音
</SegmentedControlItem>
</SegmentedControl>
{mode === 'image' ? (
<div className="personal-wechat-send-image-picker">
<span>图片</span>
<Button
variant="outline"
size="sm"
onClick={() => void handleSelectImage()}
disabled={isBusy}
>
{image ? '重新选择图片' : '选择图片'}
</Button>
{image ? (
<div>
<strong>{image.name}</strong>
<small>{image.path}</small>
</div>
) : (
<small>支持 PNG、JPG、GIF 和 WebP,最大 20 MB</small>
)}
</div>
) : (
<div className="personal-wechat-voice-composer">
<SegmentedControl
className="personal-wechat-voice-source grid w-full grid-cols-2"
aria-label="语音来源"
value={voiceSource}
onValueChange={(value) => changeVoiceSource(value as VoiceSource)}
disabled={isBusy}
>
<SegmentedControlItem value="generated" className="w-full">
输入文字生成
</SegmentedControlItem>
<SegmentedControlItem value="file" className="w-full">
选择本地文件
</SegmentedControlItem>
</SegmentedControl>
{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
variant="outline"
size="sm"
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
variant="outline"
size="sm"
onClick={() => void handleToggleGeneratedPreview()}
disabled={isSending}
>
{isPreviewPlaying ? '暂停' : '播放'}
</Button>
<Button
size="sm"
onClick={() => void handleSendGeneratedVoice()}
disabled={!canSendGenerated}
>
{isSending ? '正在发送…' : `发送到${isGroupChat ? '群聊' : '联系人'}`}
</Button>
</div>
</div>
) : null}
</div>
) : (
<div className="personal-wechat-send-image-picker">
<span>本地语音文件</span>
<Button
variant="outline"
size="sm"
onClick={() => void handleSelectVoice()}
disabled={isBusy}
>
{voice ? '重新选择语音' : '选择语音'}
</Button>
{voice ? (
<div>
<strong>{voice.name}</strong>
<small>{voice.path}</small>
</div>
) : (
<small>支持 SILK、MP3、WAV、M4A、AAC、OGG 和 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 variant="outline" onClick={handleClose} disabled={isBusy}>
取消
</Button>
{mode === 'voice' && voiceSource === 'generated' ? (
<Button disabled={!canGenerate} onClick={() => void handleGenerateVoice()}>
{isGenerating ? '正在生成语音…' : generatedVoice ? '重新生成语音' : '生成语音'}
</Button>
) : (
<Button disabled={!canSubmit} onClick={handleSend}>
{isSending
? '正在发送…'
: `测试发送${mode === 'voice' ? '语音' : '图片'}到${isGroupChat ? '群聊' : '联系人'}`}
</Button>
)}
</footer>
{(!setupReady || !composerStarted) && senderStatus && (
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
onDownloadRuntime={() => void handleDownloadRuntime()}
onBind={() => void handleBind()}
detectionAttempted={detectionAttempted}
onDetect={() => void handleDetect()}
onStartSending={() => setComposerStarted(true)}
onOpenTextToSpeechSettings={handleOpenSettings}
/>
)}
{setupReady && composerStarted && (
<PersonalWechatChatComposer
status={senderStatus!}
targetId={targetId}
isGroupChat={isGroupChat}
initialMode={initialMode}
initialImage={initialImage}
onOpenTextToSpeechSettings={handleOpenSettings}
onCancel={handleClose}
onSend={handleSend}
onMessage={(message) => setMessages((current) => [...current, message])}
busy={sendBusy}
/>
)}
{sendError && (
<div className="personal-wechat-global-error" role="alert">
{sendError}
</div>
)}
</div>
{(!setupReady || !composerStarted) && (
<div className="personal-wechat-chat-footer">
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
关闭
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
@@ -0,0 +1,306 @@
import { useState } from 'react'
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
interface PersonalWechatSetupGuideProps {
runtimeStatus: PersonalWechatRuntimeStatus | null
senderStatus: PersonalWechatSenderStatus | null
runtimeProgress: PersonalWechatRuntimeProgressEvent | null
runtimeBusy: boolean
binding: boolean
detecting: boolean
detectionAttempted: boolean
onDownloadRuntime: () => void
onBind: () => void
onDetect: () => void
onStartSending: () => void
onOpenTextToSpeechSettings?: () => void
}
function capabilityLabel(ready: boolean, detectionAttempted: boolean): string {
if (ready) return '已就绪'
return detectionAttempted ? '未检测' : '待检测'
}
function diagnosticValue(value: unknown): string {
if (value === undefined || value === null || value === '') return '未检测到'
return String(value)
}
function bindingHint(status: PersonalWechatSenderStatus): string {
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
if (status.state === 'unsupported_version')
return '当前微信版本暂不支持,请查看语音设置中的支持版本。'
if (status.state === 'runtime_missing') return '请先完成语音模型准备。'
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
}
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
if (status.state === 'online') return true
return Boolean(
status.state === 'hook_not_ready' &&
status.wechatPid &&
status.boundWechatPid === status.wechatPid &&
status.attachReady &&
status.baseAddressReady
)
}
export function PersonalWechatSetupGuide({
runtimeStatus,
senderStatus,
runtimeProgress,
runtimeBusy,
binding,
detecting,
detectionAttempted,
onDownloadRuntime,
onBind,
onDetect,
onStartSending,
onOpenTextToSpeechSettings
}: PersonalWechatSetupGuideProps): React.ReactElement {
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
const connected = senderStatus ? isWechatBound(senderStatus) : false
const canSendText = Boolean(senderStatus?.canSendText)
const canSendImage = Boolean(senderStatus?.canSendImage)
const canSendVoice = Boolean(senderStatus?.canSendVoice)
const mediaReady = canSendImage || canSendVoice
const verificationComplete = canSendText && mediaReady
const allReady = verificationComplete
const progress = runtimeProgress || runtimeStatus
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
return (
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
<div className="personal-wechat-setup-heading">
<div>
<span className="personal-wechat-eyebrow">首次使用</span>
<h2>先准备好微信消息功能</h2>
<p>只需几步,TraceMemo 就能像普通聊天窗口一样发送文字、图片和语音。</p>
</div>
{allReady && <span className="personal-wechat-setup-complete">已完成</span>}
</div>
<ol className="personal-wechat-steps">
<li
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
>
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
<div className="personal-wechat-step-content">
<strong>准备语音模型</strong>
<p>发送语音消息需要本地模型,首次使用时下载一次即可。</p>
{runtimeDownloading ? (
<div className="personal-wechat-download-progress" aria-live="polite">
<div>
<span>正在准备模型</span>
<span>{progressPercent}%</span>
</div>
<div className="personal-wechat-progress-track">
<span style={{ width: `${progressPercent}%` }} />
</div>
</div>
) : runtimeReady ? (
<span className="personal-wechat-step-status">✓ 语音模型已准备</span>
) : (
<Button
size="sm"
onClick={onDownloadRuntime}
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
>
下载模型
</Button>
)}
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
<p className="personal-wechat-step-error">模型准备失败,请重试或查看语音设置。</p>
)}
</div>
</li>
<li
className={
connected
? 'is-complete'
: binding
? 'is-active'
: runtimeReady
? 'is-current'
: 'is-pending'
}
>
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
<div className="personal-wechat-step-content">
<strong>绑定个人微信</strong>
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
{connected ? (
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
) : (
<Button
size="sm"
variant="outline"
onClick={onBind}
disabled={!runtimeReady || binding || runtimeDownloading}
>
{binding ? '正在绑定…' : '绑定微信'}
</Button>
)}
{!connected && senderStatus && (
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
)}
{!connected && (
<p className="personal-wechat-step-warning" role="note">
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
<br />
微信总是自动更新?请在微信左下角打开“设置 → 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
</p>
)}
<Button
className="w-fit"
variant="link"
size="sm"
onClick={() => setShowSupportedVersions(true)}
>
查看支持的微信版本
</Button>
</div>
</li>
<li
className={verificationComplete ? 'is-complete' : connected ? 'is-current' : 'is-pending'}
>
<span className="personal-wechat-step-number">{verificationComplete ? '✓' : '3'}</span>
<div className="personal-wechat-step-content">
<strong>验证消息能力</strong>
<p>
请打开手机微信,给任意好友手动发送:
<br />① 一条文字消息
<br />② 一张图片
</p>
<p className="personal-wechat-step-hint">
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
</p>
{!verificationComplete && (
<Button
size="sm"
variant="outline"
onClick={onDetect}
disabled={detecting || binding}
>
{detecting ? '正在检测…' : '重新检测'}
</Button>
)}
{detectionAttempted && !detecting && !verificationComplete && (
<p className="personal-wechat-step-hint" role="status">
暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。
</p>
)}
</div>
</li>
<li
className={allReady ? 'is-complete' : verificationComplete ? 'is-current' : 'is-pending'}
>
<span className="personal-wechat-step-number">{allReady ? '✓' : '4'}</span>
<div className="personal-wechat-step-content">
<strong>能力检测</strong>
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
{[
['文字消息', canSendText],
['图片和语音消息', mediaReady]
].map(([label, ready]) => (
<span key={String(label)} className={ready ? 'is-ready' : ''}>
<b aria-hidden>{ready ? '✓' : '−'}</b>
{label}
<small>{capabilityLabel(Boolean(ready), detectionAttempted)}</small>
</span>
))}
</div>
{allReady ? (
<>
<span className="personal-wechat-step-status">微信消息发送已配置完成</span>
<Button size="sm" onClick={onStartSending}>
开始发送
</Button>
</>
) : verificationComplete ? (
<>
<p className="personal-wechat-step-hint">
语音能力还未就绪,请在微信中完成一次媒体消息初始化后重新检测。
</p>
<Button size="sm" variant="outline" onClick={onDetect} disabled={detecting}>
{detecting ? '正在检测…' : '重新检测能力'}
</Button>
</>
) : null}
</div>
</li>
</ol>
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
<Button
variant="link"
size="sm"
className="personal-wechat-setup-link"
onClick={onOpenTextToSpeechSettings}
>
查看语音设置
</Button>
)}
<Dialog open={showSupportedVersions} onOpenChange={setShowSupportedVersions}>
<DialogContent className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto">
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
<details className="personal-wechat-diagnostics">
<summary>高级诊断</summary>
<p>仅用于排查连接问题,普通使用无需关注这些信息。</p>
<dl>
{[
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动'],
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
[
'接口监听',
`${diagnosticValue(senderStatus?.endpoint)} · ${senderStatus?.endpointReady ? '监听中' : '未监听'}`
],
[
'基址扫描',
senderStatus?.baseAddress || (senderStatus?.baseAddressReady ? '已完成' : '未完成')
],
['文字 Hook', senderStatus?.textHookReady ? '已就绪' : '未就绪'],
['图片 Hook', senderStatus?.imageHookReady ? '已就绪' : '未就绪'],
['语音能力', senderStatus?.canSendVoice ? '可发送' : '未就绪'],
['消息监听', senderStatus?.messageListenerReady ? '正常' : '未就绪'],
['微信版本', diagnosticValue(senderStatus?.wechatVersion)],
[
'运行时',
runtimeStatus?.version
? `${runtimeStatus.version} · ${runtimeStatus.state}`
: diagnosticValue(senderStatus?.runtimeReady)
]
].map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</details>
</section>
)
}
@@ -0,0 +1,46 @@
import type React from 'react'
export const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
export 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
export function PersonalWechatSupportedVersionsContent(): React.ReactElement {
return (
<>
<a
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
href={WECHAT_VERSION_DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
>
下载微信历史版本 ↗
</a>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{BUNDLED_WECHAT_VERSIONS.map((version) => (
<span
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
key={version}
>
{version}
</span>
))}
</div>
</>
)
}
@@ -19,28 +19,11 @@ import {
SelectTrigger,
SelectValue
} from '../../../components/ui'
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
import { isMac, isWindows } from '../../../utils/runtime-environment'
const VOICE_PAGE_SIZE = 24
const 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: '未下载',
@@ -539,7 +522,7 @@ export function TextToSpeechPage({
<span>2</span>
<div>
<strong>在档案中生成语音</strong>
<p>打开联系人或群聊的测试发送,选择“语音 → 输入文字生成”。</p>
<p>打开联系人或群聊的发送,选择“语音 → 输入文字生成”。</p>
</div>
</li>
<li>
@@ -566,7 +549,11 @@ export function TextToSpeechPage({
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
会降低系统安全性,请确认风险后再使用。
</li>
<li>微信重新登录或 PID 改变后,需要在测试发送弹窗中点击“尝试重新绑定”。</li>
<li>微信重新登录或 PID 改变后,需要在发送窗口中重新检测或绑定。</li>
<li>
绑定前建议关闭微信自动升级 在微信左下角打开“设置 →
通用”,取消勾选“有更新时自动升级微信”。微信自动更新后,版本可能不再兼容发送组件。
</li>
</ul>
{personalWechatRuntimeSupported ? (
<Button
@@ -941,24 +928,7 @@ export function TextToSpeechPage({
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<a
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
href={WECHAT_VERSION_DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
>
下载微信历史版本 ↗
</a>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{BUNDLED_WECHAT_VERSIONS.map((version) => (
<span
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
key={version}
>
{version}
</span>
))}
</div>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
)
+509
View File
@@ -564,6 +564,515 @@
}
}
.personal-wechat-chat-header {
display: flex;
align-items: center;
gap: 11px;
min-height: 68px;
padding: 14px 20px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
}
.personal-wechat-chat-avatar {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 auto;
place-items: center;
border-radius: 9px;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 14px;
font-weight: 700;
}
.personal-wechat-chat-heading {
min-width: 0;
flex: 1;
h2,
p {
margin: 0;
}
h2 {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 14px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin-top: 2px;
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.personal-wechat-connection-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--wxex-text-muted);
&.is-online {
background: var(--wxex-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wxex-success) 14%, transparent);
}
}
.personal-wechat-chat-body {
display: grid;
min-height: 0;
background: var(--wxex-bg-main);
}
.personal-wechat-message-list {
display: flex;
min-height: 96px;
flex-direction: column;
gap: 8px;
padding: 12px 20px;
background: color-mix(in srgb, var(--wxex-bg-main) 92%, var(--wxex-bg-sidebar));
}
.personal-wechat-system-message,
.personal-wechat-empty-message {
align-self: center;
color: var(--wxex-text-muted);
font-size: 11px;
}
.personal-wechat-empty-message {
margin-top: 15px;
}
.personal-wechat-message-bubble {
display: grid;
max-width: min(76%, 440px);
gap: 3px;
align-self: flex-start;
border: 1px solid var(--wxex-border);
border-radius: 10px 10px 10px 3px;
background: var(--wxex-bg-elevated);
padding: 8px 10px;
color: var(--wxex-text-primary);
font-size: 12px;
&.is-outgoing {
align-self: flex-end;
border-color: color-mix(in srgb, var(--wxex-brand) 25%, transparent);
border-radius: 10px 10px 3px 10px;
background: color-mix(in srgb, var(--wxex-brand) 10%, var(--wxex-bg-elevated));
}
}
.personal-wechat-message-kind {
color: var(--wxex-text-muted);
font-size: 10px;
}
.personal-wechat-setup {
display: grid;
gap: 16px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
padding: 18px 20px 20px;
}
.personal-wechat-setup-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
h2,
p {
margin: 0;
}
h2 {
margin-top: 3px;
color: var(--wxex-text-primary);
font-size: 16px;
font-weight: 650;
}
p {
margin-top: 4px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 1.55;
}
}
.personal-wechat-eyebrow {
color: var(--wxex-brand);
font-size: 10px;
font-weight: 700;
}
.personal-wechat-setup-complete,
.personal-wechat-step-status {
color: var(--wxex-success);
font-size: 11px;
font-weight: 650;
}
.personal-wechat-steps {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
> li {
position: relative;
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 10px;
padding-bottom: 11px;
&:not(:last-child)::after {
position: absolute;
top: 23px;
bottom: 0;
left: 11px;
width: 1px;
background: var(--wxex-border);
content: '';
}
}
}
.personal-wechat-step-number {
z-index: 1;
display: grid;
width: 23px;
height: 23px;
place-items: center;
border: 1px solid var(--wxex-border);
border-radius: 50%;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
font-size: 11px;
font-weight: 700;
}
.personal-wechat-steps > li.is-current .personal-wechat-step-number,
.personal-wechat-steps > li.is-active .personal-wechat-step-number {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.personal-wechat-steps > li.is-complete .personal-wechat-step-number {
border-color: color-mix(in srgb, var(--wxex-success) 35%, transparent);
background: color-mix(in srgb, var(--wxex-success) 12%, var(--wxex-bg-elevated));
color: var(--wxex-success);
}
.personal-wechat-step-content {
display: grid;
gap: 7px;
min-width: 0;
> strong {
color: var(--wxex-text-primary);
font-size: 13px;
}
> p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 1.55;
}
}
.personal-wechat-step-hint {
color: var(--wxex-text-muted) !important;
}
.personal-wechat-step-warning {
margin: 0;
border-radius: var(--wxex-radius-md);
background: color-mix(in srgb, var(--wxex-warning) 10%, var(--wxex-bg-elevated));
padding: 9px 11px;
color: var(--wxex-text-secondary) !important;
font-size: 11px;
line-height: 1.55;
}
.personal-wechat-step-error,
.personal-wechat-global-error {
color: var(--wxex-danger) !important;
}
.personal-wechat-download-progress {
display: grid;
gap: 6px;
max-width: 360px;
> div:first-child {
display: flex;
justify-content: space-between;
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.personal-wechat-progress-track,
.personal-wechat-preview-track {
position: relative;
overflow: hidden;
height: 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--wxex-brand) 14%, transparent);
> span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--wxex-brand);
}
}
.personal-wechat-capabilities {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
> span {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 5px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
padding: 8px;
color: var(--wxex-text-muted);
font-size: 11px;
&.is-ready {
border-color: color-mix(in srgb, var(--wxex-success) 30%, transparent);
color: var(--wxex-text-primary);
}
&:not(.is-ready) {
background: color-mix(in srgb, var(--wxex-bg-main) 70%, var(--wxex-bg-elevated));
}
}
b {
color: var(--wxex-text-muted);
.is-ready & {
color: var(--wxex-success);
}
}
small {
grid-column: 2;
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.personal-wechat-diagnostics {
border-top: 1px solid var(--wxex-border);
padding-top: 12px;
summary {
cursor: pointer;
color: var(--wxex-text-secondary);
font-size: 11px;
font-weight: 600;
}
> p {
margin: 8px 0;
color: var(--wxex-text-muted);
font-size: 10px;
}
dl {
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);
}
dl > div {
min-width: 0;
background: var(--wxex-bg-main);
padding: 7px 9px;
}
dt {
color: var(--wxex-text-muted);
font-size: 10px;
}
dd {
overflow: hidden;
margin: 2px 0 0;
color: var(--wxex-text-secondary);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.personal-wechat-composer {
display: grid;
gap: 12px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
padding: 14px 20px 18px;
}
.personal-wechat-composer-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.personal-wechat-text-editor,
.personal-wechat-voice-editor,
.personal-wechat-file-editor {
display: grid;
gap: 8px;
}
.personal-wechat-text-editor textarea {
resize: vertical;
}
.personal-wechat-text-editor > span {
justify-self: end;
color: var(--wxex-text-muted);
font-size: 10px;
}
.personal-wechat-file-editor {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.personal-wechat-file-editor > div {
min-width: 0;
}
.personal-wechat-file-editor strong,
.personal-wechat-file-editor small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.personal-wechat-file-editor strong {
color: var(--wxex-text-primary);
font-size: 12px;
}
.personal-wechat-file-editor small {
margin-top: 3px;
color: var(--wxex-text-muted);
font-size: 10px;
}
.personal-wechat-voice-heading,
.personal-wechat-composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.personal-wechat-voice-heading > span {
color: var(--wxex-text-secondary);
font-size: 11px;
}
.personal-wechat-generated-result {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid color-mix(in srgb, var(--wxex-brand) 28%, transparent);
border-radius: var(--wxex-radius-md);
background: color-mix(in srgb, var(--wxex-brand) 7%, var(--wxex-bg-elevated));
padding: 9px;
audio {
display: none;
}
strong,
span {
display: block;
}
strong {
color: var(--wxex-text-primary);
font-size: 11px;
}
span {
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.personal-wechat-composer-footer > span {
flex: 1;
font-size: 11px;
&.is-success {
color: var(--wxex-success);
}
&.is-error {
color: var(--wxex-danger);
}
}
.personal-wechat-global-error {
border-top: 1px solid color-mix(in srgb, var(--wxex-danger) 18%, transparent);
padding: 10px 20px 0;
font-size: 11px;
}
.personal-wechat-chat-footer {
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--wxex-border);
padding: 12px 20px;
}
@media (max-width: 560px) {
.personal-wechat-capabilities {
grid-template-columns: 1fr;
}
.personal-wechat-chat-header,
.personal-wechat-message-list,
.personal-wechat-setup,
.personal-wechat-composer {
padding-inline: 14px;
}
.personal-wechat-generated-result {
grid-template-columns: 1fr;
}
}
.chat-header-actions svg,
.chat-content-search svg,
.data-trust-bar svg {
@@ -96,6 +96,35 @@
}
}
.tts-auto-update-guide {
display: grid;
gap: 6px;
border-radius: var(--wxex-radius-md);
background: color-mix(in srgb, var(--wxex-warning) 9%, var(--wxex-bg-elevated));
padding: 12px 13px;
strong {
color: var(--wxex-text-primary);
font-size: 12px;
}
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 10px;
line-height: 16px;
}
img {
width: min(100%, 640px);
max-height: 280px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
object-fit: contain;
object-position: left top;
}
}
.tts-hook-warning {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -1,10 +1,13 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
const getStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const downloadRuntime = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const rebind = vi.fn()
const selectImage = vi.fn()
const selectVoice = vi.fn()
@@ -20,7 +23,6 @@ const contact = {
md5: 'fixture-md5',
type: 'group' as const
}
const readyStatus = {
state: 'online' as const,
platform: 'darwin',
@@ -48,42 +50,44 @@ const readyStatus = {
canSendVoice: true,
message: '个人微信已绑定'
}
const readyRuntime = {
version: 'v0.0.18',
state: 'ready' as const,
downloadedBytes: 1,
totalBytes: 1,
progress: 1,
platform: 'darwin' as NodeJS.Platform,
architecture: 'arm64',
supported: true,
removable: true
}
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
打开测试发送
</button>
{open && (
<PersonalWechatSendDialog
contact={contact}
isGroupChat
onClose={() => {
onClose()
setOpen(false)
}}
/>
)}
</>
function renderDialog(
props: Partial<React.ComponentProps<typeof PersonalWechatSendDialog>> = {}
): React.ReactElement {
return render(
<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} {...props} />
)
}
async function startComposer(): Promise<void> {
const start = await screen.findByRole('button', { name: '开始发送' })
fireEvent.click(start)
}
describe('PersonalWechatSendDialog', () => {
beforeEach(() => {
getStatus.mockReset().mockResolvedValue(readyStatus)
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
rebind.mockReset().mockResolvedValue(readyStatus)
selectImage.mockReset().mockResolvedValue({
canceled: false,
path: '/Users/fixture/test.png',
name: 'test.png'
})
selectVoice.mockReset().mockResolvedValue({
canceled: false,
path: '/Users/fixture/test.silk',
name: 'test.silk'
})
selectImage
.mockReset()
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.png', name: 'test.png' })
selectVoice
.mockReset()
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.silk', name: 'test.silk' })
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
getTextToSpeechSettings.mockReset().mockResolvedValue({
success: true,
@@ -125,6 +129,9 @@ describe('PersonalWechatSendDialog', () => {
configurable: true,
value: {
getPersonalWechatSenderStatus: getStatus,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
downloadPersonalWechatRuntime: downloadRuntime,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
rebindPersonalWechatSender: rebind,
selectPersonalWechatImage: selectImage,
selectPersonalWechatVoice: selectVoice,
@@ -137,14 +144,84 @@ describe('PersonalWechatSendDialog', () => {
})
})
it('switches to voice mode and sends only the selected audio file', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
it('shows a user-facing four-step setup and keeps diagnostics collapsed', async () => {
getRuntimeStatus
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
.mockResolvedValue(readyRuntime)
getStatus.mockResolvedValue({
...readyStatus,
state: 'stopped',
runtimeReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '尚未绑定当前微信'
})
renderDialog()
expect(await screen.findByText('准备语音模型')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '下载模型' })).toBeEnabled()
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
expect(screen.getByText('能力检测')).toBeInTheDocument()
expect(screen.getByText('图片和语音消息')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' }))
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(versionsDialog).toBeInTheDocument()
expect(versionsDialog).toHaveTextContent('4.1.11.53')
expect(
screen.getByText(
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
)
).toBeInTheDocument()
expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument()
expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument()
expect(screen.queryByText('PID 4668')).not.toBeVisible()
fireEvent.click(screen.getByText('高级诊断'))
expect(screen.getByText('PID 4668')).toBeInTheDocument()
})
it('sends text through the existing message API and echoes it in the chat', async () => {
renderDialog()
await startComposer()
await userEvent
.setup()
.type(screen.getByRole('textbox', { name: '消息内容' }), '你好 TraceMemo')
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'text',
to: 'fixture-room@chatroom',
text: '你好 TraceMemo',
isGroup: true
})
)
expect(
screen.getByLabelText('消息列表').querySelector('.personal-wechat-message-bubble')
).toHaveTextContent('你好 TraceMemo')
})
it('supports local image and voice selection', async () => {
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
expect(await screen.findByText('test.png')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'image',
to: 'fixture-room@chatroom',
filePath: '/Users/fixture/test.png',
isGroup: true
})
)
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
expect(await screen.findByText('test.silk')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '测试发送语音到群聊' }))
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'voice',
@@ -155,136 +232,101 @@ describe('PersonalWechatSendDialog', () => {
)
})
it('shows the selected TTS voice and keeps generation separate from sending', async () => {
const openSettings = vi.fn()
render(
<PersonalWechatSendDialog
contact={contact}
isGroupChat
onClose={vi.fn()}
onOpenTextToSpeechSettings={openSettings}
/>
)
await screen.findByText('技术交流群')
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
expect(await screen.findByText('暖阳女声')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toHaveValue('1')
expect(screen.getByRole('button', { name: '生成语音' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '前往文字转语音设置' }))
expect(openSettings).toHaveBeenCalledTimes(1)
expect(sendMessage).not.toHaveBeenCalled()
})
it('shows binding diagnostics and only offers image and voice modes', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
expect(await screen.findByText('PID 4668')).toBeInTheDocument()
expect(screen.getByText('PID 5401 · 绑定 4668')).toBeInTheDocument()
expect(screen.getByText('0x114ef8000')).toBeInTheDocument()
expect(screen.getByText('已捕获,可发送')).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: '语音' })).toBeVisible()
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
})
it('supports segmented keyboard navigation for the message type', async () => {
const user = userEvent.setup()
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
const imageMode = screen.getByRole('radio', { name: '图片' })
const voiceMode = screen.getByRole('radio', { name: '语音' })
imageMode.focus()
await user.keyboard('{ArrowRight}')
expect(voiceMode).toHaveFocus()
await user.keyboard(' ')
expect(voiceMode).toBeChecked()
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
})
it('switches to image mode and sends only the selected image', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(
screen.getByText(
'如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。',
{ exact: false }
)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
expect(await screen.findByText('test.png')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'image',
to: 'fixture-room@chatroom',
filePath: '/Users/fixture/test.png',
isGroup: true
it('uses the existing runtime, binding and detection IPC actions', async () => {
getRuntimeStatus
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
.mockResolvedValue(readyRuntime)
getStatus
.mockResolvedValueOnce({
...readyStatus,
state: 'stopped',
runtimeReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false
})
)
.mockResolvedValue({
...readyStatus,
state: 'stopped',
runtimeReady: true,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false
})
renderDialog()
await screen.findByRole('button', { name: '下载模型' })
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
await waitFor(() => expect(rebind).toHaveBeenCalledOnce())
})
it('offers a safe explicit rebind action', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
fireEvent.click(screen.getByRole('button', { name: '尝试重新绑定' }))
await waitFor(() => expect(rebind).toHaveBeenCalledTimes(1))
it('shows a stable bound state after binding', async () => {
getRuntimeStatus.mockResolvedValue(readyRuntime)
getStatus
.mockResolvedValueOnce({
...readyStatus,
state: 'stopped',
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false
})
.mockResolvedValue(readyStatus)
renderDialog()
await screen.findByRole('button', { name: '绑定微信' })
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
expect(await screen.findByText('✓ 微信已绑定')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '绑定微信' })).not.toBeInTheDocument()
})
it('blocks image sending until the media Hook and image directory are initialized', async () => {
it('treats image and voice readiness as one media capability', async () => {
getStatus.mockResolvedValue({
...readyStatus,
state: 'hook_not_ready',
imageHookReady: false,
canSendImage: false,
message: '请先手动发送图片'
canSendVoice: true
})
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
renderDialog()
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
expect(screen.queryByText('图片消息')).not.toBeInTheDocument()
expect(screen.queryByText('语音消息')).not.toBeInTheDocument()
})
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<Harness onClose={onClose} />)
const opener = screen.getByRole('button', { name: '打开测试发送' })
await user.click(opener)
await screen.findByText('技术交流群')
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = await screen.findByRole('dialog', { name: '个人微信测试发送' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
expect(onClose).toHaveBeenCalledTimes(2)
it('keeps re-detection available and explains when no new messages are found', async () => {
getRuntimeStatus.mockResolvedValue(readyRuntime)
getStatus.mockResolvedValue({
...readyStatus,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '等待消息初始化'
})
renderDialog()
const detect = await screen.findByRole('button', { name: '重新检测' })
expect(detect).toBeEnabled()
fireEvent.click(detect)
expect(
await screen.findByText(
'暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。'
)
).toBeInTheDocument()
expect(screen.getAllByText('未检测').length).toBe(2)
})
it('keeps the dialog open while a message is being sent', async () => {
it('keeps the dialog and controls stable while sending', async () => {
const user = userEvent.setup()
sendMessage.mockImplementation(() => new Promise(() => undefined))
render(<Harness />)
await user.click(screen.getByRole('button', { name: '打开测试发送' }))
await screen.findByText('技术交流群')
await user.click(screen.getByRole('button', { name: '选择图片' }))
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
renderDialog()
await startComposer()
await screen.findByRole('textbox', { name: '消息内容' })
await user.type(screen.getByRole('textbox', { name: '消息内容' }), '发送中')
await user.click(screen.getByRole('button', { name: '发送消息' }))
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled()
expect(screen.getByRole('radio', { name: '语音' })).toBeDisabled()
await user.keyboard('{Escape}')
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
await user.click(
screen.getByRole('dialog', { name: '个人微信测试发送' }).previousElementSibling as HTMLElement
)
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
})
})
+1 -1
View File
@@ -608,7 +608,7 @@ describe('daily report controls', () => {
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
expect(await screen.findByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('测试群日报.png')).toBeVisible()
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
+4 -5
View File
@@ -190,11 +190,10 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
await fixture.page.getByText('产品测试群', { exact: true }).click()
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
await trigger.click()
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
await expect(dialog).toBeVisible()
await expect(
dialog.locator('.personal-wechat-send-status strong').filter({ hasText: '个人微信已绑定' })
).toBeVisible()
const startSending = dialog.getByRole('button', { name: '开始发送' })
if (await startSending.isVisible()) await startSending.click()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
@@ -208,7 +207,7 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
expect(pageErrors).toEqual([])
const imageMode = dialog.getByRole('radio', { name: '图片' })
const voiceMode = dialog.getByRole('radio', { name: '语音' })
const voiceMode = dialog.getByRole('radio', { name: '语音', exact: true })
await imageMode.focus()
await fixture.page.keyboard.press('ArrowRight')
await expect(voiceMode).toBeFocused()
+5 -3
View File
@@ -387,7 +387,7 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await fixture.page.getByRole('button', { name: '发送消息' }).click()
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
await expect(dialog).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
expect(
@@ -406,9 +406,11 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
caret: 'hide'
})
const startSending = dialog.getByRole('button', { name: '开始发送' })
if (await startSending.isVisible()) await startSending.click()
await dialog.getByRole('radio', { name: '语音' }).click()
await expect(dialog.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
await dialog.locator('footer').scrollIntoViewIfNeeded()
await expect(dialog.getByRole('textbox', { name: '语音文字' })).toBeVisible()
await dialog.locator('.personal-wechat-composer').scrollIntoViewIfNeeded()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`personal-wechat-voice-controls-${appearanceTheme}.png`,
+19
View File
@@ -6,6 +6,25 @@ function setPrivate(target: object, key: string, value: unknown): void {
}
describe('Wcdb4Client shutdown', () => {
it('clears folded contact status snapshots when sessions are invalidated', () => {
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
const statusCache = new Map([['group@chatroom', { isFolded: true, isMuted: false }]])
setPrivate(client, 'sessionCacheGeneration', 3)
setPrivate(client, 'cachedSessions', [{ username: 'group@chatroom' }])
setPrivate(client, 'cachedChatTables', [{ name: 'Chat_group', db_number: 'group@chatroom' }])
setPrivate(client, 'sessionStatusCache', statusCache)
setPrivate(client, 'sessionStatusesUpdatedAt', Date.now())
client.invalidateSessionCache()
expect(statusCache.size).toBe(0)
expect(client).toMatchObject({
cachedSessions: null,
cachedChatTables: null,
sessionStatusesUpdatedAt: 0
})
})
it('waits for tracked Koffi calls before shutting down the native runtime', async () => {
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
const shutdown = vi.fn(() => 0)