mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 实现语音转文字并支持流式输出;
fix: 修复了语音解密失败的问题
This commit is contained in:
63
src/components/AnimatedStreamingText.tsx
Normal file
63
src/components/AnimatedStreamingText.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { memo, useEffect, useState, useRef } from 'react'
|
||||
|
||||
interface AnimatedStreamingTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
|
||||
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
|
||||
const prevTextRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const currentText = (text || '').trim()
|
||||
const prevText = prevTextRef.current
|
||||
|
||||
if (currentText === prevText) return
|
||||
if (!currentText.startsWith(prevText) && prevText !== '') {
|
||||
// 如果不是追加而是全新的文本(比如重新识别),则重置
|
||||
setDisplayedSegments([currentText])
|
||||
prevTextRef.current = currentText
|
||||
return
|
||||
}
|
||||
|
||||
const newPart = currentText.slice(prevText.length)
|
||||
if (newPart) {
|
||||
// 将新部分作为单独的段加入,以触发动画
|
||||
setDisplayedSegments(prev => [...prev, newPart])
|
||||
}
|
||||
prevTextRef.current = currentText
|
||||
}, [text])
|
||||
|
||||
// 处理 loading 状态的显示
|
||||
if (loading && !text) {
|
||||
return <span className={className}>转写中<span className="dot-flashing">...</span></span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{displayedSegments.map((segment, index) => (
|
||||
<span key={index} className="fade-in-text">
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
<style>{`
|
||||
.fade-in-text {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.dot-flashing {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
AnimatedStreamingText.displayName = 'AnimatedStreamingText'
|
||||
255
src/components/VoiceTranscribeDialog.scss
Normal file
255
src/components/VoiceTranscribeDialog.scss
Normal file
@@ -0,0 +1,255 @@
|
||||
.voice-transcribe-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.voice-transcribe-dialog {
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
|
||||
.info-icon {
|
||||
color: var(--color-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
width: 100%;
|
||||
background: var(--color-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.model-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.download-icon {
|
||||
.downloading-icon {
|
||||
color: var(--color-primary);
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.download-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.complete-icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.complete-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
145
src/components/VoiceTranscribeDialog.tsx
Normal file
145
src/components/VoiceTranscribeDialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import './VoiceTranscribeDialog.scss'
|
||||
|
||||
interface VoiceTranscribeDialogProps {
|
||||
onClose: () => void
|
||||
onDownloadComplete: () => void
|
||||
}
|
||||
|
||||
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
||||
onClose,
|
||||
onDownloadComplete
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听下载进度
|
||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
||||
if (payload.percent !== undefined) {
|
||||
setDownloadProgress(payload.percent)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true)
|
||||
setDownloadError(null)
|
||||
setDownloadProgress(0)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.whisper?.downloadModel()
|
||||
|
||||
if (result?.success) {
|
||||
setIsComplete(true)
|
||||
setDownloadProgress(100)
|
||||
|
||||
// 延迟关闭弹窗并触发转写
|
||||
setTimeout(() => {
|
||||
onDownloadComplete()
|
||||
}, 1000)
|
||||
} else {
|
||||
setDownloadError(result?.error || '下载失败')
|
||||
setIsDownloading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setDownloadError(String(error))
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!isDownloading) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
|
||||
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>语音转文字</h3>
|
||||
{!isDownloading && (
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dialog-content">
|
||||
{!isDownloading && !isComplete && (
|
||||
<>
|
||||
<div className="info-section">
|
||||
<AlertCircle size={48} className="info-icon" />
|
||||
<p className="info-text">
|
||||
首次使用语音转文字功能需要下载 AI 模型
|
||||
</p>
|
||||
<div className="model-info">
|
||||
<div className="model-item">
|
||||
<span className="label">模型名称:</span>
|
||||
<span className="value">SenseVoiceSmall</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">文件大小:</span>
|
||||
<span className="value">约 240 MB</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">支持语言:</span>
|
||||
<span className="value">中文、英文、日文、韩文</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{downloadError && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
<span>{downloadError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
<span>立即下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDownloading && !isComplete && (
|
||||
<div className="download-section">
|
||||
<div className="download-icon">
|
||||
<Download size={48} className="downloading-icon" />
|
||||
</div>
|
||||
<p className="download-text">正在下载模型...</p>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<div className="complete-section">
|
||||
<CheckCircle size={48} className="complete-icon" />
|
||||
<p className="complete-text">下载完成!正在转写语音...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user