Download vetcopilot-app/src/components/RealtimeTranscript.tsx from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 5.09 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/RealtimeTranscript.tsx
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-app/src/components/RealtimeTranscript.tsx
-
curl -L -o RealtimeTranscript.tsx https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/RealtimeTranscript.tsx
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> | |
| ) | |
| } | |