feat(voice): 优化语音识别功能及增强可用性提示

- 新增语音服务可用性状态管理,动态检测浏览器支持情况
- 调整初始输入模式为键盘输入,避免误触启动语音识别
- 合并消息列表时保留已有调试消息,避免信息丢失
- VoiceInput组件新增调试日志回调,增强调试信息输出
- 细化语音识别状态机,区分idle、starting和listening状态
- 优化权限拒绝错误处理,针对iOS给出具体操作提示
- 改进语音识别启动流程,针对iOS增加软启动延迟避免冲突
- 支持多种指针和触摸事件管理,防止冲突导致的识别异常
- 按钮UI根据状态动态调整,显示不同提示文本和样式
- 禁用语音不可用时的按钮操作,避免误操作
- 增加语音识别启动超时保护,防止长时间挂起
- 优化用户交互体验,加入振动反馈及事件防抖处理
This commit is contained in:
2026-01-23 14:00:39 +08:00
parent ed60b8424f
commit 5367a4107e
2 changed files with 277 additions and 127 deletions

69
App.tsx
View File

@@ -121,7 +121,8 @@ const App: React.FC = () => {
const [isProcessing, setIsProcessing] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [isEditingFromChat, setIsEditingFromChat] = useState(false);
const [inputMode, setInputMode] = useState<'voice' | 'keyboard'>('voice');
const [inputMode, setInputMode] = useState<'voice' | 'keyboard'>('keyboard');
const [isVoiceServiceAvailable, setIsVoiceServiceAvailable] = useState(true);
const [token, setToken] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<any>(null);
@@ -151,6 +152,12 @@ const App: React.FC = () => {
useEffect(() => {
console.log('当前URL:', window.location.href);
// 检查浏览器是否支持语音识别
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
setIsVoiceServiceAvailable(false);
}
// 检查是否存在token,如果不存在则调用登录接口
checkAndLogin().then((userInfo) => {
// 登录成功后加载数据
@@ -264,33 +271,46 @@ const App: React.FC = () => {
relatedTransactionId: msg.transaction_id || msg.relatedTransactionId
}));
// 更新本地缓存
setMessages(messagesFromDB);
setMessages(prev => {
// 如果已经有消息了(比如调试消息),保留它们并合并
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [...messagesFromDB, ...existingDebugMessages];
});
console.log('消息列表已从数据库加载:', messagesFromDB.length);
} else {
// 如果数据库中没有消息,显示初始欢迎消息
const aiName = aiSettings.name;
setMessages([{
id: 'init',
role: 'assistant',
content: `我是${aiName}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}]);
setMessages(prev => {
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [{
id: 'init',
role: 'assistant',
content: `我是${aiName}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}, ...existingDebugMessages];
});
}
} catch (error) {
console.error('加载消息列表失败:', error);
// 失败时尝试从本地存储加载
const storedMessages = localStorage.getItem('cui_messages');
if (storedMessages) {
setMessages(JSON.parse(storedMessages));
setMessages(prev => {
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [...JSON.parse(storedMessages), ...existingDebugMessages];
});
} else {
// 显示初始欢迎消息
const aiName = aiSettings.name;
setMessages([{
id: 'init',
role: 'assistant',
content: `我是${aiName}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}]);
setMessages(prev => {
const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-'));
return [{
id: 'init',
role: 'assistant',
content: `我是${aiName}。✨\n想记账?快说!🎤`,
timestamp: Date.now()
}, ...existingDebugMessages];
});
}
}
@@ -482,6 +502,11 @@ const App: React.FC = () => {
}
};
const handleDebugLog = React.useCallback((content: string) => {
// 调试信息仅打印到控制台,不再添加到消息列表
console.log(`🔧 [调试信息] ${content}`);
}, []);
const handleSendMessage = React.useCallback(async (text: string, imageBase64?: string, mimeType?: string) => {
if (!text.trim() && !imageBase64) return;
@@ -799,7 +824,10 @@ const App: React.FC = () => {
<div className="absolute bottom-20 left-0 right-0 p-4 bg-gradient-to-t from-gray-100 via-gray-100 to-transparent z-40">
<div className="max-w-4xl mx-auto flex flex-col gap-3">
<div className="flex items-center gap-3">
<button onClick={() => setInputMode(inputMode === 'voice' ? 'keyboard' : 'voice')} className="p-3 bg-white text-gray-400 rounded-full shadow-lg hover:bg-gray-50 transition-all active:scale-90">
<button
onClick={() => setInputMode(inputMode === 'voice' ? 'keyboard' : 'voice')}
className="p-3 bg-white text-gray-400 rounded-full shadow-lg hover:bg-gray-50 transition-all active:scale-90"
>
{inputMode === 'voice' ? (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
) : (
@@ -815,7 +843,14 @@ const App: React.FC = () => {
)}
</div>
) : (
<VoiceInput onTranscript={(text) => handleSendMessage(text)} isProcessing={isProcessing} isFullWidth />
<VoiceInput
onTranscript={(text) => handleSendMessage(text)}
isProcessing={isProcessing}
isFullWidth
onDebug={handleDebugLog}
onAvailabilityChange={(isAvailable) => setIsVoiceServiceAvailable(isAvailable)}
isServiceAvailable={isVoiceServiceAvailable}
/>
)}
<input type="file" accept="image/*" className="hidden" ref={fileInputRef} onChange={handleFileUpload} />