55 * 设计原则:可逆 > 不可逆——TTS 开关随时可切换。
66 */
77import { useState , useRef } from 'react' ;
8- import { Volume2 , VolumeX } from 'lucide-react' ;
8+ import { Volume2 , VolumeX , Mic , MicOff } from 'lucide-react' ;
99import { useAssistantStore } from '../store/useAssistantStore' ;
10+ import { useVoiceInput } from '../hooks/useVoiceInput' ;
1011
1112interface Props {
1213 onSend : ( text : string ) => void ;
@@ -20,6 +21,15 @@ export function ChatInput({ onSend }: Props) {
2021 const preferences = useAssistantStore ( s => s . preferences ) ;
2122 const inputRef = useRef < HTMLTextAreaElement > ( null ) ;
2223
24+ // A2 语音输入:麦克风按钮状态与控制
25+ const { listening, partialText, error, toggle, clearError } = useVoiceInput ( {
26+ onFinalText : ( final ) => {
27+ if ( final . trim ( ) ) onSend ( final ) ;
28+ clearError ( ) ;
29+ inputRef . current ?. focus ( ) ;
30+ } ,
31+ } ) ;
32+
2333 const handleSend = ( ) => {
2434 const trimmed = text . trim ( ) ;
2535 if ( ! trimmed || isStreaming ) return ;
@@ -42,42 +52,67 @@ export function ChatInput({ onSend }: Props) {
4252 } ;
4353
4454 return (
45- < div className = "border-t border-border/50 px-3 py-2.5 flex items-end gap-2" >
46- < textarea
47- ref = { inputRef }
48- value = { text }
49- onChange = { e => setText ( e . target . value ) }
50- onKeyDown = { handleKeyDown }
51- placeholder = "输入消息..."
52- rows = { 1 }
53- className = "flex-1 resize-none rounded-xl bg-bg-tertiary/50 px-3 py-2 text-sm text-text-primary
54- placeholder:text-text-tertiary outline-none focus:ring-1 focus:ring-cyber/40
55- max-h-[100px] overflow-y-auto"
56- />
57- { /* TTS 开关:图标 + 文字标签 + aria-pressed,状态一目了然 */ }
58- < button
59- onClick = { toggleTts }
60- className = { `flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors flex-shrink-0
61- ${ ttsEnabled
62- ? 'bg-cyber/15 text-cyber ring-1 ring-cyber/40'
63- : 'bg-bg-tertiary/50 text-text-tertiary hover:text-text-secondary' } `}
64- title = { ttsEnabled ? '语音朗读已开启(点击关闭)' : '语音朗读已关闭(点击开启)' }
65- aria-pressed = { ttsEnabled }
66- aria-label = { ttsEnabled ? '关闭语音朗读' : '开启语音朗读' }
67- >
68- { ttsEnabled ? < Volume2 className = "w-3.5 h-3.5" strokeWidth = { 1.5 } /> : < VolumeX className = "w-3.5 h-3.5" strokeWidth = { 1.5 } /> }
69- { ttsEnabled ? '朗读开' : '朗读关' }
70- </ button >
71- { /* 发送 */ }
72- < button
73- onClick = { handleSend }
74- disabled = { isStreaming || ! text . trim ( ) }
75- className = "p-2 rounded-lg bg-brand-600/80 text-white text-sm disabled:opacity-40
76- hover:bg-brand-500/80 transition-colors"
77- aria-label = "发送消息"
78- >
79- ➤
80- </ button >
55+ < div className = "border-t border-border/50 px-3 py-2.5" >
56+ { /* A2 语音输入错误提示(互斥/模型缺失等)——可关闭时自动消失 */ }
57+ { error && (
58+ < div className = "mb-1.5 text-xs text-orange-400 flex items-center justify-between gap-2" >
59+ < span > { error } </ span >
60+ < button onClick = { clearError } className = "text-text-tertiary hover:text-text-secondary flex-shrink-0" aria-label = "关闭提示" > ✕</ button >
61+ </ div >
62+ ) }
63+ < div className = "flex items-end gap-2" >
64+ < textarea
65+ ref = { inputRef }
66+ // A2 语音输入:拾音时输入框展示实时 partial 转写(受控态不覆盖已键入文本)
67+ value = { listening ? partialText : text }
68+ onChange = { e => setText ( e . target . value ) }
69+ onKeyDown = { handleKeyDown }
70+ placeholder = { listening ? '正在听你说…' : '输入消息...' }
71+ rows = { 1 }
72+ className = "flex-1 resize-none rounded-xl bg-bg-tertiary/50 px-3 py-2 text-sm text-text-primary
73+ placeholder:text-text-tertiary outline-none focus:ring-1 focus:ring-cyber/40
74+ max-h-[100px] overflow-y-auto"
75+ />
76+ { /* TTS 开关:图标 + 文字标签 + aria-pressed,状态一目了然 */ }
77+ < button
78+ onClick = { toggleTts }
79+ className = { `flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors flex-shrink-0
80+ ${ ttsEnabled
81+ ? 'bg-cyber/15 text-cyber ring-1 ring-cyber/40'
82+ : 'bg-bg-tertiary/50 text-text-tertiary hover:text-text-secondary' } `}
83+ title = { ttsEnabled ? '语音朗读已开启(点击关闭)' : '语音朗读已关闭(点击开启)' }
84+ aria-pressed = { ttsEnabled }
85+ aria-label = { ttsEnabled ? '关闭语音朗读' : '开启语音朗读' }
86+ >
87+ { ttsEnabled ? < Volume2 className = "w-3.5 h-3.5" strokeWidth = { 1.5 } /> : < VolumeX className = "w-3.5 h-3.5" strokeWidth = { 1.5 } /> }
88+ { ttsEnabled ? '朗读开' : '朗读关' }
89+ </ button >
90+ { /* A2 语音输入:麦克风按钮(拾音中脉冲高亮,防回声:流式回复中禁用) */ }
91+ < button
92+ onClick = { toggle }
93+ disabled = { isStreaming }
94+ className = { `flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors flex-shrink-0
95+ ${ listening
96+ ? 'bg-orange-500/15 text-orange-500 ring-1 ring-orange-500/40 animate-pulse'
97+ : 'bg-bg-tertiary/50 text-text-tertiary hover:text-text-secondary' } `}
98+ title = { listening ? '正在录音中(点击停止)' : '语音输入(点击开始)' }
99+ aria-pressed = { listening }
100+ aria-label = { listening ? '停止录音' : '开始录音' }
101+ >
102+ { listening ? < MicOff className = "w-3.5 h-3.5" strokeWidth = { 1.5 } /> : < Mic className = "w-3.5 h-3.5" strokeWidth = { 1.5 } /> }
103+ { listening ? '录音中' : '说话' }
104+ </ button >
105+ { /* 发送 */ }
106+ < button
107+ onClick = { handleSend }
108+ disabled = { isStreaming || ! text . trim ( ) || listening }
109+ className = "p-2 rounded-lg bg-brand-600/80 text-white text-sm disabled:opacity-40
110+ hover:bg-brand-500/80 transition-colors"
111+ aria-label = "发送消息"
112+ >
113+ ➤
114+ </ button >
115+ </ div >
81116 </ div >
82117 ) ;
83118}
0 commit comments