VetNet-LCPS / vetcopilot-app /src /components /RealtimeTranscript.tsx
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
5.09 kB
/**
* RealtimeTranscript 实时转写展示组件
*
* 在录音阶段显示讯飞/阿里实时返回的识别文字。
*/
import React, { useEffect, useRef } from 'react'
import type { RealtimeState, RealtimeSegment } from '../hooks/useRealtimeTranscribe'
import { useTranslation } from '../i18n'
interface Props {
/** 实时转写状态 */
rtState: RealtimeState
/** 是否可见 */
visible: boolean
}
/** 单个说话人分段 */
function SegmentBubble({ seg, isLatest }: { seg: RealtimeSegment; isLatest: boolean }) {
const isVet = seg.speaker === '兽医'
return (
<div className={`flex ${isVet ? 'justify-start' : 'justify-end'} mb-2`}>
<div className={`max-w-[85%] px-3 py-2 rounded-xl text-sm leading-relaxed transition-all duration-300 ${
isVet
? 'bg-medical-50 text-medical-800 rounded-tl-sm'
: 'bg-gray-100 text-gray-700 rounded-tr-sm'
} ${isLatest ? 'shadow-sm ring-1 ring-medical-200' : ''}`}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-0.5 opacity-50">
{seg.speaker || '...'}
</div>
<div>{seg.text}</div>
</div>
</div>
)
}
/** 纯文本流式展示(说话人未分离时) */
function StreamingText({ text, connected }: { text: string; connected: boolean }) {
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [text])
if (!text && !connected) return null
if (!text && connected) {
return (
<div className="text-center py-6 text-gray-400 text-sm">
<div className="inline-flex items-center gap-2">
<span className="inline-block w-2 h-2 bg-medical-400 rounded-full animate-pulse" />
正在聆听...
</div>
</div>
)
}
return (
<div ref={scrollRef} className="max-h-64 overflow-y-auto scrollbar-thin">
<div className="text-gray-700 text-sm leading-relaxed whitespace-pre-wrap">
{text}
{connected && (
<span className="inline-block w-1.5 h-4 bg-medical-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
</div>
)
}
export default function RealtimeTranscript({ rtState, visible }: Props) {
const { t } = useTranslation()
const { fullText, segments, connected, finalized, error, engine } = rtState
if (!visible) return null
const hasSegments = segments && segments.length > 0
const hasText = fullText && fullText.length > 0
return (
<div className="mt-4 bg-white/90 backdrop-blur-sm border border-gray-200 rounded-2xl p-4 shadow-sm">
{/* 头部 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
finalized ? 'bg-emerald-400' : connected ? 'bg-medical-400 animate-pulse' : 'bg-gray-300'
}`} />
<span className="text-xs font-medium text-gray-500">
{finalized ? '转写完成' : connected ? '实时转写中' : '等待连接...'}
</span>
{engine && (
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
{engine.replace('xunfei_', '讯飞·')}
</span>
)}
</div>
{error && (
<span className="text-xs text-red-500 truncate max-w-[200px]">{error}</span>
)}
</div>
{/* 错误提示 */}
{error && !hasText && (
<div className="text-center py-4 text-sm text-red-500">
<p>{error}</p>
<p className="text-xs text-gray-400 mt-1">{t('consultation.realtimeTranscribeError')}</p>
</div>
)}
{/* 分段对话模式(说话人已分离) */}
{hasSegments ? (
<div className="max-h-72 overflow-y-auto scrollbar-thin">
{segments.map((seg, i) => (
<SegmentBubble
key={`${i}-${seg.speaker}-${seg.text.length}`}
seg={seg}
isLatest={i === segments.length - 1 && !finalized}
/>
))}
{connected && !finalized && (
<div className="flex justify-center py-1">
<span className="inline-flex gap-1">
<span className="w-1 h-1 bg-medical-300 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-medical-300 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-medical-300 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
)}
</div>
) : (
/* 流式文本模式 */
<StreamingText text={fullText} connected={connected} />
)}
{/* 空状态 */}
{!hasText && !hasSegments && !connected && !error && (
<div className="text-center py-4 text-xs text-gray-400">
开始说话后,文字将实时显示
</div>
)}
</div>
)
}