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