mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-22 05:56:58 +08:00
feat: 语音
This commit is contained in:
@@ -244,6 +244,18 @@ function App(): React.ReactElement {
|
||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [reportNotice])
|
||||
React.useEffect(() => {
|
||||
const openVoiceRecognitionSettings = (): void => {
|
||||
setSettingsCategory('voice-recognition')
|
||||
setActivePage('settings')
|
||||
}
|
||||
window.addEventListener('wxe:open-voice-recognition-settings', openVoiceRecognitionSettings)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
'wxe:open-voice-recognition-settings',
|
||||
openVoiceRecognitionSettings
|
||||
)
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
void window.api.getSettings().then((result) => {
|
||||
setAppearanceSettings({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import type { JSX, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import type { VoiceMessageReference, VoiceModelStatus } from '../../../shared/voice-recognition'
|
||||
|
||||
interface VoicePlayerProps {
|
||||
sessionId: string
|
||||
@@ -24,8 +25,16 @@ export function VoicePlayer({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(duration)
|
||||
const [modelStatus, setModelStatus] = useState<VoiceModelStatus | null>(null)
|
||||
const [transcribing, setTranscribing] = useState(false)
|
||||
const [transcript, setTranscript] = useState<string | null>(null)
|
||||
const [transcriptError, setTranscriptError] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
const voiceReference = useMemo<VoiceMessageReference>(
|
||||
() => ({ sessionId, localId, createTime, svrId }),
|
||||
[createTime, localId, sessionId, svrId]
|
||||
)
|
||||
|
||||
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
|
||||
if (globalCurrentAudio && globalCurrentAudio !== audio) {
|
||||
@@ -144,6 +153,46 @@ export function VoicePlayer({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTranscribe = useCallback(
|
||||
async (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
setTranscriptError(null)
|
||||
const status = await window.api.getVoiceModelStatus()
|
||||
setModelStatus(status)
|
||||
if (status.state !== 'ready') return
|
||||
|
||||
setTranscribing(true)
|
||||
try {
|
||||
const result = await window.api.recognizeVoice(voiceReference)
|
||||
if (result.success) {
|
||||
setTranscript(result.transcript?.trim() || '未识别出文字')
|
||||
setModelStatus(null)
|
||||
} else if (result.code !== 'CANCELLED') {
|
||||
setTranscriptError(result.error || '语音识别失败')
|
||||
}
|
||||
} catch (recognitionError) {
|
||||
console.warn('[VoicePlayer] recognition failed:', recognitionError)
|
||||
setTranscriptError('语音识别失败,请重试')
|
||||
} finally {
|
||||
setTranscribing(false)
|
||||
}
|
||||
},
|
||||
[voiceReference]
|
||||
)
|
||||
|
||||
const handleCancelRecognition = useCallback(
|
||||
async (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
await window.api.cancelVoiceRecognition(voiceReference)
|
||||
},
|
||||
[voiceReference]
|
||||
)
|
||||
|
||||
const handleOpenVoiceSettings = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
window.dispatchEvent(new Event('wxe:open-voice-recognition-settings'))
|
||||
}, [])
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
@@ -151,33 +200,54 @@ export function VoicePlayer({
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="voice-message voice-loading">
|
||||
<span className="voice-icon">▶</span>
|
||||
<span className="voice-loading-text">加载中...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !audioUrl) {
|
||||
return (
|
||||
<div className="voice-message voice-error" onClick={handlePlayPause}>
|
||||
<span className="voice-icon">▶</span>
|
||||
<span className="voice-error-text">当前版本暂不支持播放</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-message" onClick={handlePlayPause}>
|
||||
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
|
||||
<div className="voice-bars" aria-hidden="true">
|
||||
<i></i>
|
||||
<i></i>
|
||||
<i></i>
|
||||
<div className="voice-player">
|
||||
<div
|
||||
className={`voice-message ${loading ? 'voice-loading' : ''} ${error && !audioUrl ? 'voice-error' : ''}`}
|
||||
onClick={handlePlayPause}
|
||||
>
|
||||
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
|
||||
{loading ? (
|
||||
<span className="voice-loading-text">加载中...</span>
|
||||
) : error && !audioUrl ? (
|
||||
<span className="voice-error-text">当前语音暂不支持播放</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="voice-bars" aria-hidden="true">
|
||||
<i></i>
|
||||
<i></i>
|
||||
<i></i>
|
||||
</div>
|
||||
<span className="voice-duration">{formatDuration(audioDuration)}</span>
|
||||
</>
|
||||
)}
|
||||
{transcribing ? (
|
||||
<button className="voice-text-action" type="button" onClick={handleCancelRecognition}>
|
||||
取消识别
|
||||
</button>
|
||||
) : (
|
||||
<button className="voice-text-action" type="button" onClick={handleTranscribe}>
|
||||
{transcript ? '重新识别' : '转文字'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="voice-duration">{formatDuration(audioDuration)}</span>
|
||||
{modelStatus && modelStatus.state !== 'ready' && (
|
||||
<div className="voice-model-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<span>
|
||||
{modelStatus.state === 'downloading'
|
||||
? `离线模型正在下载 ${Math.round(modelStatus.progress * 100)}%`
|
||||
: modelStatus.state === 'unsupported'
|
||||
? '当前系统暂不支持语音转文字'
|
||||
: '请先在设置中准备离线语音模型'}
|
||||
</span>
|
||||
<button type="button" onClick={handleOpenVoiceSettings}>
|
||||
前往设置
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{transcribing && <div className="voice-transcript-status">正在识别...</div>}
|
||||
{transcript && <div className="voice-transcript">{transcript}</div>}
|
||||
{transcriptError && <div className="voice-transcript-error">{transcriptError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,22 @@ export function ExportTaskCenter({
|
||||
onToggle,
|
||||
onCancel
|
||||
}: ExportTaskCenterProps): React.ReactElement {
|
||||
const [copiedJobId, setCopiedJobId] = React.useState('')
|
||||
|
||||
const copyTaskLog = async (task: ExportTaskRecord): Promise<void> => {
|
||||
const log = [
|
||||
'WechatExplorer 导出任务日志',
|
||||
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
|
||||
`会话:${task.contactName}`,
|
||||
`格式:${task.format.toUpperCase()}`,
|
||||
`状态:${task.progress.phase}`,
|
||||
`进度:${task.progress.percent ?? 0}%`,
|
||||
`错误:${task.progress.error || '未记录具体错误'}`
|
||||
].join('\n')
|
||||
await navigator.clipboard.writeText(log)
|
||||
setCopiedJobId(task.jobId)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="export-task-center-button" onClick={onToggle}>
|
||||
@@ -37,6 +53,11 @@ export function ExportTaskCenter({
|
||||
<small>
|
||||
{task.format.toUpperCase()} · {task.progress.phase}
|
||||
</small>
|
||||
{task.progress.error && (
|
||||
<small className="export-task-error" title={task.progress.error}>
|
||||
{task.progress.error}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
<span className="export-task-progress">
|
||||
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
|
||||
@@ -47,6 +68,11 @@ export function ExportTaskCenter({
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'failed' && (
|
||||
<button type="button" onClick={() => void copyTaskLog(task)}>
|
||||
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
GroupMemberName
|
||||
} from './exportTypes'
|
||||
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
|
||||
import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
|
||||
|
||||
export function ExportWorkspace({
|
||||
contacts,
|
||||
@@ -38,6 +39,8 @@ export function ExportWorkspace({
|
||||
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
|
||||
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
|
||||
const [includeMedia, setIncludeMedia] = useState(true)
|
||||
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
|
||||
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
|
||||
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||
@@ -149,6 +152,17 @@ export function ExportWorkspace({
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeContact])
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getVoiceModelStatus()
|
||||
.then((next) => active && setVoiceModelStatus(next))
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleKind = (value: string): void => {
|
||||
setSelectedKinds((current) => {
|
||||
const next = new Set(current)
|
||||
@@ -208,6 +222,12 @@ export function ExportWorkspace({
|
||||
: undefined,
|
||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||
includeMedia,
|
||||
includeVoiceTranscripts:
|
||||
includeVoiceTranscripts &&
|
||||
includeMedia &&
|
||||
format === 'html' &&
|
||||
selectedKinds.has('voice') &&
|
||||
voiceModelStatus?.state === 'ready',
|
||||
preferOriginal,
|
||||
fallbackThumbnail,
|
||||
keepMissing,
|
||||
@@ -484,6 +504,20 @@ export function ExportWorkspace({
|
||||
/>
|
||||
<span>媒体缺失时保留占位说明</span>
|
||||
</label>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeVoiceTranscripts && voiceModelStatus?.state === 'ready'}
|
||||
disabled={
|
||||
!includeMedia ||
|
||||
format !== 'html' ||
|
||||
!selectedKinds.has('voice') ||
|
||||
voiceModelStatus?.state !== 'ready'
|
||||
}
|
||||
onChange={(event) => setIncludeVoiceTranscripts(event.target.checked)}
|
||||
/>
|
||||
<span>语音转文字,显示在语音条下方</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="export-helper-text">
|
||||
资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。
|
||||
@@ -492,6 +526,10 @@ export function ExportWorkspace({
|
||||
<span>图片解密:已就绪</span>
|
||||
<span>视频资源:可用</span>
|
||||
<span>语音资源:可用</span>
|
||||
<span>
|
||||
语音转文字:
|
||||
{voiceModelStatus?.state === 'ready' ? '已就绪' : '请先在设置中准备模型'}
|
||||
</span>
|
||||
<span>表情资源:按需解析</span>
|
||||
<span>文件附件:按需复制</span>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AdvancedPage } from './pages/AdvancedPage'
|
||||
import { CacheCleanupPage } from './pages/CacheCleanupPage'
|
||||
import { AppearancePage } from './pages/AppearancePage'
|
||||
import { AboutPage } from './pages/AboutPage'
|
||||
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||
|
||||
@@ -88,6 +89,8 @@ export function SettingsWorkspace({
|
||||
return <ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||
case 'ai-model':
|
||||
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||
case 'voice-recognition':
|
||||
return <VoiceRecognitionPage onNotice={onNotice} />
|
||||
case 'recall-protection':
|
||||
return <RecallProtectionPage onNotice={onNotice} />
|
||||
case 'advanced':
|
||||
|
||||
@@ -17,7 +17,8 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
||||
{
|
||||
label: '智能能力',
|
||||
items: [
|
||||
{ id: 'ai-model', label: 'AI 模型' },
|
||||
{ id: 'voice-recognition', label: '语音转文字' },
|
||||
{ id: 'ai-model', label: 'AI 模型' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ export type SettingsCategoryId =
|
||||
| 'account-database'
|
||||
| 'database-key'
|
||||
| 'image-key'
|
||||
| 'voice-recognition'
|
||||
| 'ai-model'
|
||||
| 'recall-protection'
|
||||
| 'local-api'
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { VoiceModelStatus } from '../../../../../shared/voice-recognition'
|
||||
|
||||
const SENSEVOICE_URL = 'https://github.com/FunAudioLLM/SenseVoice'
|
||||
const SHERPA_URL = 'https://github.com/k2-fsa/sherpa-onnx'
|
||||
|
||||
const STATUS_LABELS: Record<VoiceModelStatus['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`
|
||||
}
|
||||
|
||||
function formatPlatform(status: VoiceModelStatus): string {
|
||||
if (status.platform === 'win32')
|
||||
return `Windows ${status.architecture === 'x64' ? '64 位' : status.architecture}`
|
||||
if (status.platform === 'darwin') {
|
||||
return status.architecture === 'arm64' ? 'macOS Apple 芯片' : 'macOS Intel'
|
||||
}
|
||||
return `${status.platform} ${status.architecture}`
|
||||
}
|
||||
|
||||
export function VoiceRecognitionPage({
|
||||
onNotice
|
||||
}: {
|
||||
onNotice: (message: string) => void
|
||||
}): React.ReactElement {
|
||||
const [status, setStatus] = useState<VoiceModelStatus | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setStatus(await window.api.getVoiceModelStatus())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getVoiceModelStatus().then((next) => active && setStatus(next))
|
||||
const unsubscribe = window.api.onVoiceModelProgress((next) => {
|
||||
if (active) setStatus(next)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const badgeClass = useMemo(() => {
|
||||
if (status?.state === 'ready') return 'ready'
|
||||
if (status?.state === 'downloading') return 'checking'
|
||||
if (status?.state === 'invalid' || status?.state === 'error') return 'error'
|
||||
if (status?.state === 'unsupported') return 'unavailable'
|
||||
return 'warning'
|
||||
}, [status?.state])
|
||||
|
||||
const download = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setStatus((current) =>
|
||||
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
|
||||
)
|
||||
try {
|
||||
const result = await window.api.downloadVoiceModel()
|
||||
setStatus(result.status)
|
||||
onNotice(result.success ? '离线语音模型已准备好' : result.error || '模型下载失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDownload = async (): Promise<void> => {
|
||||
await window.api.cancelVoiceModelDownload()
|
||||
onNotice('正在取消模型下载')
|
||||
}
|
||||
|
||||
const removeModel = async (): Promise<void> => {
|
||||
if (!window.confirm('删除离线语音模型?以后使用语音转文字时需要重新下载。')) return
|
||||
setBusy(true)
|
||||
try {
|
||||
setStatus(await window.api.removeVoiceModel())
|
||||
onNotice('离线语音模型已删除')
|
||||
} catch (error) {
|
||||
onNotice(error instanceof Error ? `模型删除失败:${error.message}` : '模型删除失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDirectory = async (): Promise<void> => {
|
||||
const result = await window.api.openVoiceModelDirectory()
|
||||
if (!result.success) onNotice(result.error || '无法打开模型目录')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page voice-recognition-page">
|
||||
<header className="settings-page-header">
|
||||
<div>
|
||||
<h1>语音转文字</h1>
|
||||
<p>管理本地语音识别环境和离线模型</p>
|
||||
</div>
|
||||
<div className="voice-header-status">
|
||||
<span className={`settings-status-badge ${badgeClass}`}>
|
||||
{status?.state === 'downloading'
|
||||
? `下载中 ${Math.round(status.progress * 100)}%`
|
||||
: status
|
||||
? STATUS_LABELS[status.state]
|
||||
: '检测中'}
|
||||
</span>
|
||||
{status?.state === 'downloading' && (
|
||||
<progress value={status.progress} max={1} aria-label="顶部语音模型下载进度" />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="settings-page-scroll">
|
||||
<div className="settings-page-content voice-recognition-content">
|
||||
<section className="settings-privacy-notice">
|
||||
<svg viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M12 3 5.5 5.7v5.2c0 4.3 2.7 8.2 6.5 10.1 3.8-1.9 6.5-5.8 6.5-10.1V5.7L12 3Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<strong>语音内容仅在本机处理</strong>
|
||||
<p>识别过程不会上传语音、聊天内容或转写结果,也不需要配置在线 AI 服务。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">运行环境</h2>
|
||||
<section className="settings-card voice-runtime-card">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>当前平台</dt>
|
||||
<dd>{status ? formatPlatform(status) : '检测中...'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>离线识别</dt>
|
||||
<dd className={status?.supported ? 'voice-status-success' : 'voice-status-error'}>
|
||||
{status?.supported ? '支持' : '暂不支持'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>识别引擎</dt>
|
||||
<dd>sherpa-onnx · SenseVoice</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">离线模型</h2>
|
||||
<section className="settings-card voice-model-card">
|
||||
<div className="voice-model-summary">
|
||||
<span className="settings-card-kicker">SenseVoice Small INT8</span>
|
||||
<strong>
|
||||
{status?.state === 'downloading'
|
||||
? `正在下载 ${Math.round(status.progress * 100)}%`
|
||||
: status
|
||||
? STATUS_LABELS[status.state]
|
||||
: '正在检测'}
|
||||
</strong>
|
||||
<small>
|
||||
{status
|
||||
? `版本 ${status.version} · ${formatBytes(status.totalBytes)}`
|
||||
: '读取模型状态...'}
|
||||
</small>
|
||||
{status?.error && <p className="voice-model-error">{status.error}</p>}
|
||||
<p className="voice-model-license">
|
||||
上游模型:
|
||||
<a href={SENSEVOICE_URL} target="_blank" rel="noreferrer">
|
||||
SenseVoice(MIT)
|
||||
</a>
|
||||
<span> · </span>
|
||||
推理运行库:
|
||||
<a href={SHERPA_URL} target="_blank" rel="noreferrer">
|
||||
sherpa-onnx(Apache-2.0)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="voice-model-actions">
|
||||
{status?.state === 'downloading' ? (
|
||||
<button type="button" onClick={() => void cancelDownload()}>
|
||||
取消下载
|
||||
</button>
|
||||
) : status?.state === 'ready' ? (
|
||||
<>
|
||||
<button type="button" onClick={() => void openDirectory()}>
|
||||
打开模型目录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-danger-button"
|
||||
disabled={busy}
|
||||
onClick={() => void removeModel()}
|
||||
>
|
||||
删除模型
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-primary-button"
|
||||
disabled={busy || !status?.supported}
|
||||
onClick={() => void download()}
|
||||
>
|
||||
{status?.state === 'invalid' || status?.state === 'error'
|
||||
? '重新下载模型'
|
||||
: '下载模型'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={busy} onClick={() => void refresh()}>
|
||||
重新检测
|
||||
</button>
|
||||
</div>
|
||||
{status?.state === 'downloading' && (
|
||||
<div className="voice-model-progress">
|
||||
<div>
|
||||
<span>{Math.round(status.progress * 100)}%</span>
|
||||
<small>
|
||||
{formatBytes(status.downloadedBytes)} / {formatBytes(status.totalBytes)}
|
||||
</small>
|
||||
</div>
|
||||
<progress value={status.progress} max={1} aria-label="语音模型下载进度" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">平台支持</h2>
|
||||
<section className="settings-card voice-platform-list">
|
||||
<div>
|
||||
<strong>Windows</strong>
|
||||
<span>支持 Windows 10/11 64 位</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>macOS</strong>
|
||||
<span>支持 Intel 与 Apple 芯片</span>
|
||||
</div>
|
||||
</section>
|
||||
<p className="settings-footnote">
|
||||
模型由两个平台共用;应用会随安装包提供对应系统的本地识别运行库。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -764,6 +764,10 @@
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.export-task-error {
|
||||
color: var(--wxex-danger, #b42318);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 5px;
|
||||
|
||||
@@ -1,9 +1,66 @@
|
||||
/* Voice Player */
|
||||
.voice-player {
|
||||
display: grid;
|
||||
min-width: 190px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.voice-message {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.voice-text-action,
|
||||
.voice-model-panel button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.voice-text-action {
|
||||
margin-left: auto;
|
||||
padding: 2px 0 2px 8px;
|
||||
}
|
||||
|
||||
.voice-model-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.voice-model-panel progress {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.voice-transcript,
|
||||
.voice-transcript-status,
|
||||
.voice-transcript-error {
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.voice-transcript-status {
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.voice-transcript-error {
|
||||
color: var(--wxex-danger, #c63c3c);
|
||||
}
|
||||
|
||||
.voice-loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,185 @@
|
||||
}
|
||||
}
|
||||
|
||||
.voice-runtime-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px 32px;
|
||||
margin: 0;
|
||||
|
||||
div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 5px 0 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-header-status {
|
||||
display: grid;
|
||||
min-width: 132px;
|
||||
justify-items: end;
|
||||
gap: 7px;
|
||||
|
||||
progress {
|
||||
width: 112px;
|
||||
height: 6px;
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-status-success {
|
||||
color: var(--wxex-success, #2e8b68) !important;
|
||||
}
|
||||
|
||||
.voice-status-error,
|
||||
.voice-model-error {
|
||||
color: var(--wxex-danger, #c85a5a) !important;
|
||||
}
|
||||
|
||||
.voice-model-license {
|
||||
margin: 9px 0 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
|
||||
a {
|
||||
color: var(--wxex-brand);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-model-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 18px 28px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.voice-model-summary {
|
||||
min-width: 0;
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 5px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-model-error {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.voice-model-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
> button {
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
> .settings-primary-button {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-model-progress {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 7px;
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-platform-list {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.voice-runtime-card dl,
|
||||
.voice-model-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.voice-model-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-option-card {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -308,7 +487,6 @@
|
||||
--wxex-nav-width: 68px;
|
||||
--wxex-shell-content-top: 8px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.boot-splash.is-quiet {
|
||||
|
||||
Reference in New Issue
Block a user