fix: 修复转换微信语音发送逻辑

This commit is contained in:
Wxw-Gu
2026-08-27 14:43:09 +08:00
parent b26d4dd1a7
commit 0d68217dda
18 changed files with 1020 additions and 125 deletions
@@ -206,7 +206,7 @@ export function PersonalWechatChatComposer({
const sendGenerated = async (): Promise<void> => {
if (!generatedVoiceRef.current) return
const result = await send(
await send(
{
type: 'voice',
to: targetId,
@@ -215,7 +215,20 @@ export function PersonalWechatChatComposer({
},
voiceText.trim() || '语音消息'
)
if (result) clearGeneratedVoice()
}
const sendGeneratedAlternative = async (): Promise<void> => {
if (!generatedVoiceRef.current) return
await send(
{
type: 'voice',
to: targetId,
filePath: generatedVoiceRef.current.filePath,
isGroup: isGroupChat,
voiceSendMode: 'legacy'
},
voiceText.trim() || '语音消息'
)
}
const canSend = Boolean(
@@ -341,7 +354,7 @@ export function PersonalWechatChatComposer({
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
</div>
</div>
<div>
<div className="personal-wechat-generated-result-actions">
<Button
variant="outline"
size="sm"
@@ -362,6 +375,15 @@ export function PersonalWechatChatComposer({
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
{busy ? '发送中…' : '发送'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void sendGeneratedAlternative()}
disabled={!canSend}
title="使用另一条语音发送路径"
>
{busy ? '发送中…' : '空白语音?重新处理'}
</Button>
</div>
</div>
) : (
@@ -2,19 +2,29 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { Contact } from '../../../../shared/types'
import type {
PersonalWechatSendRequest,
PersonalWechatSenderStatus
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} 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,
Switch
} from '../ui'
import {
PersonalWechatChatComposer,
type ChatMessage,
type PersonalWechatComposerMode
} from './PersonalWechatChatComposer'
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
type SelectedLocalFile = { path: string; name: string }
@@ -71,8 +81,15 @@ export function PersonalWechatSendDialog({
const [runtimeBusy, setRuntimeBusy] = useState(false)
const [sendBusy, setSendBusy] = useState(false)
const [detectionAttempted, setDetectionAttempted] = useState(false)
// Status can be reconstructed from a previous OneBot process/log. These
// session gates ensure the user explicitly binds and detects after opening
// the flow instead of inheriting stale readiness.
const [sessionBound, setSessionBound] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [sendError, setSendError] = useState<string | null>(null)
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
const [keepOneBotProcess, setKeepOneBotProcess] = useState(false)
const [composerStarted, setComposerStarted] = useState(Boolean(initialImage))
const requestIdRef = useRef(0)
const restoreFocusRef = useRef<HTMLElement | null>(null)
@@ -81,7 +98,10 @@ export function PersonalWechatSendDialog({
const targetId = contact.m_nsUsrName
const isBusy = binding || runtimeBusy || sendBusy
const setupReady = Boolean(
senderStatus?.canSendText && (senderStatus?.canSendImage || senderStatus?.canSendVoice)
sessionBound &&
detectionAttempted &&
senderStatus?.canSendText &&
(senderStatus?.canSendImage || senderStatus?.canSendVoice)
)
const refreshStatus = useCallback(async (): Promise<void> => {
@@ -97,6 +117,15 @@ export function PersonalWechatSendDialog({
setRuntimeStatus(nextRuntime)
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
setSenderStatus(nextSender)
setSessionBound(
nextSender.state === 'online' ||
Boolean(
nextSender.wechatPid &&
nextSender.boundWechatPid === nextSender.wechatPid &&
nextSender.attachReady &&
nextSender.baseAddressReady
)
)
} catch (error) {
if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error))
} finally {
@@ -114,6 +143,34 @@ export function PersonalWechatSendDialog({
return unsubscribe
}, [refreshStatus])
useEffect(() => {
let active = true
const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess
if (!readKeepProcess) return undefined
void readKeepProcess().then((keep) => {
if (active && typeof keep === 'boolean') setKeepOneBotProcess(keep)
})
return () => {
active = false
}
}, [])
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess
if (!saveKeepProcess) {
setSendError('请重启 TraceMemo 后再使用“保留 OneBot 进程”')
return
}
setKeepOneBotProcess(keep)
try {
const saved = await saveKeepProcess(keep)
if (typeof saved === 'boolean') setKeepOneBotProcess(saved)
} catch (error) {
setKeepOneBotProcess(!keep)
setSendError(error instanceof Error ? error.message : String(error))
}
}
const handleDownloadRuntime = async (): Promise<void> => {
if (runtimeBusy) return
setRuntimeBusy(true)
@@ -138,6 +195,15 @@ export function PersonalWechatSendDialog({
try {
const nextStatus = await window.api.rebindPersonalWechatSender()
setSenderStatus(nextStatus)
setSessionBound(
nextStatus.state === 'online' ||
Boolean(
nextStatus.wechatPid &&
nextStatus.boundWechatPid === nextStatus.wechatPid &&
nextStatus.attachReady &&
nextStatus.baseAddressReady
)
)
if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message)
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error))
@@ -185,103 +251,140 @@ export function PersonalWechatSendDialog({
}
}
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
setVoiceDiagnostic(diagnostic)
setVoiceDiagnosticOpen(true)
}
return (
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
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
}}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
>
<DialogHeader className="personal-wechat-chat-header">
<div className="personal-wechat-chat-avatar" aria-hidden>
{displayName.slice(0, 1)}
</div>
<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>
<>
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
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
}}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
>
<DialogHeader className="personal-wechat-chat-header">
<div className="personal-wechat-chat-avatar" aria-hidden>
{displayName.slice(0, 1)}
</div>
<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-chat-body">
<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>
)}
{(!setupReady || !composerStarted) && senderStatus && (
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
sessionBound={sessionBound}
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-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 className="personal-wechat-chat-footer flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>保留 OneBot 进程</span>
<Switch
checked={keepOneBotProcess}
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
aria-label="保留 OneBot 进程"
/>
</div>
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
语音发送诊断
</Button>
</div>
)}
{(!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}
{(!setupReady || !composerStarted) && (
<div className="personal-wechat-chat-footer flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>保留 OneBot 进程</span>
<Switch
checked={keepOneBotProcess}
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
aria-label="保留 OneBot 进程"
/>
</div>
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
关闭
</Button>
</div>
)}
</div>
{(!setupReady || !composerStarted) && (
<div className="personal-wechat-chat-footer">
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
关闭
</Button>
</div>
)}
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
<PersonalWechatVoiceDiagnosticDialog
open={voiceDiagnosticOpen}
diagnostic={voiceDiagnostic}
onOpenChange={setVoiceDiagnosticOpen}
/>
</>
)
}
@@ -15,6 +15,7 @@ interface PersonalWechatSetupGuideProps {
binding: boolean
detecting: boolean
detectionAttempted: boolean
sessionBound: boolean
onDownloadRuntime: () => void
onBind: () => void
onDetect: () => void
@@ -61,6 +62,7 @@ export function PersonalWechatSetupGuide({
binding,
detecting,
detectionAttempted,
sessionBound,
onDownloadRuntime,
onBind,
onDetect,
@@ -70,12 +72,14 @@ export function PersonalWechatSetupGuide({
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 connected = sessionBound && (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 detectedTextReady = detectionAttempted && canSendText
const detectedMediaReady = detectionAttempted && mediaReady
const verificationComplete = connected && detectedTextReady && detectedMediaReady
const allReady = verificationComplete
const progress = runtimeProgress || runtimeStatus
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
@@ -160,7 +164,8 @@ export function PersonalWechatSetupGuide({
<p className="personal-wechat-step-warning" role="note">
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
<br />
微信总是自动更新?请在微信左下角打开“设置 → 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
微信总是自动更新?请在微信左下角打开“设置 →
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
</p>
)}
<Button
@@ -188,7 +193,7 @@ export function PersonalWechatSetupGuide({
<p className="personal-wechat-step-hint">
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
</p>
{!verificationComplete && (
{connected && !verificationComplete && (
<Button
size="sm"
variant="outline"
@@ -214,8 +219,8 @@ export function PersonalWechatSetupGuide({
<strong>能力检测</strong>
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
{[
['文字消息', canSendText],
['图片和语音消息', mediaReady]
['文字消息', detectedTextReady],
['图片和语音消息', detectedMediaReady]
].map(([label, ready]) => (
<span key={String(label)} className={ready ? 'is-ready' : ''}>
<b aria-hidden>{ready ? '✓' : '−'}</b>
@@ -0,0 +1,55 @@
import { useMemo, useState } from 'react'
import type { PersonalWechatVoiceDiagnostic } from '../../../../shared/personal-wechat'
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui'
interface PersonalWechatVoiceDiagnosticDialogProps {
open: boolean
diagnostic: PersonalWechatVoiceDiagnostic | null
onOpenChange: (open: boolean) => void
}
export function PersonalWechatVoiceDiagnosticDialog({
open,
diagnostic,
onOpenChange
}: PersonalWechatVoiceDiagnosticDialogProps): React.ReactElement {
const [copied, setCopied] = useState(false)
const json = useMemo(() => (diagnostic ? JSON.stringify(diagnostic, null, 2) : ''), [diagnostic])
const copyDiagnostic = async (): Promise<void> => {
if (!json) return
const result = await window.api.copyText(json)
setCopied(result.success)
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) setCopied(false)
onOpenChange(nextOpen)
}}
>
<DialogContent className="max-w-[560px]">
<DialogHeader>
<DialogTitle>语音发送诊断</DialogTitle>
</DialogHeader>
{json ? (
<pre className="max-h-[min(50vh,420px)] overflow-auto rounded-md border border-border-subtle bg-muted/40 p-3 text-xs leading-5 text-foreground">
{json}
</pre>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">暂无诊断信息</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button onClick={() => void copyDiagnostic()} disabled={!json}>
{copied ? '已复制' : '复制诊断 JSON'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}