feat: 增加windows发送能力

抽离功能到设置页面
抽离文字转语音功能
This commit is contained in:
电摇小子
2026-08-31 02:44:24 +08:00
parent 698251796f
commit 5acfa14475
33 changed files with 2226 additions and 905 deletions
+2
View File
@@ -1753,6 +1753,7 @@ function App(): React.ReactElement {
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
onOpenTextToSpeechSettings={openTextToSpeechSettings}
onOpenPersonalWechatSettings={openWechatSendSettings}
isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime}
/>
@@ -1821,6 +1822,7 @@ function App(): React.ReactElement {
onReveal={handleRevealReport}
onSwitchTemplate={handleSwitchReportTemplate}
sendTarget={selectedReportContact}
onOpenPersonalWechatSettings={openWechatSendSettings}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div>
@@ -21,6 +21,7 @@ interface ChatWindowProps {
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
onOpenTextToSpeechSettings?: () => void
onOpenPersonalWechatSettings?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
}
@@ -38,6 +39,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onLoadOlderMessages,
onCreateGroupReport,
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
isAiLoading = false,
jumpToTime
}) => {
@@ -171,6 +173,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
isGroupChat={isGroupChat}
onClose={() => setSendDialogOpen(false)}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
onOpenPersonalWechatSettings={onOpenPersonalWechatSettings}
/>
)}
@@ -20,6 +20,7 @@ export type ChatMessage = {
}
interface PersonalWechatChatComposerProps {
className?: string
status: PersonalWechatSenderStatus
targetId: string
isGroupChat: boolean
@@ -42,6 +43,7 @@ function formatAudioTime(value: number): string {
}
export function PersonalWechatChatComposer({
className,
status,
targetId,
isGroupChat,
@@ -245,7 +247,10 @@ export function PersonalWechatChatComposer({
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
return (
<section className="personal-wechat-composer" aria-label="发送消息">
<section
className={`personal-wechat-composer${className ? ` ${className}` : ''}`}
aria-label="发送消息"
>
<SegmentedControl
className="personal-wechat-composer-mode"
aria-label="消息类型"
@@ -262,158 +267,167 @@ export function PersonalWechatChatComposer({
<SegmentedControlItem value="voice">语音</SegmentedControlItem>
</SegmentedControl>
{mode === 'text' && (
<div className="personal-wechat-text-editor">
<Textarea
aria-label="消息内容"
placeholder="输入消息"
value={text}
maxLength={2000}
rows={3}
disabled={isBusy}
onChange={(event) => {
setText(event.target.value)
setFeedback(null)
}}
/>
<span>{text.length} / 2000</span>
</div>
)}
{mode === 'image' && (
<div className="personal-wechat-file-editor">
<div>
<strong>{image?.name || '还没有选择图片'}</strong>
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
<div className="personal-wechat-composer-editor">
{mode === 'text' && (
<div className="personal-wechat-text-editor">
<Textarea
aria-label="消息内容"
placeholder="输入消息"
value={text}
maxLength={2000}
rows={3}
disabled={isBusy}
onChange={(event) => {
setText(event.target.value)
setFeedback(null)
}}
/>
<span>{text.length} / 2000</span>
</div>
<Button variant="outline" size="sm" onClick={() => void selectImage()} disabled={isBusy}>
{image ? '重新选择' : '选择图片'}
</Button>
</div>
)}
)}
{mode === 'voice' && (
<div className="personal-wechat-voice-editor">
<SegmentedControl
aria-label="语音来源"
value={voiceSource}
onValueChange={(value) => {
clearGeneratedVoice()
setVoiceSource(value as VoiceSource)
setFeedback(null)
}}
disabled={isBusy}
>
<SegmentedControlItem value="generated">输入文字生成</SegmentedControlItem>
<SegmentedControlItem value="file">选择本地文件</SegmentedControlItem>
</SegmentedControl>
{voiceSource === 'generated' ? (
<>
<div className="personal-wechat-voice-heading">
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
{onOpenTextToSpeechSettings && (
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings}>
语音设置
</Button>
)}
</div>
<Textarea
aria-label="语音文字"
placeholder="输入要生成的文字"
value={voiceText}
maxLength={1000}
rows={2}
disabled={isBusy}
onChange={(event) => {
clearGeneratedVoice()
setVoiceText(event.target.value)
setFeedback(null)
}}
/>
{generatedVoice ? (
<div className="personal-wechat-generated-result">
<audio
ref={generatedAudioRef}
src={generatedVoice.audioDataUrl}
preload="metadata"
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
onTimeUpdate={(event) => setPreviewCurrentTime(event.currentTarget.currentTime)}
onPause={() => setIsPreviewPlaying(false)}
onPlay={() => setIsPreviewPlaying(true)}
onEnded={() => {
setIsPreviewPlaying(false)
setPreviewCurrentTime(0)
}}
/>
<div>
<strong>语音已生成</strong>
<span>
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
</span>
<div className="personal-wechat-preview-track">
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
{mode === 'image' && (
<div className="personal-wechat-file-editor">
<div>
<strong>{image?.name || '还没有选择图片'}</strong>
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void selectImage()}
disabled={isBusy}
>
{image ? '重新选择' : '选择图片'}
</Button>
</div>
)}
{mode === 'voice' && (
<div className="personal-wechat-voice-editor">
<SegmentedControl
aria-label="语音来源"
value={voiceSource}
onValueChange={(value) => {
clearGeneratedVoice()
setVoiceSource(value as VoiceSource)
setFeedback(null)
}}
disabled={isBusy}
>
<SegmentedControlItem value="generated">输入文字生成</SegmentedControlItem>
<SegmentedControlItem value="file">选择本地文件</SegmentedControlItem>
</SegmentedControl>
{voiceSource === 'generated' ? (
<>
<div className="personal-wechat-voice-heading">
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
{onOpenTextToSpeechSettings && (
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings}>
语音设置
</Button>
)}
</div>
<Textarea
aria-label="语音文字"
placeholder="输入要生成的文字"
value={voiceText}
maxLength={1000}
rows={2}
disabled={isBusy}
onChange={(event) => {
clearGeneratedVoice()
setVoiceText(event.target.value)
setFeedback(null)
}}
/>
{generatedVoice ? (
<div className="personal-wechat-generated-result">
<audio
ref={generatedAudioRef}
src={generatedVoice.audioDataUrl}
preload="metadata"
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
onTimeUpdate={(event) =>
setPreviewCurrentTime(event.currentTarget.currentTime)
}
onPause={() => setIsPreviewPlaying(false)}
onPlay={() => setIsPreviewPlaying(true)}
onEnded={() => {
setIsPreviewPlaying(false)
setPreviewCurrentTime(0)
}}
/>
<div>
<strong>语音已生成</strong>
<span>
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
</span>
<div className="personal-wechat-preview-track">
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
</div>
</div>
<div className="personal-wechat-generated-result-actions">
<Button
variant="outline"
size="sm"
onClick={async () => {
const audio = generatedAudioRef.current
if (!audio) return
if (audio.paused) {
await audio.play()
setIsPreviewPlaying(true)
} else {
audio.pause()
}
}}
disabled={busy}
>
{isPreviewPlaying ? '暂停' : '试听'}
</Button>
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
{busy ? '发送中…' : '发送'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void sendGeneratedAlternative()}
disabled={!canSend}
title="使用另一条语音发送路径"
>
{busy ? '发送中…' : '空白语音?重新处理'}
</Button>
</div>
</div>
<div className="personal-wechat-generated-result-actions">
<Button
variant="outline"
size="sm"
onClick={async () => {
const audio = generatedAudioRef.current
if (!audio) return
if (audio.paused) {
await audio.play()
setIsPreviewPlaying(true)
} else {
audio.pause()
}
}}
disabled={busy}
>
{isPreviewPlaying ? '暂停' : '试听'}
</Button>
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
{busy ? '发送中…' : '发送'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void sendGeneratedAlternative()}
disabled={!canSend}
title="使用另一条语音发送路径"
>
{busy ? '发送中…' : '空白语音?重新处理'}
</Button>
</div>
) : (
<Button
size="sm"
onClick={() => void generateVoice()}
disabled={!generatedReady || isBusy}
>
{isGenerating ? '正在生成…' : '生成语音'}
</Button>
)}
</>
) : (
<div className="personal-wechat-file-editor">
<div>
<strong>{voice?.name || '还没有选择语音文件'}</strong>
<small>{voice ? voice.path : '支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC'}</small>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => void generateVoice()}
disabled={!generatedReady || isBusy}
onClick={() => void selectVoice()}
disabled={isBusy}
>
{isGenerating ? '正在生成…' : '生成语音'}
{voice ? '重新选择' : '选择语音'}
</Button>
)}
</>
) : (
<div className="personal-wechat-file-editor">
<div>
<strong>{voice?.name || '还没有选择语音文件'}</strong>
<small>{voice ? voice.path : '支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC'}</small>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void selectVoice()}
disabled={isBusy}
>
{voice ? '重新选择' : '选择语音'}
</Button>
</div>
)}
</div>
)}
)}
</div>
)}
</div>
<div className="personal-wechat-composer-footer">
{feedback && (
@@ -18,6 +18,7 @@ import {
DialogTitle,
Switch
} from '../ui'
import { isMac, isWindows } from '../../utils/runtime-environment'
import {
PersonalWechatChatComposer,
type ChatMessage,
@@ -25,14 +26,16 @@ import {
} from './PersonalWechatChatComposer'
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
type SelectedLocalFile = { path: string; name: string }
interface PersonalWechatSendDialogProps {
export interface PersonalWechatSendDialogProps {
contact: Contact
isGroupChat: boolean
onClose: () => void
onOpenTextToSpeechSettings?: () => void
onOpenPersonalWechatSettings?: () => void
initialMode?: PersonalWechatComposerMode
initialImage?: SelectedLocalFile | null
}
@@ -63,7 +66,12 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
}
}
export function PersonalWechatSendDialog({
export function PersonalWechatSendDialog(props: PersonalWechatSendDialogProps): React.ReactElement {
if (isWindows) return <PersonalWechatWindowsSendDialog {...props} />
return <PersonalWechatMacSendDialog {...props} />
}
function PersonalWechatMacSendDialog({
contact,
isGroupChat,
onClose,
@@ -110,7 +118,9 @@ export function PersonalWechatSendDialog({
setSendError(null)
try {
const [nextRuntime, nextSender] = await Promise.all([
window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null),
isMac
? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null)
: Promise.resolve(null),
window.api.getPersonalWechatSenderStatus()
])
if (requestId !== requestIdRef.current) return
@@ -135,6 +145,7 @@ export function PersonalWechatSendDialog({
useEffect(() => {
void refreshStatus()
if (!isMac) return undefined
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
setRuntimeProgress(status)
setRuntimeStatus(status)
@@ -144,6 +155,7 @@ export function PersonalWechatSendDialog({
}, [refreshStatus])
useEffect(() => {
if (!isMac) return undefined
let active = true
const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess
if (!readKeepProcess) return undefined
@@ -156,6 +168,7 @@ export function PersonalWechatSendDialog({
}, [])
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
if (!isMac) return
const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess
if (!saveKeepProcess) {
setSendError('请重启 TraceMemo 后再使用“保留 OneBot 进程”')
@@ -252,6 +265,7 @@ export function PersonalWechatSendDialog({
}
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
if (!isMac) return
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
setVoiceDiagnostic(diagnostic)
setVoiceDiagnosticOpen(true)
@@ -348,7 +362,7 @@ export function PersonalWechatSendDialog({
</div>
)}
</div>
{setupReady && composerStarted && (
{setupReady && composerStarted && isMac && (
<div className="personal-wechat-chat-footer flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>保留 OneBot 进程</span>
@@ -364,15 +378,19 @@ export function PersonalWechatSendDialog({
</div>
)}
{(!setupReady || !composerStarted) && (
<div className="personal-wechat-chat-footer flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>保留 OneBot 进程</span>
<Switch
checked={keepOneBotProcess}
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
aria-label="保留 OneBot 进程"
/>
</div>
<div
className={`personal-wechat-chat-footer flex items-center ${isMac ? 'justify-between' : 'justify-end'}`}
>
{isMac && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>保留 OneBot 进程</span>
<Switch
checked={keepOneBotProcess}
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
aria-label="保留 OneBot 进程"
/>
</div>
)}
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
关闭
</Button>
@@ -5,6 +5,7 @@ import type {
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { isMac } from '../../utils/runtime-environment'
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
interface PersonalWechatSetupGuideProps {
@@ -37,8 +38,10 @@ function bindingHint(status: PersonalWechatSenderStatus): string {
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
if (status.state === 'unsupported_version')
return '当前微信版本暂不支持,请查看语音设置中的支持版本。'
if (status.state === 'runtime_missing') return '请先完成语音模型准备。'
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
if (status.state === 'runtime_missing') {
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
}
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
}
@@ -70,6 +73,7 @@ export function PersonalWechatSetupGuide({
onOpenTextToSpeechSettings
}: PersonalWechatSetupGuideProps): React.ReactElement {
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
@@ -101,12 +105,12 @@ export function PersonalWechatSetupGuide({
>
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
<div className="personal-wechat-step-content">
<strong>准备语音模型</strong>
<p>发送语音消息需要本地模型,首次使用时下载一次即可。</p>
<strong>准备 {runtimeLabel}</strong>
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
{runtimeDownloading ? (
<div className="personal-wechat-download-progress" aria-live="polite">
<div>
<span>正在准备模型</span>
<span>正在准备 {runtimeLabel}</span>
<span>{progressPercent}%</span>
</div>
<div className="personal-wechat-progress-track">
@@ -114,18 +118,20 @@ export function PersonalWechatSetupGuide({
</div>
</div>
) : runtimeReady ? (
<span className="personal-wechat-step-status">✓ 语音模型已准备</span>
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
) : (
<Button
size="sm"
onClick={onDownloadRuntime}
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
>
下载模型
下载运行时
</Button>
)}
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
<p className="personal-wechat-step-error">模型准备失败,请重试或查看语音设置。</p>
<p className="personal-wechat-step-error">
运行时准备失败,请重试或查看微信发送设置。
</p>
)}
</div>
</li>
@@ -277,7 +283,9 @@ export function PersonalWechatSetupGuide({
<dl>
{[
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动'],
...(isMac
? [['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动']]
: []),
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
[
'接口监听',
@@ -0,0 +1,241 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
PersonalWechatSendRequest,
PersonalWechatSenderStatus
} from '../../../../shared/personal-wechat'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
return {
state: 'error',
platform: 'win32',
arch: 'unknown',
sipDisabled: true,
wechatRunning: false,
runtimeReady: true,
endpoint: '',
endpointReady: false,
attachReady: true,
baseAddressReady: true,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '无法检测 Windows 微信发送能力',
error: error instanceof Error ? error.message : String(error)
}
}
function statusLabel(status: PersonalWechatSenderStatus | null): string {
if (!status) return '正在检测'
if (status.state === 'online' && status.canSend) return '微信发送能力已就绪'
if (status.state === 'online') return '微信发送能力未就绪'
if (status.state === 'hook_not_ready') return '微信发送能力初始化中'
if (status.state === 'wechat_not_running') return '微信未运行'
if (status.state === 'unsupported_version') return '微信版本不匹配'
if (status.state === 'error') return '绑定异常'
return '微信发送能力未配置'
}
function statusText(value: string): string {
return value.replace(/OneBot|Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
}
function statusDescription(status: PersonalWechatSenderStatus | null): string {
if (!status) return '正在检查本地 Windows 微信发送能力'
return statusText(status.message)
}
export function PersonalWechatWindowsSendDialog({
contact,
isGroupChat,
onClose,
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
initialMode = 'text',
initialImage = null
}: PersonalWechatSendDialogProps): React.ReactElement {
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [detecting, setDetecting] = useState(true)
const [sendBusy, setSendBusy] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const requestIdRef = useRef(0)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName
const refreshStatus = useCallback(async (): Promise<void> => {
const requestId = ++requestIdRef.current
setDetecting(true)
try {
const nextStatus = await window.api.getPersonalWechatSenderStatus()
if (requestId === requestIdRef.current) setStatus(nextStatus)
} catch (error) {
if (requestId === requestIdRef.current) setStatus(fallbackStatus(error))
} finally {
if (requestId === requestIdRef.current) setDetecting(false)
}
}, [])
useEffect(() => {
void refreshStatus()
}, [refreshStatus])
const handleClose = useCallback((): void => {
if (sendBusy || closingRef.current) return
closingRef.current = true
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
onClose()
queueMicrotask(() => restoreFocus?.focus())
}, [onClose, sendBusy])
const handleOpenPersonalWechatSettings = (): void => {
if (!onOpenPersonalWechatSettings || sendBusy) return
handleClose()
onOpenPersonalWechatSettings()
}
const handleSend = async (
request: PersonalWechatSendRequest
): Promise<{ success: boolean; error?: string }> => {
setSendBusy(true)
try {
const response = await window.api.sendPersonalWechatMessage({ ...request, to: targetId })
setStatus(response.status)
return { success: response.success, error: response.error }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setStatus((current) => ({
...(current || fallbackStatus(error)),
state: 'error',
error: message
}))
return { success: false, error: message }
} finally {
setSendBusy(false)
}
}
const statusTone = useMemo(() => {
if (status?.state === 'online' && status.canSend) return 'ready'
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
return 'blocked'
}, [status])
return (
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[620px] gap-4 overflow-y-auto p-[22px]"
onOpenAutoFocus={() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
}}
onEscapeKeyDown={(event) => sendBusy && event.preventDefault()}
onPointerDownOutside={(event) => sendBusy && event.preventDefault()}
>
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
<div>
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
个人微信发送
</DialogTitle>
</div>
<DialogDescription className="sr-only">
向当前微信联系人或群聊发送文字、图片和语音。
</DialogDescription>
</DialogHeader>
<div className="personal-wechat-send-device-note" role="note">
<span aria-hidden>i</span>
<p>
<strong>请在其他设备确认发送结果</strong>
语音、图片等消息请切换到手机、平板等其他设备确认是否送达。
</p>
</div>
<div className="personal-wechat-send-target">
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
<strong>{displayName}</strong>
<code>{targetId}</code>
</div>
<div className={`personal-wechat-send-status ${statusTone}`}>
<span className="personal-wechat-send-status-dot" />
<div>
<strong>{statusLabel(status)}</strong>
<p>{statusDescription(status)}</p>
{status?.error ? <small className="error">{statusText(status.error)}</small> : null}
</div>
<div className="personal-wechat-send-status-actions">
{status && !status.canSend && onOpenPersonalWechatSettings ? (
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={handleOpenPersonalWechatSettings}
disabled={sendBusy}
>
前往发送能力设置
</Button>
) : null}
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={() => void refreshStatus()}
disabled={sendBusy || detecting}
>
{detecting ? '检测中…' : '重新检测'}
</Button>
</div>
</div>
{status ? (
<>
{messages.length > 0 ? (
<div className="personal-wechat-message-list" aria-label="消息列表">
{messages.map((message) => (
<div
key={message.id}
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
>
<span className="personal-wechat-message-kind">
{message.type === 'text'
? '文字'
: message.type === 'image'
? '图片'
: '语音'}
</span>
<span>{message.text || message.fileName}</span>
</div>
))}
</div>
) : null}
<PersonalWechatChatComposer
status={status}
targetId={targetId}
isGroupChat={isGroupChat}
initialMode={initialMode}
initialImage={initialImage}
className="personal-wechat-windows-composer"
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
onCancel={handleClose}
onSend={handleSend}
onMessage={(message) => setMessages((current) => [...current, message])}
busy={sendBusy}
/>
</>
) : null}
</DialogContent>
</Dialog>
)
}
@@ -22,6 +22,7 @@ interface ReportViewerProps {
) => Promise<{ success: boolean; error?: string }>
sendTarget?: Contact | null
personalWechatSendSupported?: boolean
onOpenPersonalWechatSettings?: () => void
}
const calculateFitZoom = (
@@ -50,7 +51,8 @@ export function ReportViewer({
onReveal,
onSwitchTemplate,
sendTarget = null,
personalWechatSendSupported = supportsPersonalWechatSend
personalWechatSendSupported = supportsPersonalWechatSend,
onOpenPersonalWechatSettings
}: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1)
const [fitZoom, setFitZoom] = useState(1)
@@ -276,6 +278,7 @@ export function ReportViewer({
name: report.pngPath.split(/[\\/]/).pop() || '群聊日报.png'
}}
onClose={() => setSendDialogOpen(false)}
onOpenPersonalWechatSettings={onOpenPersonalWechatSettings}
/>
)}
</main>
@@ -104,6 +104,13 @@ export function SettingsWorkspace({
)
case 'text-to-speech':
return <TextToSpeechPage onNotice={onNotice} />
case 'personal-wechat-send':
return (
<PersonalWechatSendPage
onNotice={onNotice}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
)
case 'recall-protection':
return <RecallProtectionPage onNotice={onNotice} />
case 'advanced':
@@ -46,5 +46,6 @@ export const SETTINGS_CATEGORY_LABELS = {
...Object.fromEntries(
SETTINGS_NAVIGATION.flatMap((group) => group.items.map((item) => [item.id, item.label]))
),
'personal-wechat-send': '微信发送',
'storage-export': '存储与导出'
} as Record<SettingsCategoryId, string>
@@ -5,6 +5,7 @@ export type SettingsCategoryId =
| 'voice-recognition'
| 'wechat-send'
| 'text-to-speech'
| 'personal-wechat-send'
| 'ai-model'
| 'recall-protection'
| 'local-api'
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactElement } from 'react'
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
import type {
PersonalWechatSendCapability,
PersonalWechatSenderStatus
@@ -7,8 +7,33 @@ import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../../shared/personal-wechat-runtime'
import { Button, Skeleton } from '../../../components/ui'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Skeleton
} from '../../../components/ui'
import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide'
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
import { isMac, isWindows } from '../../../utils/runtime-environment'
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
missing: '未下载',
downloading: '下载中',
ready: '已就绪',
invalid: '需要修复',
error: '下载失败',
unsupported: '暂不支持'
}
function formatBytes(value: number): string {
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> = {
unsupported: '暂不支持',
@@ -19,6 +44,13 @@ const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> =
error: '异常'
}
function normalizeWindowsPort(value: string): string | null {
const text = value.trim()
if (!/^\d{1,5}$/.test(text)) return null
const port = Number(text)
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? String(port) : null
}
function boundToCurrentWechat(status: PersonalWechatSenderStatus | null): boolean {
if (!status) return false
return (
@@ -51,8 +83,21 @@ export function PersonalWechatSendPage({
const [detecting, setDetecting] = useState(false)
const [detectionAttempted, setDetectionAttempted] = useState(false)
const [error, setError] = useState('')
const [windowsSenderStatus, setWindowsSenderStatus] = useState<PersonalWechatSenderStatus | null>(
null
)
const [windowsPortInput, setWindowsPortInput] = useState('')
const [windowsDetectedPort, setWindowsDetectedPort] = useState('')
const [windowsEndpointBusy, setWindowsEndpointBusy] = useState(false)
const [showWechatVersions, setShowWechatVersions] = useState(false)
const runtimeVersionsTriggerRef = useRef<HTMLButtonElement | null>(null)
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
const refresh = useCallback(async (): Promise<void> => {
if (isWindows) {
setLoading(false)
return
}
setError('')
try {
const [nextCapability, nextSender, nextRuntime] = await Promise.all([
@@ -72,19 +117,53 @@ export function PersonalWechatSendPage({
}, [])
useEffect(() => {
if (isWindows) return
void refresh()
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
const subscribe = window.api.onPersonalWechatRuntimeProgress
if (!subscribe) return
const unsubscribe = subscribe((status) => {
setRuntimeProgress(status)
setRuntimeStatus(status)
if (status.state === 'ready') void refresh()
})
return unsubscribe
return () => unsubscribe?.()
}, [refresh])
useEffect(() => {
if (!isWindows) return
let active = true
const loadWindowsEndpoint = async (): Promise<void> => {
try {
const settingsResult = await window.api.getSettings()
const configuredPort = String(settingsResult.settings.windowsWechatPort || '').trim()
if (!active) return
setWindowsPortInput(configuredPort)
if (configuredPort) {
const status = await window.api.getPersonalWechatSenderStatus()
if (!active) return
setWindowsSenderStatus(status)
}
setLoading(false)
} catch (reason) {
if (!active) return
setWindowsSenderStatus(null)
setError(reason instanceof Error ? reason.message : '微信发送能力状态读取失败')
setLoading(false)
}
}
void loadWindowsEndpoint()
return () => {
active = false
}
}, [refresh])
const downloadRuntime = async (): Promise<void> => {
if (runtimeBusy) return
if (runtimeBusy || !personalWechatRuntimeSupported) return
setRuntimeBusy(true)
setError('')
setRuntimeStatus((current) =>
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
)
try {
const result = await window.api.downloadPersonalWechatRuntime()
setRuntimeStatus(result.status)
@@ -97,6 +176,41 @@ export function PersonalWechatSendPage({
}
}
const cancelRuntimeDownload = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
await window.api.cancelPersonalWechatRuntimeDownload()
onNotice('正在取消发送组件下载')
}
const removeRuntime = async (): Promise<void> => {
if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return
if (!window.confirm('卸载微信发送组件?以后需要发送个人微信消息时可以重新下载。')) return
setRuntimeBusy(true)
try {
setRuntimeStatus(await window.api.removePersonalWechatRuntime())
onNotice('微信发送组件已卸载')
} catch (reason) {
onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败')
} finally {
setRuntimeBusy(false)
}
}
const openRuntimeDirectory = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
const result = await window.api.openPersonalWechatRuntimeDirectory()
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
}
const refreshRuntime = async (): Promise<void> => {
if (!isMac || runtimeBusy) return
try {
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
} catch (reason) {
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
}
}
const bindWechat = async (): Promise<void> => {
if (binding) return
setBinding(true)
@@ -116,6 +230,10 @@ export function PersonalWechatSendPage({
}
const detectCapability = async (): Promise<void> => {
if (isWindows) {
await detectWindowsEndpoint()
return
}
if (detecting) return
setDetecting(true)
setDetectionAttempted(true)
@@ -126,91 +244,379 @@ export function PersonalWechatSendPage({
}
}
const normalizedWindowsPort = normalizeWindowsPort(windowsPortInput)
const windowsCanSave = Boolean(
normalizedWindowsPort &&
normalizedWindowsPort === windowsDetectedPort &&
windowsSenderStatus?.endpointReady &&
windowsSenderStatus.canSend
)
const handleWindowsPortInput = (value: string): void => {
setWindowsPortInput(value)
setWindowsDetectedPort('')
setWindowsSenderStatus(null)
}
const detectWindowsEndpoint = async (): Promise<void> => {
if (windowsEndpointBusy) return
if (!normalizedWindowsPort) {
onNotice('请输入 1 到 65535 之间的微信发送能力端口')
return
}
setWindowsEndpointBusy(true)
setError('')
setWindowsDetectedPort('')
try {
const nextStatus = await window.api.checkPersonalWechatSenderStatus(normalizedWindowsPort)
setWindowsSenderStatus(nextStatus)
if (nextStatus.endpointReady && nextStatus.canSend) {
setWindowsDetectedPort(normalizedWindowsPort)
onNotice('已检测到微信发送能力,请点击保存')
} else {
onNotice(nextStatus.error || nextStatus.message || '未检测到微信发送能力')
}
} catch (reason) {
onNotice(reason instanceof Error ? reason.message : '微信发送能力检测失败')
} finally {
setWindowsEndpointBusy(false)
}
}
const saveWindowsEndpoint = async (): Promise<void> => {
if (windowsEndpointBusy || !windowsCanSave || !normalizedWindowsPort) return
setWindowsEndpointBusy(true)
try {
await window.api.setSettings({ windowsWechatPort: normalizedWindowsPort })
setWindowsDetectedPort('')
await refresh()
onNotice('微信发送能力端口已保存')
} catch (reason) {
onNotice(reason instanceof Error ? reason.message : '微信发送能力端口保存失败')
} finally {
setWindowsEndpointBusy(false)
}
}
const clearWindowsEndpoint = async (): Promise<void> => {
if (windowsEndpointBusy || !windowsPortInput.trim()) return
setWindowsEndpointBusy(true)
setError('')
try {
await window.api.setSettings({ windowsWechatPort: '' })
setWindowsPortInput('')
setWindowsDetectedPort('')
setWindowsSenderStatus(null)
await refresh()
onNotice('微信发送能力端口已清除')
} catch (reason) {
onNotice(reason instanceof Error ? reason.message : '微信发送能力端口清除失败')
} finally {
setWindowsEndpointBusy(false)
}
}
const status = capability?.status || 'error'
const ready = capability?.ready === true
const pageStatus: PersonalWechatSendCapability['status'] = isWindows
? windowsSenderStatus?.canSend
? 'ready'
: windowsSenderStatus?.state === 'error' && windowsSenderStatus.endpointReady
? 'error'
: windowsPortInput.trim()
? 'needs_verification'
: 'unconfigured'
: status
const pageReady = isWindows ? Boolean(windowsSenderStatus?.canSend) : ready
const pageCapabilities = isWindows
? {
text: Boolean(windowsSenderStatus?.canSendText),
image: Boolean(windowsSenderStatus?.canSendImage),
voice: Boolean(windowsSenderStatus?.canSendVoice)
}
: capability?.capabilities || { text: false, image: false, voice: false }
const pageMessage = isWindows
? windowsSenderStatus?.message || '请输入端口并检测 Windows 微信发送能力'
: capability?.message || '微信发送能力暂不可用'
return (
<div className="settings-page personal-wechat-send-page">
<header className="settings-page-header">
<div>
<h1>微信发送</h1>
<p>管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。</p>
</div>
<span
className={`settings-status-badge ${loading ? 'checking' : ready ? '' : status === 'unsupported' ? 'unavailable' : 'warning'}`}
>
{loading ? '检测中' : capabilityLabel[status]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
{loading && !capability ? (
<Skeleton className="h-28 w-full" />
) : (
<>
<h2 className="settings-section-heading">发送能力</h2>
<section className="settings-card">
<div className="flex items-start justify-between gap-4">
<div>
<span className="settings-card-kicker">个人微信</span>
<strong className="mt-1 block text-base">
{capability?.message || '微信发送能力暂不可用'}
</strong>
<p className="mt-2 text-sm text-muted-foreground">
{status === 'unsupported'
? '微信消息发送目前仅支持 macOS。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '检测中…' : '重新检测'}
</Button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', capability?.capabilities.text],
['图片', capability?.capabilities.image],
['语音', capability?.capabilities.voice]
] as const
).map(([label, available]) => (
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<strong className="mt-1 block text-sm">
{available ? '可发送' : '未就绪'}
</strong>
<>
<div className="settings-page personal-wechat-send-page">
<header className="settings-page-header">
<div>
<h1>微信发送</h1>
<p>管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。</p>
</div>
<span
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
>
{loading ? '检测中' : capabilityLabel[pageStatus]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
{loading && !capability ? (
<Skeleton className="h-28 w-full" />
) : (
<>
<h2 className="settings-section-heading">发送能力</h2>
<section className="settings-card">
<div className="flex items-start justify-between gap-4">
<div>
<span className="settings-card-kicker">个人微信</span>
<strong className="mt-1 block text-base">{pageMessage}</strong>
<p className="mt-2 text-sm text-muted-foreground">
{pageStatus === 'unsupported' && !isWindows
? '微信消息发送目前仅支持 macOS。'
: isWindows
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
</p>
</div>
))}
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</section>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '检测中…' : '重新检测'}
</Button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', pageCapabilities.text],
['图片', pageCapabilities.image],
['语音', pageCapabilities.voice]
] as const
).map(([label, available]) => (
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<strong className="mt-1 block text-sm">
{available ? '可发送' : '未就绪'}
</strong>
</div>
))}
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</section>
<h2 className="settings-section-heading">配置与检测</h2>
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
detectionAttempted={detectionAttempted}
sessionBound={boundToCurrentWechat(senderStatus)}
onDownloadRuntime={() => void downloadRuntime()}
onBind={() => void bindWechat()}
onDetect={() => void detectCapability()}
onStartSending={() =>
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
</>
)}
{isWindows ? (
<>
<h2 className="settings-section-heading">Windows 接口配置</h2>
<section className="settings-card tts-runtime-card">
<div className="tts-runtime-summary">
<span className="settings-card-kicker">Windows</span>
<strong>
{!windowsSenderStatus
? windowsPortInput
? '待检测'
: '未配置'
: windowsSenderStatus.canSend
? '微信发送能力已就绪'
: windowsSenderStatus.endpointReady
? '接口已连接,但暂不可发送'
: '未检测到发送能力'}
</strong>
<small>
{windowsSenderStatus?.message || '输入端口后检测微信发送能力'}
</small>
</div>
<div className="tts-runtime-actions tts-windows-endpoint-actions">
<label className="tts-windows-port-field">
<span>端口</span>
<Input
aria-label="微信发送能力端口"
className="tts-windows-port-input"
type="number"
min={1}
max={65535}
inputMode="numeric"
placeholder="端口号"
value={windowsPortInput}
disabled={windowsEndpointBusy}
onChange={(event) => handleWindowsPortInput(event.target.value)}
/>
</label>
<Button
variant={windowsCanSave ? 'default' : 'outline'}
size="sm"
disabled={windowsEndpointBusy}
onClick={() =>
void (windowsCanSave ? saveWindowsEndpoint() : detectWindowsEndpoint())
}
>
{windowsEndpointBusy
? windowsCanSave
? '保存中…'
: '检测中…'
: windowsCanSave
? '保存'
: '检测'}
</Button>
<Button
variant="ghost"
size="sm"
disabled={windowsEndpointBusy || !windowsPortInput.trim()}
onClick={() => void clearWindowsEndpoint()}
>
清除端口
</Button>
</div>
</section>
</>
) : isMac ? (
<>
<h2 className="settings-section-heading">OneBot 运行时</h2>
<section className="settings-card tts-runtime-card">
<div className="tts-runtime-summary">
<span className="settings-card-kicker">
OneBot {runtimeStatus?.version || 'v0.0.18'}
</span>
<strong>
{runtimeStatus?.state === 'downloading'
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
: runtimeStatus
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
: '正在检测'}
</strong>
<small>
{!runtimeStatus
? '正在检测当前平台与组件状态'
: personalWechatRuntimeSupported
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
: '当前 Mac 环境不满足个人微信发送组件要求'}
</small>
{runtimeStatus?.error ? (
<p className="tts-runtime-error">{runtimeStatus.error}</p>
) : null}
</div>
<div className="tts-runtime-actions">
{personalWechatRuntimeSupported &&
runtimeStatus?.state === 'downloading' ? (
<Button
variant="outline"
size="sm"
onClick={() => void cancelRuntimeDownload()}
>
取消下载
</Button>
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
<>
{runtimeStatus.directory ? (
<Button
variant="outline"
size="sm"
onClick={() => void openRuntimeDirectory()}
>
打开目录
</Button>
) : null}
{runtimeStatus.removable ? (
<Button
variant="destructive"
size="sm"
disabled={runtimeBusy}
onClick={() => void removeRuntime()}
>
卸载组件
</Button>
) : null}
</>
) : personalWechatRuntimeSupported ? (
<Button
size="sm"
disabled={runtimeBusy}
onClick={() => void downloadRuntime()}
>
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
? '重新下载'
: '下载组件'}
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={runtimeBusy}
onClick={() => void refreshRuntime()}
>
重新检测组件
</Button>
{personalWechatRuntimeSupported ? (
<Button
variant="outline"
size="sm"
onClick={(event) => {
runtimeVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
支持版本
</Button>
) : null}
</div>
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<div className="tts-runtime-progress">
<div>
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
<small>
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
{formatBytes(runtimeStatus.totalBytes)}
</small>
</div>
<progress
value={runtimeStatus.progress}
max={1}
aria-label="微信发送组件下载进度"
/>
</div>
) : null}
</section>
<h2 className="settings-section-heading">配置与检测</h2>
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
detectionAttempted={detectionAttempted}
sessionBound={boundToCurrentWechat(senderStatus)}
onDownloadRuntime={() => void downloadRuntime()}
onBind={() => void bindWechat()}
onDetect={() => void detectCapability()}
onStartSending={() =>
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
</>
) : null}
</>
)}
</div>
</div>
</div>
</div>
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
<DialogContent
className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto"
onCloseAutoFocus={(event) => {
const trigger = runtimeVersionsTriggerRef.current
if (!trigger) return
event.preventDefault()
trigger.focus()
runtimeVersionsTriggerRef.current = null
}}
>
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
</>
)
}
@@ -4,14 +4,8 @@ import type {
TextToSpeechSettings,
TextToSpeechVoice
} from '../../../../../shared/text-to-speech'
import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Select,
SelectContent,
@@ -19,20 +13,8 @@ import {
SelectTrigger,
SelectValue
} from '../../../components/ui'
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
import { isMac, isWindows } from '../../../utils/runtime-environment'
const VOICE_PAGE_SIZE = 24
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
missing: '未下载',
downloading: '下载中',
ready: '已就绪',
invalid: '需要修复',
error: '下载失败',
unsupported: '暂不支持'
}
const VOICE_FILTERS = [
{ value: 'Chinese', label: '中文' },
@@ -139,11 +121,6 @@ function compactCount(value?: number): string {
}).format(value)
}
function formatBytes(value: number): string {
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function voiceMetaTags(voice: TextToSpeechVoice): string[] {
return Array.from(new Set([...voice.languages, ...voice.tags])).filter(Boolean)
}
@@ -191,13 +168,8 @@ export function TextToSpeechPage({
const [savingKey, setSavingKey] = useState(false)
const [savingVoiceId, setSavingVoiceId] = useState('')
const [playingVoiceId, setPlayingVoiceId] = useState('')
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
const [runtimeBusy, setRuntimeBusy] = useState(false)
const [showWechatVersions, setShowWechatVersions] = useState(false)
const [error, setError] = useState('')
const audioRef = useRef<HTMLAudioElement | null>(null)
const wechatVersionsTriggerRef = useRef<HTMLButtonElement | null>(null)
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
const loadVoices = useCallback(
async (nextPage: number, title: string, append = false, tags: string[] = []): Promise<void> => {
@@ -254,35 +226,6 @@ export function TextToSpeechPage({
}
}, [loadVoices])
useEffect(() => {
try {
if (sessionStorage.getItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY) !== '1') return
sessionStorage.removeItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY)
setShowWechatVersions(true)
} catch {
// The page remains usable if session storage is unavailable.
}
}, [])
useEffect(() => {
let active = true
void window.api
.getPersonalWechatRuntimeStatus()
.then((status) => active && setRuntimeStatus(status))
.catch((reason) => {
if (!active) return
setRuntimeStatus(null)
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
})
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
if (active) setRuntimeStatus(status)
})
return () => {
active = false
unsubscribe()
}
}, [])
const selectedVoice = voices.find((voice) => voice.id === selectedVoiceId)
const visibleVoices = useMemo(() => voices, [voices])
@@ -431,51 +374,6 @@ export function TextToSpeechPage({
if (!result.success) onNotice(result.error || '无法打开 API Key 页面')
}
const refreshRuntime = async (): Promise<void> => {
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
}
const downloadRuntime = async (): Promise<void> => {
if (runtimeBusy || !isMac || !runtimeStatus?.supported) return
setRuntimeBusy(true)
setRuntimeStatus((current) =>
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
)
try {
const result = await window.api.downloadPersonalWechatRuntime()
setRuntimeStatus(result.status)
onNotice(result.success ? '微信发送组件已准备好' : result.error || '发送组件下载失败')
} finally {
setRuntimeBusy(false)
}
}
const cancelRuntimeDownload = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
await window.api.cancelPersonalWechatRuntimeDownload()
onNotice('正在取消发送组件下载')
}
const removeRuntime = async (): Promise<void> => {
if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return
if (!window.confirm('卸载微信发送组件?以后需要向个人微信发送语音时可以重新下载。')) return
setRuntimeBusy(true)
try {
setRuntimeStatus(await window.api.removePersonalWechatRuntime())
onNotice('微信发送组件已卸载')
} catch (reason) {
onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败')
} finally {
setRuntimeBusy(false)
}
}
const openRuntimeDirectory = async (): Promise<void> => {
if (!personalWechatRuntimeSupported) return
const result = await window.api.openPersonalWechatRuntimeDirectory()
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
}
const keyStatusText = !settings
? '正在读取 API Key 状态'
: settings.keySource === 'secure-storage'
@@ -487,449 +385,289 @@ export function TextToSpeechPage({
: '当前系统安全存储不可用,请从应用环境中提供 API Key'
return (
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
<div className="settings-page text-to-speech-page">
<header className="settings-page-header">
<div>
<h1>文字转语音</h1>
<p>选择喜欢的音色,把文字生成为可发送的微信语音</p>
</div>
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
</span>
</header>
<div className="settings-page text-to-speech-page">
<header className="settings-page-header">
<div>
<h1>文字转语音</h1>
<p>配置语音服务和音色,把文字生成为自然语音</p>
</div>
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content text-to-speech-content">
<section className="tts-usage-guide">
<div className="tts-usage-guide-heading">
<div>
<span className="tts-experimental-badge">实验性功能</span>
<h2>使用说明</h2>
</div>
<p>语音生成与微信发送是两个独立步骤。建议先生成并试听,确认无误后再发送。</p>
</div>
<ol className="tts-usage-steps">
<li>
<span>1</span>
<div>
<strong>配置并选择音色</strong>
<p>保存 API Key,在下方音色库中选择一个音色。</p>
</div>
</li>
<li>
<span>2</span>
<div>
<strong>在档案中生成语音</strong>
<p>打开联系人或群聊的发送,选择“语音 → 输入文字生成”。</p>
</div>
</li>
<li>
<span>3</span>
<div>
<strong>试听后手动发送</strong>
<p>点击“生成语音”,播放检查内容,再点击发送到当前会话。</p>
</div>
</li>
</ol>
<div className="tts-hook-warning">
<div className="tts-hook-warning-icon" aria-hidden>
!
</div>
<div>
<strong>微信发送能力注意事项</strong>
<ul>
<li>
仅支持与当前 OneBot 运行时配置匹配的 macOS
微信版本;微信自动更新后需要重新确认兼容性。
</li>
<li>
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
会降低系统安全性,请确认风险后再使用。
</li>
<li>微信重新登录或 PID 改变后,需要在发送窗口中重新检测或绑定。</li>
<li>
绑定前建议关闭微信自动升级 在微信左下角打开“设置 →
通用”,取消勾选“有更新时自动升级微信”。微信自动更新后,版本可能不再兼容发送组件。
</li>
</ul>
{personalWechatRuntimeSupported ? (
<Button
className="mt-2"
variant="outline"
size="sm"
onClick={(event) => {
wechatVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
查看支持版本
</Button>
) : null}
</div>
</div>
</section>
<h2 className="settings-section-heading">微信发送组件</h2>
<section className="settings-card tts-runtime-card">
<div className="tts-runtime-summary">
<span className="settings-card-kicker">
OneBot {runtimeStatus?.version || 'v0.0.18'}
</span>
<strong>
{!isMac
? '暂不支持'
: runtimeStatus?.state === 'downloading'
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
: runtimeStatus
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
: '正在检测'}
</strong>
<small>
{isWindows
? 'Windows 暂不支持个人微信发送;仍可生成和试听语音'
: !isMac
? '当前平台暂不支持个人微信发送'
: !runtimeStatus
? '正在检测当前平台与组件状态'
: personalWechatRuntimeSupported
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
: '当前 Mac 环境不满足个人微信发送组件要求'}
</small>
{runtimeStatus?.error ? (
<p className="tts-runtime-error">{runtimeStatus.error}</p>
) : null}
</div>
<div className="tts-runtime-actions">
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<Button variant="outline" size="sm" onClick={() => void cancelRuntimeDownload()}>
取消下载
</Button>
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
<>
{runtimeStatus.directory ? (
<Button
variant="outline"
size="sm"
onClick={() => void openRuntimeDirectory()}
>
打开目录
</Button>
) : null}
{runtimeStatus.removable ? (
<Button
variant="destructive"
size="sm"
disabled={runtimeBusy}
onClick={() => void removeRuntime()}
>
卸载组件
</Button>
) : null}
</>
) : personalWechatRuntimeSupported ? (
<Button size="sm" disabled={runtimeBusy} onClick={() => void downloadRuntime()}>
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
? '重新下载'
: '下载组件'}
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={runtimeBusy}
onClick={() => void refreshRuntime()}
>
重新检测
</Button>
{personalWechatRuntimeSupported ? (
<Button
variant="outline"
size="sm"
onClick={(event) => {
wechatVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
支持版本
</Button>
) : null}
</div>
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<div className="tts-runtime-progress">
<div>
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
<small>
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
{formatBytes(runtimeStatus.totalBytes)}
</small>
</div>
<progress
value={runtimeStatus.progress}
max={1}
aria-label="微信发送组件下载进度"
/>
</div>
) : null}
</section>
<h2 className="settings-section-heading">API 设置</h2>
<section className="settings-card tts-api-card">
<div className="tts-api-heading">
<div>
<span className="settings-card-kicker">语音服务</span>
<strong>API Key</strong>
<p>保存后即可加载音色并生成语音。</p>
</div>
<Button variant="outline" size="sm" onClick={() => void openApiKeys()}>
前往 api.fish.audio 获取 Key
</Button>
</div>
<label className="tts-api-input">
<span>API Key</span>
<div>
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
disabled={savingKey || !settings?.encryptionAvailable}
placeholder={
settings?.hasStoredApiKey
? '已安全保存;输入新 Key 可替换'
: settings?.hasEnvironmentApiKey
? '当前使用环境变量;也可保存一个应用专用 Key'
: '粘贴 API Key'
}
autoComplete="off"
onChange={(event) => setApiKey(event.target.value)}
/>
<Button variant="outline" onClick={() => setShowApiKey((current) => !current)}>
{showApiKey ? '隐藏' : '显示'}
</Button>
<Button
className="tts-save-key-button"
disabled={!apiKey.trim() || savingKey || !settings?.encryptionAvailable}
onClick={() => void saveApiKey()}
>
{savingKey ? '保存中…' : '保存 Key'}
</Button>
</div>
</label>
<div className="tts-api-footer">
<span>{keyStatusText}</span>
{settings?.hasStoredApiKey ? (
<Button
variant="destructive"
size="sm"
disabled={savingKey}
onClick={() => void clearApiKey()}
>
清除应用内 Key
</Button>
) : null}
</div>
<div className="tts-model-select">
<span>合成模型</span>
<Select
value={settings?.model || 's2.1-pro-free'}
disabled={!settings}
onValueChange={(value) => void changeModel(value as TextToSpeechModel)}
>
<SelectTrigger aria-label="合成模型">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s2.1-pro-free">s2.1-pro-free</SelectItem>
<SelectItem value="s2.1-pro">s2.1-pro</SelectItem>
</SelectContent>
</Select>
</div>
</section>
<div className="tts-voice-heading">
<div className="settings-page-scroll">
<div className="settings-page-content text-to-speech-content">
<section className="tts-usage-guide">
<div className="tts-usage-guide-heading">
<div>
<h2 className="settings-section-heading">选择音色</h2>
<span className="tts-experimental-badge">实验性功能</span>
<h2>使用说明</h2>
</div>
<p>配置服务、选择音色,然后在需要使用语音的会话中生成并试听。</p>
</div>
<ol className="tts-usage-steps">
<li>
<span>1</span>
<div>
<strong>配置语音服务</strong>
<p>保存 API Key,在下方音色库中选择一个音色。</p>
</div>
</li>
<li>
<span>2</span>
<div>
<strong>输入文字生成</strong>
<p>在会话的语音工具中选择“输入文字生成”,提交需要转换的内容。</p>
</div>
</li>
<li>
<span>3</span>
<div>
<strong>试听生成结果</strong>
<p>点击“生成语音”后播放结果,确认内容和音色效果。</p>
</div>
</li>
</ol>
</section>
<h2 className="settings-section-heading">API 设置</h2>
<section className="settings-card tts-api-card">
<div className="tts-api-heading">
<div>
<span className="settings-card-kicker">语音服务</span>
<strong>API Key</strong>
<p>保存后即可加载音色并生成语音。</p>
</div>
<Button variant="outline" size="sm" onClick={() => void openApiKeys()}>
前往 api.fish.audio 获取 Key
</Button>
</div>
<label className="tts-api-input">
<span>API Key</span>
<div>
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
disabled={savingKey || !settings?.encryptionAvailable}
placeholder={
settings?.hasStoredApiKey
? '已安全保存;输入新 Key 可替换'
: settings?.hasEnvironmentApiKey
? '当前使用环境变量;也可保存一个应用专用 Key'
: '粘贴 API Key'
}
autoComplete="off"
onChange={(event) => setApiKey(event.target.value)}
/>
<Button variant="outline" onClick={() => setShowApiKey((current) => !current)}>
{showApiKey ? '隐藏' : '显示'}
</Button>
<Button
className="tts-save-key-button"
disabled={!apiKey.trim() || savingKey || !settings?.encryptionAvailable}
onClick={() => void saveApiKey()}
>
{savingKey ? '保存中…' : '保存 Key'}
</Button>
</div>
</label>
<div className="tts-api-footer">
<span>{keyStatusText}</span>
{settings?.hasStoredApiKey ? (
<Button
variant="destructive"
size="sm"
disabled={savingKey}
onClick={() => void clearApiKey()}
>
清除应用内 Key
</Button>
) : null}
</div>
<div className="tts-model-select">
<span>合成模型</span>
<Select
value={settings?.model || 's2.1-pro-free'}
disabled={!settings}
onValueChange={(value) => void changeModel(value as TextToSpeechModel)}
>
<SelectTrigger aria-label="合成模型">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s2.1-pro-free">s2.1-pro-free</SelectItem>
<SelectItem value="s2.1-pro">s2.1-pro</SelectItem>
</SelectContent>
</Select>
</div>
</section>
<div className="tts-voice-heading">
<div>
<h2 className="settings-section-heading">选择音色</h2>
<p>
当前已加载 {voices.length} 个音色{total ? `,共找到 ${total} 个` : ''}。
</p>
</div>
<form
className="tts-voice-search"
onSubmit={(event) => {
event.preventDefault()
void searchVoices()
}}
>
<span aria-hidden>⌕</span>
<Input
type="search"
value={query}
aria-label="按音色名称搜索"
placeholder="按音色名称搜索"
disabled={!settings?.hasApiKey || loadingVoices}
onChange={(event) => setQuery(event.target.value)}
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={!settings?.hasApiKey || loadingVoices}
>
搜索
</Button>
</form>
</div>
<div className="tts-voice-filters" aria-label="音色筛选">
<button
type="button"
className={!selectedTags.length ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void clearVoiceFilters()}
>
全部
</button>
{VOICE_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
className={selectedTags.includes(filter.value) ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void toggleVoiceFilter(filter.value)}
>
{filter.label}
</button>
))}
</div>
{selectedVoice ? (
<section className="tts-current-voice">
<VoiceAvatar voice={selectedVoice} />
<div>
<span className="settings-card-kicker">当前音色</span>
<strong>{selectedVoice.name}</strong>
<p>
当前已加载 {voices.length} 个音色{total ? `,共找到 ${total} 个` : ''}。
{selectedVoice.authorName ? `${selectedVoice.authorName} · ` : ''}
{selectedVoice.description}
</p>
</div>
<form
className="tts-voice-search"
onSubmit={(event) => {
event.preventDefault()
void searchVoices()
}}
>
<span aria-hidden>⌕</span>
<Input
type="search"
value={query}
aria-label="按音色名称搜索"
placeholder="按音色名称搜索"
disabled={!settings?.hasApiKey || loadingVoices}
onChange={(event) => setQuery(event.target.value)}
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={!settings?.hasApiKey || loadingVoices}
>
搜索
</Button>
</form>
</div>
<div className="tts-voice-filters" aria-label="音色筛选">
<button
type="button"
className={!selectedTags.length ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void clearVoiceFilters()}
>
全部
</button>
{VOICE_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
className={selectedTags.includes(filter.value) ? 'active' : ''}
disabled={!settings?.hasApiKey || loadingVoices}
onClick={() => void toggleVoiceFilter(filter.value)}
>
{filter.label}
</button>
))}
</div>
{selectedVoice ? (
<section className="tts-current-voice">
<VoiceAvatar voice={selectedVoice} />
<div>
<span className="settings-card-kicker">当前音色</span>
<strong>{selectedVoice.name}</strong>
<p>
{selectedVoice.authorName ? `${selectedVoice.authorName} · ` : ''}
{selectedVoice.description}
</p>
</div>
<div className="tts-current-tags">
{voiceMetaTags(selectedVoice)
.slice(0, 5)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(selectedVoice).length > 5 ? (
<span>+{voiceMetaTags(selectedVoice).length - 5}</span>
) : null}
</div>
</section>
) : null}
{!settings?.hasApiKey && !loading ? (
<div className="settings-card tts-voice-empty">
<strong>先配置 API Key</strong>
<span>保存后即可加载并选择音色。</span>
<div className="tts-current-tags">
{voiceMetaTags(selectedVoice)
.slice(0, 5)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(selectedVoice).length > 5 ? (
<span>+{voiceMetaTags(selectedVoice).length - 5}</span>
) : null}
</div>
) : (
<div className="tts-voice-grid" role="radiogroup" aria-label="可用音色">
{visibleVoices.map((voice) => {
const selected = voice.id === selectedVoiceId
return (
<article
key={voice.id}
className={`tts-voice-card ${selected ? 'selected' : ''}`}
</section>
) : null}
{!settings?.hasApiKey && !loading ? (
<div className="settings-card tts-voice-empty">
<strong>先配置 API Key</strong>
<span>保存后即可加载并选择音色。</span>
</div>
) : (
<div className="tts-voice-grid" role="radiogroup" aria-label="可用音色">
{visibleVoices.map((voice) => {
const selected = voice.id === selectedVoiceId
return (
<article
key={voice.id}
className={`tts-voice-card ${selected ? 'selected' : ''}`}
>
<button
type="button"
role="radio"
aria-checked={selected}
className="tts-voice-select"
disabled={Boolean(savingVoiceId)}
onClick={() => void selectVoice(voice)}
>
<button
type="button"
role="radio"
aria-checked={selected}
className="tts-voice-select"
disabled={Boolean(savingVoiceId)}
onClick={() => void selectVoice(voice)}
>
<VoiceAvatar voice={voice} />
<span className="tts-voice-copy">
<span className="tts-voice-title-row">
<strong>{voice.name}</strong>
{voice.authorName ? <em>{voice.authorName}</em> : null}
</span>
<small>{voice.description || '公开音色'}</small>
<span className="tts-voice-tags">
{voiceMetaTags(voice)
.slice(0, 4)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(voice).length > 4 ? (
<span>+{voiceMetaTags(voice).length - 4}</span>
) : null}
</span>
<span className="tts-voice-stats">
<span title="使用量">▥ {compactCount(voice.taskCount)}</span>
<span title="喜欢">♡ {compactCount(voice.likeCount)}</span>
{voice.markCount ? (
<span title="收藏">☆ {compactCount(voice.markCount)}</span>
) : null}
</span>
<VoiceAvatar voice={voice} />
<span className="tts-voice-copy">
<span className="tts-voice-title-row">
<strong>{voice.name}</strong>
{voice.authorName ? <em>{voice.authorName}</em> : null}
</span>
<span className="tts-voice-check">
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
<small>{voice.description || '公开音色'}</small>
<span className="tts-voice-tags">
{voiceMetaTags(voice)
.slice(0, 4)
.map((tag) => (
<span key={tag}>{voiceTagLabel(tag)}</span>
))}
{voiceMetaTags(voice).length > 4 ? (
<span>+{voiceMetaTags(voice).length - 4}</span>
) : null}
</span>
</button>
<Button
variant="outline"
size="sm"
disabled={!voice.previewUrl}
onClick={() => playPreview(voice)}
>
{playingVoiceId === voice.id ? '停止' : '试听'}
</Button>
</article>
)
})}
</div>
)}
<span className="tts-voice-stats">
<span title="使用量">▥ {compactCount(voice.taskCount)}</span>
<span title="喜欢">♡ {compactCount(voice.likeCount)}</span>
{voice.markCount ? (
<span title="收藏">☆ {compactCount(voice.markCount)}</span>
) : null}
</span>
</span>
<span className="tts-voice-check">
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
</span>
</button>
<Button
variant="outline"
size="sm"
disabled={!voice.previewUrl}
onClick={() => playPreview(voice)}
>
{playingVoiceId === voice.id ? '停止' : '试听'}
</Button>
</article>
)
})}
</div>
)}
{loadingVoices ? <div className="tts-voice-loading">正在加载音色…</div> : null}
{!loadingVoices && settings?.hasApiKey && !voices.length ? (
<div className="settings-card tts-voice-empty">
{appliedQuery ? `没有找到“${appliedQuery}”相关音色` : '没有获取到可用音色'}
</div>
) : null}
{hasMore ? (
<Button
variant="outline"
className="tts-load-more"
disabled={loadingVoices}
onClick={() => void loadVoices(pageNumber + 1, appliedQuery, true, selectedTags)}
>
加载更多音色
</Button>
) : null}
{error ? <p className="tts-settings-error">{error}</p> : null}
</div>
{loadingVoices ? <div className="tts-voice-loading">正在加载音色…</div> : null}
{!loadingVoices && settings?.hasApiKey && !voices.length ? (
<div className="settings-card tts-voice-empty">
{appliedQuery ? `没有找到“${appliedQuery}”相关音色` : '没有获取到可用音色'}
</div>
) : null}
{hasMore ? (
<Button
variant="outline"
className="tts-load-more"
disabled={loadingVoices}
onClick={() => void loadVoices(pageNumber + 1, appliedQuery, true, selectedTags)}
>
加载更多音色
</Button>
) : null}
{error ? <p className="tts-settings-error">{error}</p> : null}
</div>
</div>
<DialogContent
className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto"
onCloseAutoFocus={(event) => {
const trigger = wechatVersionsTriggerRef.current
if (!trigger) return
event.preventDefault()
trigger.focus()
wechatVersionsTriggerRef.current = null
}}
>
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
</div>
)
}
+62 -2
View File
@@ -275,7 +275,32 @@
}
.personal-wechat-send-mode {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 3px;
border: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
padding: 3px;
> [role='radio'] {
min-width: 0;
min-height: 34px;
border-radius: var(--wxex-radius-sm);
padding: 0 10px;
color: var(--wxex-text-secondary);
line-height: 1.2;
white-space: nowrap;
&:hover:not(:disabled):not([data-state='checked']) {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
&[data-state='checked'] {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: var(--wxex-shadow-popover);
}
}
}
.personal-wechat-send-status-dot {
@@ -944,6 +969,22 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.personal-wechat-composer-editor {
box-sizing: border-box;
height: 248px;
min-height: 248px;
min-width: 0;
overflow-y: auto;
scrollbar-gutter: stable;
}
.personal-wechat-composer-editor > .personal-wechat-text-editor,
.personal-wechat-composer-editor > .personal-wechat-voice-editor,
.personal-wechat-composer-editor > .personal-wechat-file-editor {
box-sizing: border-box;
min-height: 100%;
}
.personal-wechat-text-editor,
.personal-wechat-voice-editor,
.personal-wechat-file-editor {
@@ -951,8 +992,14 @@
gap: 8px;
}
.personal-wechat-composer-editor > .personal-wechat-text-editor {
height: 100%;
grid-template-rows: minmax(0, 1fr) auto;
}
.personal-wechat-text-editor textarea {
resize: vertical;
min-height: 0;
resize: none;
}
.personal-wechat-text-editor > span {
@@ -967,6 +1014,15 @@
gap: 10px;
}
.personal-wechat-composer-editor > .personal-wechat-file-editor {
height: 100%;
align-content: center;
}
.personal-wechat-composer-editor > .personal-wechat-voice-editor {
align-content: start;
}
.personal-wechat-file-editor > div {
min-width: 0;
}
@@ -1071,6 +1127,10 @@
.personal-wechat-generated-result {
grid-template-columns: 1fr;
}
.personal-wechat-composer-editor {
height: 280px;
min-height: 280px;
}
}
.chat-header-actions svg,
@@ -125,7 +125,8 @@
}
}
.tts-hook-warning {
.tts-hook-warning,
.tts-send-warning {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
@@ -150,7 +151,7 @@
}
}
.tts-hook-warning-icon {
.tts-send-warning-icon {
width: 24px;
height: 24px;
display: grid;
@@ -204,6 +205,24 @@
gap: 8px;
}
.tts-windows-endpoint-actions {
align-items: flex-end;
min-width: 250px;
}
.tts-windows-port-field {
display: flex;
align-items: center;
gap: 8px;
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 700;
}
.tts-windows-port-input {
width: 128px;
}
.tts-runtime-progress {
grid-column: 1 / -1;
display: grid;
@@ -9,6 +9,4 @@ export const runtimePlatform = detectRuntimePlatform()
export const isMac = runtimePlatform === 'darwin'
export const isWindows = runtimePlatform === 'win32'
// Renderer entry points use this single capability flag. The main process still
// performs the authoritative Apple Silicon and runtime checks before sending.
export const supportsPersonalWechatSend = isMac
export const supportsPersonalWechatSend = isMac || isWindows