[251030] fix: WebSpeech API Error Update

🕐 커밋 시간: 2025. 10. 30. 10:05:16

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +74줄
  • 삭제: -23줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/hooks/useWebSpeechManager.js
  + com.twin.app.shoptime/src/services/voice/VoiceRecognitionManager.js
  + com.twin.app.shoptime/src/services/webSpeech/WebSpeechServiceAsync.js
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.jsx
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/.gitignore
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • API 서비스 레이어 개선
  • 소규모 기능 개선
  • 모듈 구조 개선
This commit is contained in:
2025-10-30 10:05:17 +09:00
parent f569558a14
commit 3725ade27d
7 changed files with 1141 additions and 23 deletions

View File

@@ -15,7 +15,7 @@ npm-debug.log
# ipk file # ipk file
srcBackup srcBackup
# com.lgshop.app_*.ipk # com.lgshop.app_*.ipk
.doc .docs
.docs .docs
nul nul
.txt .txt

View File

@@ -0,0 +1,124 @@
// src/hooks/useWebSpeechManager.js
import { useEffect, useCallback, useRef } from 'react';
import { useDispatch } from 'react-redux';
import VoiceRecognitionManager, {
VOICE_STATE,
WEBSPEECH_VERSION,
} from '../services/voice/VoiceRecognitionManager';
import WebSpeechServiceAsync from '../services/webSpeech/WebSpeechServiceAsync';
/**
* WebSpeechManager를 React Hook으로 통합 (continuous=true 전용)
*
* 기능:
* - Manager 생성 및 라이프사이클 관리
* - 상태 변경 리스너 등록
* - Redux dispatch
* - startListening, stopListening, retry 함수 제공
*
* @param {Function} onStateChange - 상태 변경 콜백 (newState, oldState) => void
* @param {Object} options - Manager 옵션
* - webSpeechVersion: WEBSPEECH_VERSION.V1 (기본)
* - maxListeningTime: 15000 (기본)
* - startTimeoutMs: 5000 (기본)
* @returns {Object} { startListening, stopListening, retry, manager }
*/
export const useWebSpeechManager = (onStateChange, options = {}) => {
const managerRef = useRef(null);
const dispatch = useDispatch();
// Manager 초기화 (한 번만)
useEffect(() => {
console.log('[useWebSpeechManager] Manager 생성');
// Manager 인스턴스 생성
managerRef.current = new VoiceRecognitionManager(
new WebSpeechServiceAsync(),
{
webSpeechVersion: WEBSPEECH_VERSION.V1, // 기본값
...options,
}
);
// 상태 변경 리스너 등록
const unsubscribe = managerRef.current.onStateChange((newState, oldState) => {
console.log(`[useWebSpeechManager] 상태 변경: ${oldState}${newState}`);
// Redux dispatch
dispatch({
type: 'VOICE_STATE_CHANGED',
payload: {
state: newState,
oldState: oldState,
timestamp: new Date().toISOString(),
},
});
// 콜백 호출
if (onStateChange) {
try {
onStateChange(newState, oldState);
} catch (error) {
console.error('[useWebSpeechManager] onStateChange 콜백 에러:', error);
}
}
});
console.log('[useWebSpeechManager] Manager 초기화 완료');
// cleanup: 언마운트 시
return () => {
console.log('[useWebSpeechManager] 언마운트 - 정리 중');
unsubscribe();
};
}, [dispatch, onStateChange, options]);
// 음성 인식 시작
const startListening = useCallback(async () => {
try {
console.log('[useWebSpeechManager] startListening() 호출');
const result = await managerRef.current.startListeningAsync();
console.log('[useWebSpeechManager] ✅ 시작 성공:', result);
return result;
} catch (error) {
console.error('[useWebSpeechManager] ❌ 시작 실패:', error.message);
throw error;
}
}, []);
// 음성 인식 종료 (수동)
const stopListening = useCallback(async () => {
try {
console.log('[useWebSpeechManager] stopListening() 호출');
const result = await managerRef.current._finishListening('user_stopped');
console.log('[useWebSpeechManager] ✅ 종료 성공:', result);
return result;
} catch (error) {
console.error('[useWebSpeechManager] ❌ 종료 실패:', error.message);
throw error;
}
}, []);
// 재시도
const retry = useCallback(async () => {
try {
console.log('[useWebSpeechManager] retry() 호출');
const result = await managerRef.current.retryAsync();
console.log('[useWebSpeechManager] ✅ 재시도 성공:', result);
return result;
} catch (error) {
console.error('[useWebSpeechManager] ❌ 재시도 실패:', error.message);
throw error;
}
}, []);
return {
startListening,
stopListening,
retry,
manager: managerRef.current,
};
};
export default useWebSpeechManager;

View File

@@ -0,0 +1,377 @@
// src/services/voice/VoiceRecognitionManager.js
/**
* SilenceDetection 버전 정의 (continuous=true에서는 미사용)
* ⚠️ continuous=false 환경에서만 사용
* 현재는 참고용으로만 유지
*/
export const SILENCE_DETECTION_VERSIONS = {
V1: 'v1', // 미사용
V2: 'v2', // 미사용
};
/**
* WebSpeech Version 정의
*/
export const WEBSPEECH_VERSION = {
V1: 'v1', // 현재: continuous=true 기본 구현
V2: 'v2', // 미래: 개선된 기능 추가
};
/**
* 음성 인식 상태 머신
*/
export const VOICE_STATE = {
// 초기 상태
IDLE: 'idle',
// 초기화 단계
INITIALIZING: 'initializing',
INITIALIZED: 'initialized',
// 활성화 단계
STARTING: 'starting',
STARTED: 'started',
// 녹음 단계
LISTENING: 'listening',
// 처리 단계
PROCESSING: 'processing',
PROCESSED: 'processed',
// 종료 단계
STOPPING: 'stopping',
STOPPED: 'stopped',
// 에러 상태
ERROR: 'error',
};
/**
* 음성 인식 관리자
*
* Promise 기반 음성인식 라이프사이클 관리
* ⭐ continuous=true 전용
*
* - 각 단계를 Promise로 래핑하여 순서 보장
* - 15초 maxListeningTime 타이머로 자동 종료
* - 상태 머신으로 중앙 관리
* - 에러 처리 및 복구
* - WEBSPEECH_VERSION 조건부 기능 지원
*/
class VoiceRecognitionManager {
constructor(
webSpeechService,
options = {}
) {
this.service = webSpeechService;
// 옵션 기본값
this.options = {
webSpeechVersion: WEBSPEECH_VERSION.V1, // 기본값
maxListeningTime: 15000, // 15초
initTimeoutMs: 5000, // 5초
startTimeoutMs: 5000, // 5초
stopTimeoutMs: 5000, // 5초
...options,
};
// 상태 관리
this.currentState = VOICE_STATE.IDLE;
this.stateListeners = [];
// 타이머 관리
this.listeningTimer = null; // maxListeningTime용 (유일한 타이머)
console.log('[VoiceRecognitionManager] 생성됨', {
webSpeechVersion: this.options.webSpeechVersion,
maxListeningTime: this.options.maxListeningTime,
});
}
// ========================================================================
// 상태 관리 (옵저버 패턴)
// ========================================================================
/**
* 상태 변경
* @param {string} newState - 새 상태 (VOICE_STATE의 값)
*/
setState(newState) {
if (this.currentState === newState) {
return; // 동일한 상태로 변경하려면 무시
}
const oldState = this.currentState;
this.currentState = newState;
console.log(
`[VoiceRecognitionManager] 상태 변경: ${oldState}${newState}`
);
// 모든 리스너에 알림
this.stateListeners.forEach((listener) => {
try {
listener(newState, oldState);
} catch (error) {
console.error('[VoiceRecognitionManager] 리스너 실행 중 에러:', error);
}
});
}
/**
* 상태 변경 리스너 등록
* @param {Function} callback - (newState, oldState) => void
* @returns {Function} unsubscribe 함수
*/
onStateChange(callback) {
this.stateListeners.push(callback);
// unsubscribe 함수 반환
return () => {
this.stateListeners = this.stateListeners.filter((l) => l !== callback);
};
}
// ========================================================================
// 핵심: Promise 체인으로 순차 처리
// ========================================================================
/**
* 음성 인식 시작 (최상위 함수)
*
* Promise 체인:
* cleanup() → initialize() → start() → waitForListening()
*
* ⭐ continuous=true 기반:
* - SilenceDetection 미사용
* - 15초 maxListeningTime 타이머만 사용
* - 사용자가 수동으로 중단하거나 15초 도달 시 종료
*
* @returns {Promise<{status: 'success', state: string, timestamp: string}>}
* @throws {Error} 어느 단계든 실패하면 throw
*/
async startListeningAsync() {
try {
console.log('[VoiceRecognitionManager] startListeningAsync() 시작');
// ========================================
// Step 1: Cleanup
// ========================================
console.log('[VoiceRecognitionManager] [Step 1] Cleanup 시작...');
this.setState(VOICE_STATE.INITIALIZING);
await this.service.cleanupAsync();
console.log('[VoiceRecognitionManager] ✅ [Step 1] Cleanup 완료');
// ========================================
// Step 2: Initialize (continuous=true 강제)
// ========================================
console.log('[VoiceRecognitionManager] [Step 2] Initialize 시작...');
await this.service.initializeAsync(
{
lang: 'en-US',
continuous: true, // ★ 강제 설정 (continuous=false 지원 안 함)
interimResults: true,
},
this.options.initTimeoutMs
);
console.log('[VoiceRecognitionManager] ✅ [Step 2] Initialize 완료');
this.setState(VOICE_STATE.INITIALIZED);
// ========================================
// Step 3: Start (onstart 이벤트까지 대기)
// ========================================
console.log('[VoiceRecognitionManager] [Step 3] Start 시작...');
this.setState(VOICE_STATE.STARTING);
await this.service.startAsync(this.options.startTimeoutMs);
console.log('[VoiceRecognitionManager] ✅ [Step 3] Start 완료 (onstart 이벤트 수신)');
this.setState(VOICE_STATE.STARTED);
// ========================================
// Step 4: Listening (사용자가 말할 수 있는 상태)
// ========================================
console.log('[VoiceRecognitionManager] [Step 4] Listening 상태 진입');
this.setState(VOICE_STATE.LISTENING);
console.log('🎉 [완료] 음성 인식 준비 완료! (사용자가 말할 수 있음)');
// ========================================
// Step 5: 최대 listening 시간 타이머 (15초)
// ========================================
console.log('[VoiceRecognitionManager] [Step 5] MaxListeningTime 타이머 설정 (15초)');
this._setupListeningTimeout();
console.log('[VoiceRecognitionManager] ✅✅✅ 모든 단계 완료!');
return {
status: 'success',
state: VOICE_STATE.LISTENING,
timestamp: new Date().toISOString(),
};
} catch (error) {
console.error('[VoiceRecognitionManager] ❌ [에러] 음성 인식 시작 실패:', error.message);
this.setState(VOICE_STATE.ERROR);
throw error;
}
}
// ========================================================================
// 최대 listening 시간 타이머 (continuous=true 전용)
// ========================================================================
/**
* 최대 listening 시간 타이머 설정
* 15초를 초과하면 자동으로 종료
*
* ⭐ continuous=true에서는 이것이 유일한 자동 종료 메커니즘
*/
_setupListeningTimeout() {
this.listeningTimer = setTimeout(() => {
console.log(
`[VoiceRecognitionManager] [Timeout] ${this.options.maxListeningTime}ms 초과 → 종료`
);
this._finishListening('timeout_max_listening_time');
}, this.options.maxListeningTime);
}
// ========================================================================
// Listening 종료 → Processing → Stopped
// ========================================================================
/**
* Listening 종료
*
* 순서:
* 1. 타이머 정리
* 2. PROCESSING 상태로 전환
* 3. 최종 텍스트 처리 (외부에서 처리)
* 4. Stop 호출 + onend 이벤트까지 대기 ← ★ Promise로 보장
* 5. IDLE 상태로 복귀
*
* @param {string} reason - 종료 이유 (timeout_max_listening_time, user_stopped 등)
* @returns {Promise<{status: 'finished', reason: string}>}
* @throws {Error} Stop 실패
*/
async _finishListening(reason) {
try {
console.log(`[VoiceRecognitionManager] _finishListening() 시작 (이유: ${reason})`);
// ========================================
// Step 1: 타이머 정리
// ========================================
if (this.listeningTimer) {
clearTimeout(this.listeningTimer);
this.listeningTimer = null;
}
console.log('[VoiceRecognitionManager] ✅ 타이머 정리 완료');
// ========================================
// Step 2: PROCESSING 상태로 전환
// ========================================
this.setState(VOICE_STATE.PROCESSING);
console.log('[VoiceRecognitionManager] [Step 1] PROCESSING 상태로 전환');
// ========================================
// Step 3: 최종 텍스트 처리
// ========================================
// 이 부분은 외부(VoiceInputOverlay)에서 처리
console.log('[VoiceRecognitionManager] [Step 2] 최종 텍스트 처리 (외부)');
// ========================================
// Step 4: Stop 호출
// ========================================
console.log('[VoiceRecognitionManager] [Step 3] Stop 시작...');
this.setState(VOICE_STATE.STOPPING);
await this.service.stopAsync(this.options.stopTimeoutMs);
console.log('[VoiceRecognitionManager] ✅ [Step 3] Stop 완료 (onend 이벤트 수신)');
this.setState(VOICE_STATE.STOPPED);
// ========================================
// Step 5: 최종 상태 복귀
// ========================================
this.setState(VOICE_STATE.IDLE);
console.log('[VoiceRecognitionManager] ✅✅✅ 음성 인식 완전 종료');
return {
status: 'finished',
reason: reason,
timestamp: new Date().toISOString(),
};
} catch (error) {
console.error('[VoiceRecognitionManager] ❌ [에러] Listening 종료 실패:', error.message);
this.setState(VOICE_STATE.ERROR);
throw error;
}
}
// ========================================================================
// 에러 복구
// ========================================================================
/**
* 에러 후 재시도
*
* 순서:
* 1. IDLE 상태로 리셋
* 2. 100ms 대기 (상태 정리)
* 3. startListeningAsync() 재호출
*
* @returns {Promise<{status: 'success', state: string, timestamp: string}>}
*/
async retryAsync() {
try {
console.log('[VoiceRecognitionManager] retryAsync() 시작');
// Step 1: IDLE 상태로 리셋
this.setState(VOICE_STATE.IDLE);
// Step 2: 100ms 대기
await new Promise((resolve) => setTimeout(resolve, 100));
// Step 3: 재시작
console.log('[VoiceRecognitionManager] 음성 인식 재시작...');
return await this.startListeningAsync();
} catch (error) {
console.error('[VoiceRecognitionManager] ❌ 재시도 실패:', error.message);
this.setState(VOICE_STATE.ERROR);
throw error;
}
}
// ========================================================================
// 사용자 정보 제공
// ========================================================================
/**
* 현재 상태 반환
*/
getCurrentState() {
return this.currentState;
}
/**
* 현재 상태가 LISTENING인가?
*/
isListening() {
return this.currentState === VOICE_STATE.LISTENING;
}
/**
* 현재 상태가 ERROR인가?
*/
isError() {
return this.currentState === VOICE_STATE.ERROR;
}
/**
* WEBSPEECH_VERSION 조건부 기능 실행
* @param {string} version - 버전 (V1, V2)
* @returns {boolean} 버전 매칭 여부
*/
isVersion(version) {
return this.options.webSpeechVersion === version;
}
}
export default VoiceRecognitionManager;

View File

@@ -0,0 +1,255 @@
// src/services/webSpeech/WebSpeechServiceAsync.js
import WebSpeechService from './WebSpeechService';
/**
* WebSpeechService의 비동기 래퍼 (continuous=true 전용)
* 각 단계별로 Promise를 반환하여 순서 보장
*
* ⚠️ 주의: continuous=true 기반으로 설계됨
* - onend 이벤트가 자동으로 발생하지 않음
* - 명시적으로 stop()을 호출해야 onend 이벤트 발생
* - Promise 체인으로 순서 보장 필수
*/
class WebSpeechServiceAsync extends WebSpeechService {
/**
* Promise 기반 초기화 (cleanup + initialize)
*
* 순서:
* 1. 기존 인식 세션 완전 정리 (동기)
* 2. 새 SpeechRecognition 객체 생성 (동기)
* 3. 설정 적용 (동기)
* 4. 이벤트 핸들러 등록 (동기)
* 5. Promise resolve (동기)
*
* @param {Object} config - WebSpeech 설정 (continuous 강제 true)
* @param {number} timeoutMs - 초기화 타임아웃 (기본 5000ms)
* @returns {Promise<{status: 'initialized', timestamp: string}>}
*/
initializeAsync(config = {}, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
try {
console.log('[WebSpeechServiceAsync] initializeAsync() 시작');
// Step 1: 기존 인식 세션 정리 (동기)
if (this.recognition) {
try {
this.recognition.abort();
console.log('[WebSpeechServiceAsync] 기존 세션 abort() 완료');
// Step 2: 이벤트 핸들러 명시적 제거
this.recognition.onstart = null;
this.recognition.onresult = null;
this.recognition.onerror = null;
this.recognition.onend = null;
console.log('[WebSpeechServiceAsync] 이벤트 핸들러 제거 완료');
} catch (e) {
console.warn('[WebSpeechServiceAsync] Cleanup 중 에러 (무시):', e.message);
}
}
// Step 3: 부모 클래스의 initialize() 호출 (동기)
// - new SpeechRecognition() 객체 생성
// - 설정 적용 (lang, continuous=true, interimResults, maxAlternatives)
// - setupEventHandlers() 호출로 이벤트 리스너 등록
//
// ⚠️ continuous: true 강제 설정!
const mergedConfig = {
...config,
continuous: true, // ★ continuous=false는 지원하지 않음
};
const success = this.initialize(mergedConfig);
if (!success) {
reject(new Error('WebSpeechService not supported or initialization failed'));
return;
}
console.log('[WebSpeechServiceAsync] initialize() 완료');
// Step 4: Promise resolve (동기)
// 이 시점에서 객체 생성과 설정이 모두 완료됨
resolve({
status: 'initialized',
config: {
lang: this.recognition.lang,
continuous: this.recognition.continuous,
interimResults: this.recognition.interimResults,
maxAlternatives: this.recognition.maxAlternatives,
},
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('[WebSpeechServiceAsync] initializeAsync() 에러:', error.message);
reject(new Error(`Initialize failed: ${error.message}`));
}
});
}
/**
* Promise 기반 시작
* recognition.start() 호출 후 onstart 이벤트까지 대기
*
* 순서:
* 1. recognition.start() 호출 (동기) → 마이크 접근 권한 요청
* 2. onstart 이벤트 대기 (비동기) → 150-300ms 후
* 3. 또는 onerror 이벤트 수신 → 즉시~수초
* 4. 타임아웃 처리 → 5초
*
* @param {number} timeoutMs - 타임아웃 시간 (기본 5000ms)
* @returns {Promise<{status: 'listening', timestamp: string}>}
*
* @throws {Error} start() 실패 또는 onstart 타임아웃
*/
startAsync(timeoutMs = 5000) {
return new Promise((resolve, reject) => {
console.log('[WebSpeechServiceAsync] startAsync() 시작');
// Step 1: recognition.start() 호출 (동기)
const success = this.start();
if (!success) {
console.error('[WebSpeechServiceAsync] start() 실패 (이미 리스닝 중이거나 초기화 안 됨)');
reject(new Error('WebSpeech start() failed - already listening or not initialized'));
return;
}
console.log('[WebSpeechServiceAsync] start() 호출 완료, onstart 이벤트 대기 중...');
// Step 2: 타임아웃 설정
const timeoutId = setTimeout(() => {
console.error(`[WebSpeechServiceAsync] onstart 이벤트 타임아웃 (${timeoutMs}ms)`);
reject(new Error(`onstart event timeout (${timeoutMs}ms)`));
}, timeoutMs);
// Step 3: onstart 이벤트 대기
const originalOnStart = this.callbacks.onStart;
this.callbacks.onStart = () => {
clearTimeout(timeoutId);
console.log('[WebSpeechServiceAsync] onstart 이벤트 수신 ✅');
// 원래 onStart 콜백도 호출
if (originalOnStart) {
originalOnStart();
}
// Promise resolve
resolve({
status: 'listening',
timestamp: new Date().toISOString(),
});
};
// Step 4: onerror 이벤트도 처리
const originalOnError = this.callbacks.onError;
this.callbacks.onError = (errorInfo) => {
clearTimeout(timeoutId);
console.error('[WebSpeechServiceAsync] onerror 이벤트 수신:', errorInfo);
// 원래 onError 콜백도 호출
if (originalOnError) {
originalOnError(errorInfo);
}
// Promise reject
reject(new Error(`WebSpeech error: ${errorInfo.error} - ${errorInfo.message}`));
};
});
}
/**
* Promise 기반 중지
* recognition.stop() 호출 후 onend 이벤트까지 대기
*
* ⚠️ continuous=true 특수성:
* - onend 이벤트가 자동으로 발생하지 않음
* - 명시적으로 stop()을 호출해야 onend 이벤트 발생
* - 따라서 이 메서드는 "stop() 호출 후 onend 대기"를 보장
*
* 순서:
* 1. isListening 확인 (동기)
* 2. recognition.stop() 호출 (동기)
* 3. onend 이벤트 대기 (비동기) ← ★ continuous=true에서는 필수
* 4. 또는 onerror 이벤트
* 5. 타임아웃 처리 → 5초
*
* @param {number} timeoutMs - 타임아웃 시간 (기본 5000ms)
* @returns {Promise<{status: 'stopped', timestamp: string}>}
*
* @throws {Error} 현재 리스닝 중이 아니거나 onend 타임아웃
*/
stopAsync(timeoutMs = 5000) {
return new Promise((resolve, reject) => {
console.log('[WebSpeechServiceAsync] stopAsync() 시작');
// Step 1: 현재 리스닝 상태 확인
if (!this.isListening) {
console.warn('[WebSpeechServiceAsync] 현재 리스닝 중이 아님');
reject(new Error('Not currently listening'));
return;
}
// Step 2: 타임아웃 설정
const timeoutId = setTimeout(() => {
console.error(`[WebSpeechServiceAsync] onend 이벤트 타임아웃 (${timeoutMs}ms)`);
reject(new Error(`onend event timeout (${timeoutMs}ms)`));
}, timeoutMs);
// Step 3: onend 이벤트 대기
const originalOnEnd = this.callbacks.onEnd;
this.callbacks.onEnd = () => {
clearTimeout(timeoutId);
console.log('[WebSpeechServiceAsync] onend 이벤트 수신 ✅');
// 원래 onEnd 콜백도 호출
if (originalOnEnd) {
originalOnEnd();
}
// Promise resolve
resolve({
status: 'stopped',
timestamp: new Date().toISOString(),
});
};
// Step 4: recognition.stop() 호출 (동기)
// ⚠️ continuous=true이므로 명시적으로 호출 필수
this.stop();
console.log('[WebSpeechServiceAsync] stop() 호출 완료, onend 이벤트 대기 중...');
});
}
/**
* Promise 기반 정리
*
* @returns {Promise<{status: 'cleaned', timestamp: string}>}
* @throws {Error} cleanup 중 에러
*/
cleanupAsync() {
return new Promise((resolve, reject) => {
try {
console.log('[WebSpeechServiceAsync] cleanupAsync() 시작');
// 부모 클래스의 cleanup() 호출 (동기)
// - abort()
// - 콜백 초기화
this.cleanup();
console.log('[WebSpeechServiceAsync] cleanup() 완료 ✅');
resolve({
status: 'cleaned',
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('[WebSpeechServiceAsync] cleanupAsync() 에러:', error.message);
reject(new Error(`Cleanup failed: ${error.message}`));
}
});
}
}
export default WebSpeechServiceAsync;

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import css from './ApiStatusDisplay.module.less';
/**
* API 상태 표시 컴포넌트
*
* 오른쪽 아래에 조용한 상태 메시지 표시
* - 로딩: "API Status..."
* - 성공: "Success"
* - 오류: 오류 메시지
*
* PROMPT/LISTENING 모드 상관없이 항상 표시
* query/searchId와 겹치지 않는 위치에 배치
*/
const ApiStatusDisplay = ({
status = 'idle', // 'idle', 'loading', 'success', 'error'
message = 'API Status', // 표시할 메시지
autoHideDuration = 3000, // ms 단위 (성공시만 적용)
onStatusChange = null, // 상태 변경 콜백
}) => {
const [displayMessage, setDisplayMessage] = useState(message);
const [displayStatus, setDisplayStatus] = useState(status);
const [isVisible, setIsVisible] = useState(true);
const [hideTimer, setHideTimer] = useState(null);
// status 변경시
useEffect(() => {
setDisplayStatus(status);
setDisplayMessage(message);
// 이전 타이머 정리
if (hideTimer) {
clearTimeout(hideTimer);
setHideTimer(null);
}
// 성공시 자동 숨김
if (status === 'success') {
const timer = setTimeout(() => {
setIsVisible(false);
}, autoHideDuration);
setHideTimer(timer);
} else {
setIsVisible(true);
}
// 콜백 호출
if (onStatusChange) {
onStatusChange({ status, message });
}
// 정리
return () => {
if (hideTimer) {
clearTimeout(hideTimer);
}
};
}, [status, message, autoHideDuration, onStatusChange]);
if (!isVisible) {
return null;
}
return (
<div
className={`${css.apiStatusDisplay} ${css[`status_${displayStatus}`]}`}
role="status"
aria-label={`API Status: ${displayMessage}`}
>
{/* 상태 아이콘 */}
<div className={css.iconContainer}>
{displayStatus === 'loading' && (
<div className={css.spinner}>
<div className={css.spinnerInner}></div>
</div>
)}
{displayStatus === 'success' && (
<div className={css.successIcon}></div>
)}
{displayStatus === 'error' && (
<div className={css.errorIcon}>!</div>
)}
{displayStatus === 'idle' && (
<div className={css.idleIcon}></div>
)}
</div>
{/* 상태 메시지 */}
<div className={css.messageContainer}>
<span className={css.message}>{displayMessage}</span>
</div>
</div>
);
};
ApiStatusDisplay.propTypes = {
status: PropTypes.oneOf(['idle', 'loading', 'success', 'error']),
message: PropTypes.string,
autoHideDuration: PropTypes.number,
onStatusChange: PropTypes.func,
};
export default ApiStatusDisplay;

View File

@@ -0,0 +1,207 @@
@import '../../../style/CommonStyle.module.less';
.apiStatusDisplay {
position: fixed;
bottom: 30px;
right: 30px;
display: flex;
align-items: center;
gap: 16px;
padding: 16px 28px;
background: rgba(0, 0, 0, 0.75);
border-radius: 16px;
color: #ffffff;
font-size: 24px;
font-weight: 400;
font-family: "LG Smart UI";
z-index: 10000;
max-width: 600px;
opacity: 0.9;
transition: all 0.3s ease;
line-height: 28px;
&.status_idle {
background: rgba(100, 100, 100, 0.75);
color: #cccccc;
.idleIcon {
color: #999999;
}
}
&.status_loading {
background: rgba(0, 100, 200, 0.75);
color: #ffffff;
.spinner {
display: block;
}
}
&.status_success {
background: rgba(0, 150, 80, 0.75);
color: #ffffff;
animation: slideInSuccess 0.3s ease;
.successIcon {
display: flex;
color: #ffffff;
font-weight: bold;
font-size: 24px;
}
}
&.status_error {
background: rgba(200, 50, 50, 0.75);
color: #ffffff;
animation: slideInError 0.3s ease;
.errorIcon {
display: flex;
color: #ffffff;
font-weight: bold;
font-size: 24px;
}
}
}
.iconContainer {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 32px;
flex-shrink: 0;
}
/* 로딩 스피너 */
.spinner {
display: none;
position: relative;
width: 28px;
height: 28px;
.spinnerInner {
position: absolute;
width: 100%;
height: 100%;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid #ffffff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
/* 성공 아이콘 */
.successIcon {
display: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
}
/* 에러 아이콘 */
.errorIcon {
display: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
}
/* 대기 아이콘 */
.idleIcon {
font-size: 24px;
}
.messageContainer {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.message {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 24px;
font-family: "LG Smart UI";
font-weight: 400;
line-height: 28px;
}
/* 애니메이션 */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes slideInSuccess {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 0.9;
transform: translateY(0);
}
}
@keyframes slideInError {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 0.9;
transform: translateY(0);
}
}
/* 모바일 대응 */
@media (max-width: 768px) {
.apiStatusDisplay {
bottom: 20px;
right: 20px;
padding: 12px 20px;
font-size: 16px;
max-width: 380px;
gap: 12px;
.message {
font-size: 16px;
}
}
.iconContainer {
width: 24px;
height: 24px;
}
.spinner {
width: 20px;
height: 20px;
.spinnerInner {
border-width: 2px;
border-top-width: 2px;
}
}
.successIcon,
.errorIcon {
width: 24px;
height: 24px;
}
.idleIcon {
font-size: 16px;
}
}

View File

@@ -32,6 +32,7 @@ import VoiceResponse from './modes/VoiceResponse';
import VoiceApiError from './modes/VoiceApiError'; import VoiceApiError from './modes/VoiceApiError';
import WebSpeechEventDebug from './WebSpeechEventDebug'; import WebSpeechEventDebug from './WebSpeechEventDebug';
import VoiceDebugDashboard from './VoiceDebugDashboard'; import VoiceDebugDashboard from './VoiceDebugDashboard';
import ApiStatusDisplay from './ApiStatusDisplay';
import css from './VoiceInputOverlay.module.less'; import css from './VoiceInputOverlay.module.less';
const OverlayContainer = SpotlightContainerDecorator( const OverlayContainer = SpotlightContainerDecorator(
@@ -195,6 +196,9 @@ const VoiceInputOverlay = ({
const [isBubbleClickSearch, setIsBubbleClickSearch] = useState(false); const [isBubbleClickSearch, setIsBubbleClickSearch] = useState(false);
// 디버그 대시보드 표시 여부 // 디버그 대시보드 표시 여부
const [showDashboard, setShowDashboard] = useState(false); const [showDashboard, setShowDashboard] = useState(false);
// API 상태 표시용 state
const [apiStatus, setApiStatus] = useState('idle'); // 'idle', 'loading', 'success', 'error'
const [apiStatusMessage, setApiStatusMessage] = useState('API Status...');
// useSearchHistory Hook 적용 (음성검색 기록 관리) // useSearchHistory Hook 적용 (음성검색 기록 관리)
const { addVoiceSearch } = useSearchHistory(); const { addVoiceSearch } = useSearchHistory();
@@ -581,14 +585,14 @@ const VoiceInputOverlay = ({
} }
}, [isVisible, isListening, addWebSpeechEventLog]); }, [isVisible, isListening, addWebSpeechEventLog]);
// ShopperHouse API 오류 감지 및 APIError 모드로 전환 // ShopperHouse API 오류 감지 및 API 상태 표시 업데이트
// ⚠️ 최적화: 실시간 상태 표시 제거, 결과만 표시
useEffect(() => { useEffect(() => {
if (DEBUG_MODE) { if (DEBUG_MODE) {
console.log('🔍 [DEBUG] shopperHouseError useEffect running:', { console.log('🔍 [DEBUG] shopperHouseError useEffect running:', {
isVisible, isVisible,
hasError: !!shopperHouseError, hasError: !!shopperHouseError,
error: shopperHouseError, error: shopperHouseError,
currentMode,
}); });
} }
@@ -600,29 +604,25 @@ const VoiceInputOverlay = ({
return; return;
} }
// 오류가 발생하고 overlay가 표시 중이며 현재 모드가 RESPONSE일 경우 APIError 모드로 전환 // 오류가 발생 경우 - API 상태 표시 업데이트만 (팝업 대신)
if (isVisible && shopperHouseError && currentMode === VOICE_MODES.RESPONSE) { if (shopperHouseError && isVisible) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
console.log( console.log('[VoiceInputOverlay] ❌ API error - showing result only:', shopperHouseError);
'[VoiceInputOverlay] ❌ ShopperHouse API error detected, switching to APIERROR mode:',
{
error: shopperHouseError,
currentMode,
}
);
} }
// APIError 모드로 전환 // ⚠️ 심각한 오류만 표시 (5xx 서버 오류, 커스텀 메시지)
setCurrentMode(VOICE_MODES.APIERROR); // 인증 관련 오류(401, 402, 501)나 네트워크 오류는 필터링됨 (searchActions.js에서)
setVoiceInputMode(null); const errorMsg = shopperHouseError.retMsg || shopperHouseError.message || '오류 발생';
setApiStatus('error');
setApiStatusMessage(errorMsg);
} }
return () => { return () => {
// Cleanup: 필요 시 정리 로직 추가 // Cleanup: 필요 시 정리 로직 추가
}; };
}, [shopperHouseError, isVisible, currentMode]); }, [shopperHouseError, isVisible]);
// ShopperHouse API 응답 수신 시 overlay 닫기 // ShopperHouse API 응답 수신 시 overlay 닫기 및 API 상태 업데이트
useEffect(() => { useEffect(() => {
if (DEBUG_MODE) { if (DEBUG_MODE) {
console.log('🔍 [DEBUG] shopperHouseData useEffect running:', { console.log('🔍 [DEBUG] shopperHouseData useEffect running:', {
@@ -652,6 +652,14 @@ const VoiceInputOverlay = ({
} }
shopperHouseDataRef.current = shopperHouseData; shopperHouseDataRef.current = shopperHouseData;
// ✅ API 성공 상태 표시
setApiStatus('success');
setApiStatusMessage('Success');
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay] 📍 API Status updated:', { status: 'success', message: 'Success' });
}
// 직접 닫기 (VoiceResponse 컴포넌트에서 결과를 표시한 후 닫음) // 직접 닫기 (VoiceResponse 컴포넌트에서 결과를 표시한 후 닫음)
onClose(); onClose();
} }
@@ -1538,7 +1546,9 @@ const VoiceInputOverlay = ({
if (currentMode === VOICE_MODES.PROMPT) { if (currentMode === VOICE_MODES.PROMPT) {
// ✨ PROMPT 모드에서만 LISTENING으로 전환 가능 // ✨ PROMPT 모드에서만 LISTENING으로 전환 가능
console.log('[VoiceInput] 🎙️ 마이크 버튼 클릭 - WebSpeech 재초기화 시작'); console.log('\n🎤 ════════════════════════════════════════════════════════════════');
console.log('🎤 [VoiceInput] MIC BUTTON CLICKED - WebSpeech Initialization Flow');
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
// 📋 단계별 처리: // 📋 단계별 처리:
// 1. WebSpeech 완전 cleanup (이전 상태 제거) // 1. WebSpeech 완전 cleanup (이전 상태 제거)
@@ -1549,34 +1559,44 @@ const VoiceInputOverlay = ({
// ============================================================ // ============================================================
// 1⃣ WebSpeech 완전 cleanup (이전 상태 제거) // 1⃣ WebSpeech 완전 cleanup (이전 상태 제거)
// ============================================================ // ============================================================
console.log('[VoiceInput] ├─ [Step 1] WebSpeech cleanup 시작'); console.log('[VoiceInput] 📍 [STEP 1] WebSpeech Cleanup');
console.log('[VoiceInput] ├─ Dispatching: cleanupWebSpeech()');
dispatch(cleanupWebSpeech()); dispatch(cleanupWebSpeech());
console.log('[VoiceInput] └─ ✅ Cleanup dispatched\n');
// ============================================================ // ============================================================
// 2⃣ Redux STT 데이터 초기화 (searchId는 Redux에서 유지됨) // 2⃣ Redux STT 데이터 초기화 (searchId는 Redux에서 유지됨)
// ============================================================ // ============================================================
console.log('[VoiceInput] ├─ [Step 2] STT 데이터 초기화 (searchId 유지)'); console.log('[VoiceInput] 📍 [STEP 2] STT Data Clear');
console.log('[VoiceInput] ├─ Dispatching: clearSTTText()');
dispatch(clearSTTText()); dispatch(clearSTTText());
console.log('[VoiceInput] ├─ Clearing interim text buffer');
// ✅ TInput 초기화 // ✅ TInput 초기화
if (onSearchChange) { if (onSearchChange) {
onSearchChange({ value: '' }); onSearchChange({ value: '' });
console.log('[VoiceInput] ├─ TInput cleared');
} }
// ✅ Interim text ref 초기화 // ✅ Interim text ref 초기화
interimTextRef.current = ''; interimTextRef.current = '';
console.log('[VoiceInput] ├─ Interim text ref cleared');
// 기존 타이머 정리 // 기존 타이머 정리
clearTimerRef(listeningTimerRef); clearTimerRef(listeningTimerRef);
clearTimerRef(silenceDetectionTimerRef); clearTimerRef(silenceDetectionTimerRef);
console.log('[VoiceInput] ├─ Previous timers cleared');
// UI 모드 업데이트 // UI 모드 업데이트
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH); setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING); setCurrentMode(VOICE_MODES.LISTENING);
console.log('[VoiceInput] ├─ UI mode set to WEBSPEECH / LISTENING');
// ✅ LISTENING 모드 진입 시 로그 초기화 (새로운 음성 입력 시작) // ✅ LISTENING 모드 진입 시 로그 초기화 (새로운 음성 입력 시작)
setWebSpeechEventLogs([]); setWebSpeechEventLogs([]);
writeLocalStorage(VOICE_EVENT_LOGS_KEY, []); writeLocalStorage(VOICE_EVENT_LOGS_KEY, []);
console.log('[VoiceInput] └─ ✅ Event logs cleared\n');
if (DEBUG_MODE) { if (DEBUG_MODE) {
console.log('🧹 [DEBUG] Cleared webSpeechEventLogs on mic click'); console.log('🧹 [DEBUG] Cleared webSpeechEventLogs on mic click');
} }
@@ -1584,8 +1604,12 @@ const VoiceInputOverlay = ({
// ============================================================ // ============================================================
// 3⃣ WebSpeech 재초기화 (약간의 지연 후) // 3⃣ WebSpeech 재초기화 (약간의 지연 후)
// ============================================================ // ============================================================
console.log('[VoiceInput] ├─ [Step 3] WebSpeech 재초기화 시작'); console.log('[VoiceInput] ⏳ [STEP 3] Waiting 100ms for Redux state sync...');
const reinitializeWebSpeech = setTimeout(() => { const reinitializeWebSpeech = setTimeout(() => {
console.log('[VoiceInput] 📍 [STEP 3] WebSpeech Initialization');
console.log('[VoiceInput] ├─ Dispatching: initializeWebSpeech()');
console.log('[VoiceInput] │ └─ lang: en-US, continuous: true, interimResults: true');
// Redux 상태 업데이트를 기다리기 위해 약간의 지연 // Redux 상태 업데이트를 기다리기 위해 약간의 지연
dispatch( dispatch(
initializeWebSpeech({ initializeWebSpeech({
@@ -1595,17 +1619,37 @@ const VoiceInputOverlay = ({
}) })
); );
console.log('[VoiceInput] └─ ✅ Initialize dispatched\n');
// ============================================================ // ============================================================
// 4⃣ WebSpeech 즉시 시작 // 4⃣ WebSpeech 즉시 시작
// ============================================================ // ============================================================
console.log('[VoiceInput] └─ [Step 4] WebSpeech 시작'); console.log('[VoiceInput] 📍 [STEP 4] WebSpeech Start');
console.log('[VoiceInput] ├─ Dispatching: startWebSpeech()');
dispatch(startWebSpeech()); dispatch(startWebSpeech());
console.log('[VoiceInput] └─ ✅ Start dispatched\n');
// 15초 타이머 설정: 최대 입력 시간 제한 // ============================================================
// 5⃣ 15초 타이머 설정 및 준비 완료
// ============================================================
console.log('[VoiceInput] 📍 [STEP 5] Setup MaxListeningTime Timer');
console.log('[VoiceInput] ├─ Setting 15-second listening timeout');
listeningTimerRef.current = setTimeout(() => { listeningTimerRef.current = setTimeout(() => {
console.log('[VoiceInput] ⏲️ [TIMEOUT] 15-second max listening time reached');
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input'); addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
processFinalVoiceInput('15초 타임아웃'); processFinalVoiceInput('15초 타임아웃');
}, 15000); // 15초 }, 15000); // 15초
console.log('[VoiceInput] ├─ ⏲️ Timer started (15000ms)');
console.log('[VoiceInput] └─ ✅ Timer configured\n');
// ============================================================
// ✨ INITIALIZATION COMPLETE
// ============================================================
console.log('🎤 ════════════════════════════════════════════════════════════════');
console.log('✨ [VoiceInput] 음성 인식 준비 완료! (Voice Recognition Ready!)');
console.log('✨ [VoiceInput] Waiting for voice input... (max 15 seconds)');
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
}, 100); }, 100);
// Cleanup: 언마운트 시 타이머 정리 // Cleanup: 언마운트 시 타이머 정리
@@ -1963,6 +2007,13 @@ const VoiceInputOverlay = ({
isBubbleClickSearch={isBubbleClickSearch} isBubbleClickSearch={isBubbleClickSearch}
/> />
</div> </div>
{/* API 상태 표시 - 오른쪽 아래에 조용하게 표시 */}
<ApiStatusDisplay
status={apiStatus}
message={apiStatusMessage}
autoHideDuration={3000}
/>
</TFullPopup> </TFullPopup>
); );
}; };