70492d9eba
Simplify asset upload temp paths, refresh deploy docs for Aliyun DNS topology, and ship MindSpace content-scan and auth improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
148 lines
4.6 KiB
TypeScript
148 lines
4.6 KiB
TypeScript
import { useCallback, useEffect } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { useVoiceSession } from '../hooks/useVoiceSession';
|
||
import { isVoiceInputAvailable } from '../voice/capabilities';
|
||
import { VoiceWaveform } from './VoiceWaveform';
|
||
|
||
export function VoiceInputDialog({
|
||
open,
|
||
disabled = false,
|
||
onClose,
|
||
onSend,
|
||
onError,
|
||
}: {
|
||
open: boolean;
|
||
disabled?: boolean;
|
||
onClose: () => void;
|
||
onSend: (text: string) => void;
|
||
onError?: (message: string) => void;
|
||
}) {
|
||
const { phase, text, analyser, liveRecognition, stopListening, finishFallbackRecording, resetSession } =
|
||
useVoiceSession({
|
||
active: open && !disabled,
|
||
onError,
|
||
});
|
||
|
||
const handleClose = useCallback(() => {
|
||
stopListening();
|
||
resetSession();
|
||
onClose();
|
||
}, [onClose, resetSession, stopListening]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') handleClose();
|
||
};
|
||
window.addEventListener('keydown', onKeyDown);
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
};
|
||
}, [open, handleClose]);
|
||
|
||
if (!open) return null;
|
||
|
||
const listening = phase === 'listening';
|
||
const transcribing = phase === 'transcribing';
|
||
const busy = phase === 'requesting' || transcribing;
|
||
const canSend = Boolean(text.trim()) && !busy && !disabled;
|
||
|
||
const handleSend = () => {
|
||
const value = text.trim();
|
||
if (!value || disabled) return;
|
||
stopListening();
|
||
resetSession();
|
||
onSend(value);
|
||
onClose();
|
||
};
|
||
|
||
const statusText =
|
||
phase === 'requesting'
|
||
? '正在准备麦克风…'
|
||
: listening
|
||
? liveRecognition
|
||
? '正在聆听,文字会实时显示'
|
||
: '正在录音,完成后点击「完成识别」'
|
||
: transcribing
|
||
? '正在识别…'
|
||
: '可编辑识别结果后发送';
|
||
|
||
const supported = isVoiceInputAvailable();
|
||
if (!supported) {
|
||
return createPortal(
|
||
<div className="voice-dialog-backdrop" role="presentation" onClick={handleClose}>
|
||
<section className="voice-dialog" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||
<p className="voice-dialog-status">当前浏览器不支持语音输入,请改用文字发送。</p>
|
||
<div className="voice-dialog-actions">
|
||
<button type="button" className="ghost-btn" onClick={handleClose}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
return createPortal(
|
||
<div className="voice-dialog-backdrop" role="presentation" onClick={handleClose}>
|
||
<section
|
||
className="voice-dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="voice-dialog-title"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="voice-dialog-header">
|
||
<div>
|
||
<p className="voice-dialog-eyebrow">语音输入</p>
|
||
<h3 id="voice-dialog-title">说出你想发送的内容</h3>
|
||
</div>
|
||
<button type="button" className="voice-dialog-close" onClick={handleClose} aria-label="关闭">
|
||
关闭
|
||
</button>
|
||
</div>
|
||
|
||
<VoiceWaveform analyser={analyser} active={listening || phase === 'requesting'} />
|
||
|
||
<p className="voice-dialog-status" role="status">
|
||
{transcribing ? <span className="voice-btn-spinner" aria-hidden="true" /> : null}
|
||
{statusText}
|
||
</p>
|
||
|
||
<textarea
|
||
className="voice-dialog-input input"
|
||
rows={4}
|
||
placeholder={listening ? '识别文字会出现在这里…' : '输入或编辑要发送的内容…'}
|
||
value={text}
|
||
disabled={busy}
|
||
onChange={(event) => updateText(event.target.value)}
|
||
/>
|
||
|
||
<div className="voice-dialog-actions">
|
||
<button type="button" className="ghost-btn" onClick={handleClose}>
|
||
取消
|
||
</button>
|
||
{!liveRecognition && listening ? (
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
disabled={transcribing}
|
||
onClick={() => void finishFallbackRecording()}
|
||
>
|
||
完成识别
|
||
</button>
|
||
) : null}
|
||
<button type="button" className="send-btn" disabled={!canSend} onClick={handleSend}>
|
||
发送
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|