[251020] fix: VoiceInput

🕐 커밋 시간: 2025. 10. 20. 15:51:53

📊 변경 통계:
  • 총 파일: 11개
  • 추가: +377줄
  • 삭제: -142줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/shopperHouseResponse.txt
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/searchActions.js
  ~ com.twin.app.shoptime/src/actions/webSpeechActions.js
  ~ com.twin.app.shoptime/src/reducers/searchReducer.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/TInput/TInput.module.less
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.jsx

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
  • 대규모 기능 개발
  • 모듈 구조 개선
This commit is contained in:
2025-10-20 15:51:58 +09:00
parent dc43a2d5ae
commit c25b4a806c
11 changed files with 418 additions and 142 deletions

View File

@@ -0,0 +1,41 @@
HTTP/1.1 200 OK
X-Server-Time: 1284366813334
Content-Type: application/json
{
“retCode”: 0,
“retMsg”: “Success”
"data": {
"result": {
"time": "25 ms",
"results": [
{
"docs": [
{
"dcPrice": "$ 69.99",
"thumbnail": "https://media.us.lg.com/transform/ecomm-PDPGallery-1100x730/e9b7c49b-66ed-45d4-8890-dd32c91a2053/TV-accessories_WS25XA_gallery-01_3000x3000",
"reviewGrade": "",
"partnerName": "LGE",
"partnerLogo": "http://aic-ngfts.lge.com/fts/gftsDownload.lge?biz_code=LGSHOPPING&func_code=IMAGE&file_path=/lgshopping/image/us_obs_logo_60x60.png",
"price": "$ 69.99",
"contentId": "V3_8001_Tv Search_PD_9_WS25XA",
"title": "StandbyME 2 Carry Strap & Wall-Mount Holder",
"soldout": "N",
"rankInfo": 1,
"euEnrgLblInfos": [ ]
}
],
"total_count": 100,
"type": "item",
"hit_count": 100,
"searchId": "SEARCH_uCS3z1N0QgtRXjsyhDCpA0R80",
"sortingType": "LG_RECOMMENDED",
"rangeType": “SIMILAR",
"createdAt": “2025-09-23 13:23:11",
"relativeQuerys": [
"What are some luxury skincare products",
"What are some luxury skincare products"
]
}
],
"htt

View File

@@ -169,6 +169,7 @@ export const types = {
// search actions
GET_SEARCH: 'GET_SEARCH',
GET_SHOPPERHOUSE_SEARCH: 'GET_SHOPPERHOUSE_SEARCH',
CLEAR_SHOPPERHOUSE_DATA: 'CLEAR_SHOPPERHOUSE_DATA',
RESET_SEARCH: 'RESET_SEARCH',
GET_SEARCH_PROCESSED: 'GET_SEARCH_PROCESSED',
SET_SEARCH_INIT_PERFORMED: 'SET_SEARCH_INIT_PERFORMED',

View File

@@ -87,21 +87,48 @@ export const updateSearchTimestamp = () => ({
});
// ShopperHouse 검색 조회 IF-LGSP-098
let getShopperHouseSearchKey = null;
export const getShopperHouseSearch =
(query, searchId = null) =>
(dispatch, getState) => {
// 새로운 검색 시작 - 고유 키 생성
const currentSearchKey = new Date().getTime();
getShopperHouseSearchKey = currentSearchKey;
console.log('[ShopperHouse] 🔍 [DEBUG] API 호출 시작 - key:', currentSearchKey, 'query:', query);
const onSuccess = (response) => {
console.log(
'[ShopperHouse] getShopperHouseSearch onSuccess: ',
JSON.stringify(response.data)
);
console.log('[ShopperHouse] 📥 [DEBUG] API 응답 도착 - key:', currentSearchKey);
console.log('[ShopperHouse] 🔑 [DEBUG] 현재 유효한 key:', getShopperHouseSearchKey);
dispatch({
type: types.GET_SHOPPERHOUSE_SEARCH,
payload: response.data,
});
// ✨ 현재 요청이 최신 요청인지 확인
if (currentSearchKey === getShopperHouseSearchKey) {
console.log('[ShopperHouse] ✅ [DEBUG] 유효한 응답 - Redux 업데이트');
console.log('[ShopperHouse] getShopperHouseSearch onSuccess: ', JSON.stringify(response.data));
dispatch(updateSearchTimestamp());
// ✅ API 성공 여부 확인
const retCode = response.data?.retCode;
if (retCode !== 0) {
console.error('[ShopperHouse] ❌ API 실패 - retCode:', retCode, 'retMsg:', response.data?.retMsg);
return;
}
// ✅ result 데이터 존재 확인
if (!response.data?.data?.result) {
console.error('[ShopperHouse] ❌ API 응답에 result 데이터 없음');
return;
}
dispatch({
type: types.GET_SHOPPERHOUSE_SEARCH,
payload: response.data,
});
dispatch(updateSearchTimestamp());
} else {
console.log('[ShopperHouse] ❌ [DEBUG] 오래된 응답 무시 - Redux 업데이트 안함');
}
};
const onFail = (error) => {
@@ -116,3 +143,14 @@ export const getShopperHouseSearch =
TAxios(dispatch, getState, 'post', URLS.GET_SHOPPERHOUSE_SEARCH, {}, params, onSuccess, onFail);
};
// ShopperHouse 검색 데이터 초기화
export const clearShopperHouseData = () => {
// ✨ 검색 키 초기화 - 진행 중인 요청의 응답을 무시하도록
console.log('[ShopperHouse] 🧹 [DEBUG] clearShopperHouseData - 이전 요청 무효화');
getShopperHouseSearchKey = null;
return {
type: types.CLEAR_SHOPPERHOUSE_DATA,
};
};

View File

@@ -127,3 +127,13 @@ export const cleanupWebSpeech = () => (dispatch) => {
type: types.WEB_SPEECH_CLEANUP,
});
};
/**
* STT 텍스트 초기화 (이전 음성 인식 결과 제거)
*/
export const clearSTTText = () => (dispatch) => {
console.log('[WebSpeechActions] Clearing STT text...');
dispatch({
type: types.VOICE_CLEAR_STATE,
});
};

View File

@@ -60,7 +60,14 @@ export const searchReducer = (state = initialState, action) => {
};
case types.GET_SHOPPERHOUSE_SEARCH: {
const resultData = action.payload.data.result;
// ✅ 안전한 데이터 접근
const resultData = action.payload?.data?.result;
if (!resultData) {
console.error('[searchReducer] Invalid shopperHouse data structure');
return state;
}
const results = resultData.results || [];
// searchId 추출 (첫 번째 result에서)
@@ -78,6 +85,13 @@ export const searchReducer = (state = initialState, action) => {
};
}
case types.CLEAR_SHOPPERHOUSE_DATA:
return {
...state,
shopperHouseData: null,
shopperHouseSearchId: null,
};
default:
return state;
}

View File

@@ -84,6 +84,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
const [isSearchOverlayVisible, setIsSearchOverlayVisible] = useState(false);
// isVoiceOverlayVisible 상태 변화 추적
useEffect(() => {
console.log('🔄 [DEBUG][SearchPanel] isVoiceOverlayVisible changed to:', isVoiceOverlayVisible);
}, [isVoiceOverlayVisible]);
//인풋창 포커스 구분을 위함
const [inputFocus, setInputFocus] = useState(false);
const _onFocus = useCallback(() => {
@@ -115,11 +120,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const initialFocusTimerRef = useRef(null);
const spotlightResumeTimerRef = useRef(null);
const onFocusMic = useCallback(() => {
console.log('[MicFocus]');
// 포커스 시 VoiceInputOverlay 표시
setIsVoiceOverlayVisible(true);
}, []);
// const onFocusMic = useCallback(() => {
// console.log('🎙️ [DEBUG][SearchPanel] onFocusMic called, current isVoiceOverlayVisible:', isVoiceOverlayVisible);
// // 포커스 시 VoiceInputOverlay 표시
// setIsVoiceOverlayVisible(true);
// }, [isVoiceOverlayVisible]);
// 가짜 데이터 - 실제로는 Redux store나 API에서 가져와야 함
const recentSearches = useMemo(() => ['Puppy food', 'Dog toy', 'Fitness'], []);
@@ -314,6 +319,20 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
};
}, [isOnTop, isOnTopRef]);
// ✨ ShopperHouse 검색 결과 수신 시 TInput으로 포커스 이동
useEffect(() => {
if (shopperHouseData && isOnTop) {
// ShopperHouse 검색 결과가 들어왔을 때 TInput으로 포커스 이동
const focusTimer = setTimeout(() => {
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}, 600); // VoiceInputOverlay 닫히는 시간(500ms) + 여유(100ms)
return () => {
clearTimeout(focusTimer);
};
}
}, [shopperHouseData, isOnTop]);
useEffect(() => {
return () => {
const currentSearchVal = searchQueryRef.current;
@@ -406,9 +425,10 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (!isOnTopRef.current) {
return;
}
// 마이크 버튼 클릭 시 voice overlay 토글
setIsVoiceOverlayVisible((prev) => !prev);
}, [isOnTopRef]);
console.log('🖱️ [DEBUG][SearchPanel] onClickMic called, current isVoiceOverlayVisible:', isVoiceOverlayVisible);
setIsVoiceOverlayVisible(true);
// setIsVoiceOverlayVisible((prev) => !prev);
}, [isOnTopRef, isVoiceOverlayVisible]);
const onCancel = useCallback(() => {
if (!isOnTopRef.current) {
@@ -502,6 +522,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// Voice overlay close handler
const handleVoiceOverlayClose = useCallback(() => {
console.log('🚪 [DEBUG][SearchPanel] handleVoiceOverlayClose called, setting isVoiceOverlayVisible to FALSE');
setIsVoiceOverlayVisible(false);
}, []);
@@ -630,7 +651,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
<SpottableMicButton
className={css.microphoneButton}
onClick={onClickMic}
onFocus={onFocusMic}
// onFocus={onFocusMic}
onKeyDown={handleMicKeyDown}
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
>

View File

@@ -25,7 +25,9 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
return null;
}
const docs = shopperHouseInfo.results[0].docs || [];
const resultData = shopperHouseInfo.results[0];
const docs = resultData.docs || [];
return docs.map((doc) => {
const contentId = doc.contentId;
const tokens = contentId.split('_');
@@ -33,16 +35,22 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
const prdtId = tokens?.[5] || '';
return {
thumbnail: doc.thumnail || doc.imgPath || '', //이미지 경로
thumbnail: doc.thumbnail || doc.imgPath || '', // 이미지 경로 (API 필드명 수정)
title: doc.title || doc.prdtName || '', // 제목
dcPrice: doc.dcPrice || doc.price || '', // 할인가격
price: doc.orgPrice || doc.price || '', // 원가
soldout: doc.soldout || false, // 품절 여부
contentId, //콘텐트 아이디
reviewGrade: doc.reviewGrade || '', //리뷰 점수 (추가 정보)
partnerName: doc.partnerName || '', //파트너 네임
price: doc.price || '', // 원가
soldout: doc.soldout || 'N', // 품절 여부
contentId, // 콘텐트 아이디
reviewGrade: doc.reviewGrade || '', // 리뷰 점수
partnerName: doc.partnerName || '', // 파트너 네임
partnerLogo: doc.partnerLogo || '', // 파트너 로고 (API 명세서 추가)
rankInfo: doc.rankInfo || 0, // 랭킹 정보 (API 명세서 추가)
patnrId, // 파트너 아이디
prdtId, // 상품 아이디
// results 레벨 추가 정보
searchId: resultData.searchId || '',
sortingType: resultData.sortingType || '',
rangeType: resultData.rangeType || '',
};
});
}, [shopperHouseInfo]);
@@ -175,19 +183,24 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
[themeInfo]
);
// relativeQuerys 가져오기 (ShopperHouse API 응답)
const relativeQuerys = useMemo(() => {
if (shopperHouseInfo?.results?.[0]?.relativeQuerys) {
return shopperHouseInfo.results[0].relativeQuerys;
}
// 기본값
return ['Puppy food', 'Dog toy', 'Fitness'];
}, [shopperHouseInfo]);
return (
<div className={css.searchBox}>
<div className={css.topBox}>
<span className={css.topBoxTitle}>How about these?</span>
<ul className={css.topBoxList}>
{[
{ text: 'Puppy food', key: 'puppy-food' },
{ text: 'Dog toy', key: 'dog-toy' },
{ text: 'Fitness', key: 'fitness' },
].map(({ text, key }) => {
{relativeQuerys.map((text, index) => {
const handleClick = () => keywordClick(text);
return (
<SpottableLi key={key} className={css.topBoxListItem} onClick={handleClick}>
<SpottableLi key={`query-${index}`} className={css.topBoxListItem} onClick={handleClick}>
{text}
</SpottableLi>
);

View File

@@ -7,11 +7,11 @@
position: relative;
// 입력 가능 모드일 때 Container 스타일
&.containerActive {
.input {
background-color: #ffff00 !important; // 노란색 배경
}
}
// &.containerActive {
// .input {
// background-color: #ffff00 !important; // 노란색 배경
// }
// }
.input {
margin-right: 0;
@@ -42,9 +42,9 @@
// 입력 가능 모드 (webOS 키보드가 뜨는 상태)
// .input과 함께 사용되므로 더 구체적인 선택자 필요
.input.inputActive {
background-color: #ffff00 !important; // 노란색 배경
}
// .input.inputActive {
// background-color: #ffff00 !important; // 노란색 배경
// }
.withIcon {
> div {

View File

@@ -10,10 +10,12 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco
import Spottable from '@enact/spotlight/Spottable';
import micIcon from '../../../../assets/images/searchpanel/image-mic.png';
import { getShopperHouseSearch } from '../../../actions/searchActions';
import { getShopperHouseSearch, clearShopperHouseData } from '../../../actions/searchActions';
import { clearSTTText } from '../../../actions/webSpeechActions';
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
import TInput, { ICONS, KINDS } from '../TInput/TInput';
import { useWebSpeech } from '../../../hooks/useWebSpeech';
import { readLocalStorage, writeLocalStorage } from '../../../utils/helperMethods';
import VoiceListening from './modes/VoiceListening';
import VoiceNotRecognized from './modes/VoiceNotRecognized';
import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle';
@@ -64,6 +66,17 @@ const OVERLAY_SPOTLIGHT_ID = 'voice-input-overlay-container';
const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box';
const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button';
// 🔧 검색 기록 관리 상수
const SEARCH_HISTORY_KEY = 'voiceSearchHistory';
const MAX_HISTORY_SIZE = 5;
const DEFAULT_SUGGESTIONS = [
'"Can you recommend a good budget cordless vacuum?"',
'"Show me trending skincare."',
'"Find the newest Nike sneakers."',
'"Show me snail cream that helps with sensitive skin."',
'"Recommend a tasty melatonin gummy."',
];
// 🔧 실험적 기능: Wake Word Detection ("Hey Shoptime")
// false로 설정하면 이 기능은 완전히 비활성화됩니다
const ENABLE_WAKE_WORD = false;
@@ -93,6 +106,10 @@ const VoiceInputOverlay = ({
searchQuery = '',
onSearchChange,
}) => {
if (DEBUG_MODE) {
console.log('🔄 [DEBUG] VoiceInputOverlay render - isVisible:', isVisible, 'mode:', mode);
}
const dispatch = useDispatch();
const lastFocusedElement = useRef(null);
const listeningTimerRef = useRef(null);
@@ -122,9 +139,35 @@ const VoiceInputOverlay = ({
const [voiceInputMode, setVoiceInputMode] = useState(null);
// STT 응답 텍스트 저장
const [sttResponseText, setSttResponseText] = useState('');
// 검색 기록 관리 (localStorage 기반, 최근 5개)
const [searchHistory, setSearchHistory] = useState(() => {
const history = readLocalStorage(SEARCH_HISTORY_KEY, DEFAULT_SUGGESTIONS);
if (DEBUG_MODE) {
console.log('📚 [DEBUG] Loaded searchHistory from localStorage:', history);
}
return history;
});
// Voice Version (어떤 음성 시스템을 사용할지 결정)
const voiceVersion = VOICE_VERSION.WEB_SPEECH;
// ⭐ isVisible prop 변경 추적
useEffect(() => {
console.log('👁️ [DEBUG][VoiceInputOverlay] isVisible prop changed to:', isVisible);
if (isVisible) {
console.log('📺 [DEBUG][VoiceInputOverlay] Overlay is now VISIBLE, mode:', currentMode);
} else {
console.log('🙈 [DEBUG][VoiceInputOverlay] Overlay is now HIDDEN');
}
}, [isVisible, currentMode]);
// ⭐ currentMode 변경 추적
useEffect(() => {
console.log('🔀 [DEBUG][VoiceInputOverlay] currentMode changed to:', currentMode);
if (isVisible) {
console.log(`📍 [DEBUG][VoiceInputOverlay] Current state: isVisible=true, mode=${currentMode}`);
}
}, [currentMode, isVisible]);
// 🔊 Beep 소리 재생 함수 - zero dependencies
const playBeep = useCallback(() => {
if (!ENABLE_BEEP_SOUND) return;
@@ -200,24 +243,71 @@ const VoiceInputOverlay = ({
}
}, []);
// Web Speech API Hook (WebSpeech 모드일 때만 활성화) - zero dependencies
const handleWebSpeechSTT = useCallback((sttText) => {
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] WebSpeech STT text received:', sttText);
}
// 🔍 검색 기록 저장 함수 (성능 최적화: stable reference)
const addToSearchHistory = useCallback((searchText) => {
if (!searchText || searchText.trim().length < 3) return;
// 타이머 중지
clearTimerRef(listeningTimerRef);
const trimmedText = searchText.trim();
// STT 텍스트 저장
setSttResponseText(sttText);
setSearchHistory((prevHistory) => {
// 중복 제거 (대소문자 구분 없이)
const filtered = prevHistory.filter(
(item) => item.toLowerCase() !== trimmedText.toLowerCase()
);
// RESPONSE 모드로 전환
setCurrentMode(VOICE_MODES.RESPONSE);
if (DEBUG_MODE) {
console.log('📺 [VoiceInputOverlay.v2] Switching to RESPONSE mode with text:', sttText);
}
}, []);
// 최신 항목을 맨 앞에 추가
const newHistory = [trimmedText, ...filtered].slice(0, MAX_HISTORY_SIZE);
// localStorage에 저장
writeLocalStorage(SEARCH_HISTORY_KEY, newHistory);
if (DEBUG_MODE) {
console.log('💾 [VoiceInputOverlay.v2] Search history updated:', newHistory);
}
return newHistory;
});
}, []); // dependency 없음 (setSearchHistory는 stable)
// Web Speech API Hook (WebSpeech 모드일 때만 활성화)
const handleWebSpeechSTT = useCallback(
(sttText) => {
if (DEBUG_MODE) {
console.log('🎤 [DEBUG] handleWebSpeechSTT called with:', sttText);
}
// 타이머 중지
clearTimerRef(listeningTimerRef);
// STT 텍스트 저장
setSttResponseText(sttText);
// RESPONSE 모드로 전환
setCurrentMode(VOICE_MODES.RESPONSE);
if (DEBUG_MODE) {
console.log('📺 [DEBUG] Switching to RESPONSE mode with text:', sttText);
}
// ✅ TInput에 텍스트 표시
if (onSearchChange) {
onSearchChange({ value: sttText });
}
// ✨ 검색 기록에 추가
addToSearchHistory(sttText);
// ✨ ShopperHouse API 자동 호출
if (sttText && sttText.trim().length >= 3) {
if (DEBUG_MODE) {
console.log('🔍 [DEBUG] Calling ShopperHouse API from STT with query:', sttText.trim());
console.log('🔍 [DEBUG] Query length:', sttText.trim().length);
console.log('🔍 [DEBUG] Query contains apostrophe:', sttText.includes("'"));
}
dispatch(getShopperHouseSearch(sttText.trim()));
}
},
[dispatch, addToSearchHistory, onSearchChange]
);
const { isListening, interimText, startListening, stopListening, isSupported } = useWebSpeech(
isVisible, // Overlay가 열려있을 때만 활성화 (voiceInputMode와 무관하게 초기화)
@@ -236,13 +326,47 @@ const VoiceInputOverlay = ({
// Redux에서 shopperHouse 검색 결과 가져오기 (simplified ref usage)
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
const shopperHouseDataRef = useRef(null);
const isInitializingRef = useRef(false); // overlay 초기화 중 플래그
// 🔍 DEBUG: shopperHouseData 변경 추적
useEffect(() => {
if (DEBUG_MODE) {
console.log('🔍 [DEBUG] shopperHouseData changed:', {
shopperHouseData,
refValue: shopperHouseDataRef.current,
isVisible,
areEqual: shopperHouseData === shopperHouseDataRef.current,
});
}
}, [shopperHouseData, isVisible]);
// ShopperHouse API 응답 수신 시 overlay 닫기
useEffect(() => {
if (DEBUG_MODE) {
console.log('🔍 [DEBUG] shopperHouseData useEffect running:', {
isVisible,
hasData: !!shopperHouseData,
refValue: shopperHouseDataRef.current,
dataEqualsRef: shopperHouseData === shopperHouseDataRef.current,
isInitializing: isInitializingRef.current,
});
}
// ✨ 초기화 중에는 close 로직 실행 안 함 (중복 닫기 방지)
if (isInitializingRef.current) {
if (DEBUG_MODE) {
console.log('🔍 [DEBUG] Skipping close check - overlay is initializing');
}
return;
}
// 이전 값과 비교하여 새로운 데이터가 들어왔을 때만 닫기
if (isVisible && shopperHouseData && shopperHouseData !== shopperHouseDataRef.current) {
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] ShopperHouse data received, closing overlay');
console.log('[VoiceInputOverlay.v2] ShopperHouse data received, closing overlay', {
oldRef: shopperHouseDataRef.current,
newData: shopperHouseData,
});
}
shopperHouseDataRef.current = shopperHouseData;
@@ -393,19 +517,58 @@ const VoiceInputOverlay = ({
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
useEffect(() => {
if (DEBUG_MODE) {
console.log('👁️ [DEBUG] isVisible useEffect triggered - isVisible:', isVisible);
}
if (isVisible) {
if (DEBUG_MODE) {
console.log('✅ [DEBUG] ===== OVERLAY OPENING =====');
console.log('🔍 [DEBUG] Clearing Redux shopperHouseData');
}
// ✨ Redux shopperHouseData 초기화 (이전 검색 결과 제거)
dispatch(clearShopperHouseData());
// ✨ Redux lastSTTText 초기화 (이전 음성 인식 텍스트 제거)
dispatch(clearSTTText());
// ✨ 초기화 시작 플래그 설정 (close 로직 일시 차단)
isInitializingRef.current = true;
if (DEBUG_MODE) {
console.log('🚧 [DEBUG] isInitializingRef set to TRUE - close logic DISABLED');
}
// 현재 포커스된 요소 저장
lastFocusedElement.current = Spotlight.getCurrent();
// ✨ shopperHouseDataRef를 null로 초기화
shopperHouseDataRef.current = null;
if (DEBUG_MODE) {
console.log('🔍 [DEBUG] shopperHouseDataRef initialized to NULL');
}
// 모드 초기화 (항상 prompt 모드로 시작)
if (DEBUG_MODE) {
console.log('🔀 [DEBUG] Setting currentMode to:', mode);
}
setCurrentMode(mode);
setVoiceInputMode(null);
// 마이크 버튼으로 포커스 이동
focusTimerRef.current = setTimeout(() => {
Spotlight.focus(MIC_SPOTLIGHT_ID);
// ✨ 초기화 완료 - close 로직 활성화
isInitializingRef.current = false;
if (DEBUG_MODE) {
console.log('✅ [DEBUG] Initialization complete, close logic ENABLED');
}
}, 100);
} else {
if (DEBUG_MODE) {
console.log('❌ [DEBUG] ===== OVERLAY CLOSING =====');
}
// Overlay가 닫힐 때 원래 포커스 복원 및 상태 초기화
// 타이머 정리
@@ -433,7 +596,7 @@ const VoiceInputOverlay = ({
clearTimerRef(focusTimerRef);
clearTimerRef(focusRestoreTimerRef);
};
}, [isVisible, mode]);
}, [isVisible, mode, dispatch]);
// Cleanup all timers and AudioContext on component unmount
useEffect(() => {
@@ -458,20 +621,33 @@ const VoiceInputOverlay = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출
const handleSuggestionClick = useCallback(
(suggestion) => {
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Suggestion clicked:', suggestion);
console.log('💡 [DEBUG] handleSuggestionClick called with:', suggestion);
}
// 따옴표 제거
const query = suggestion.replace(/^["']|["']$/g, '').trim();
// Input 창에 텍스트 설정
// Input 창에 텍스트 설정 (유지)
if (onSearchChange) {
onSearchChange({ value: query });
}
// ✨ 검색 기록에 추가
addToSearchHistory(query);
// ✨ ShopperHouse API 자동 호출
if (query && query.length >= 3) {
if (DEBUG_MODE) {
console.log('🔍 [DEBUG] Calling ShopperHouse API from bubble click with query:', query);
}
dispatch(getShopperHouseSearch(query));
}
},
[onSearchChange]
[onSearchChange, dispatch, addToSearchHistory]
);
// Input 창에서 API 호출 핸들러 (돋보기 아이콘 클릭 시에만)
@@ -512,79 +688,58 @@ const VoiceInputOverlay = ({
}
}, []);
// TALK AGAIN 버튼 핸들러
const handleTalkAgain = useCallback(() => {
// Input 창 포커스 핸들러 - VoiceInputOverlay 닫고 SearchPanel 화면 보이기
const handleInputFocus = useCallback(() => {
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] TALK AGAIN - Restarting LISTENING mode');
console.log('⌨️ [DEBUG] handleInputFocus called - closing overlay to show SearchPanel');
}
// VoiceInputOverlay 닫기 - SearchPanel이 보이게 됨
handleClose();
}, [handleClose]);
// 🔊 Beep 소리 재생
playBeep();
// 기존 타이머 정리
clearTimerRef(listeningTimerRef);
// STT 텍스트 초기화
setSttResponseText('');
// LISTENING 모드로 전환
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
// WebSpeech API 시작
startListening();
// 15초 타이머 설정
listeningTimerRef.current = setTimeout(() => {
if (DEBUG_MODE) {
console.log('⏰ [VoiceInputOverlay.v2] 15초 타임아웃 - PROMPT 모드로 복귀');
}
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
stopListening();
}, 15000);
}, [playBeep, startListening, stopListening]);
// ⛔ TALK AGAIN 버튼 제거됨 - 더 이상 사용하지 않음
// const handleTalkAgain = useCallback(() => { ... }, []);
// 모드에 따른 컨텐츠 렌더링 - Memoized
const renderModeContent = useMemo(() => {
if (DEBUG_MODE) {
console.log(
'📺 [VoiceInputOverlay.v2] renderModeContent - currentMode:',
currentMode,
'voiceInputMode:',
voiceInputMode,
'isListening:',
isListening
'🎬 [DEBUG][VoiceInputOverlay] renderModeContent called',
'| currentMode:', currentMode,
'| voiceInputMode:', voiceInputMode,
'| isListening:', isListening
);
}
switch (currentMode) {
case VOICE_MODES.PROMPT:
if (DEBUG_MODE) {
console.log('📺 Rendering: VoicePromptScreen');
console.log('✅ [DEBUG][VoiceInputOverlay] MODE = PROMPT | Rendering VoicePromptScreen with', searchHistory.length, 'suggestions');
}
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
<VoicePromptScreen
suggestions={searchHistory}
onSuggestionClick={handleSuggestionClick}
/>
);
case VOICE_MODES.LISTENING:
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceListening (15초 타이머 기반)');
console.log('🎤 [DEBUG][VoiceInputOverlay] MODE = LISTENING | Rendering VoiceListening (15초 타이머)');
}
return <VoiceListening interimText={interimText} />;
case VOICE_MODES.RESPONSE:
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceResponse with text:', sttResponseText);
console.log('💬 [DEBUG][VoiceInputOverlay] MODE = RESPONSE | Rendering VoiceResponse with text:', sttResponseText);
}
return <VoiceResponse responseText={sttResponseText} onTalkAgain={handleTalkAgain} />;
return <VoiceResponse responseText={sttResponseText} />;
case VOICE_MODES.NOINIT:
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceNotRecognized (NOINIT mode)');
console.log('⚠️ [DEBUG][VoiceInputOverlay] MODE = NOINIT | Rendering VoiceNotRecognized');
}
return <VoiceNotRecognized prompt={NOINIT_ERROR_MESSAGE} />;
case VOICE_MODES.NOTRECOGNIZED:
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceNotRecognized (NOTRECOGNIZED mode)');
console.log('❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized');
}
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_3:
@@ -595,21 +750,23 @@ const VoiceInputOverlay = ({
return <VoiceNotRecognizedCircle />;
default:
if (DEBUG_MODE) {
console.log('📺 Rendering: VoicePromptScreen (default)');
console.log('🔄 [DEBUG][VoiceInputOverlay] MODE = DEFAULT | Rendering VoicePromptScreen');
}
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
<VoicePromptScreen
suggestions={searchHistory}
onSuggestionClick={handleSuggestionClick}
/>
);
}
}, [
currentMode,
voiceInputMode,
isListening,
suggestions,
searchHistory,
handleSuggestionClick,
interimText,
sttResponseText,
handleTalkAgain,
]);
// 마이크 버튼 포커스 핸들러 (VUI)
@@ -624,7 +781,7 @@ const VoiceInputOverlay = ({
// Overlay 닫기 핸들러 (모든 닫기 동작을 통합)
const handleClose = useCallback(() => {
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Closing overlay');
console.log('🚪 [DEBUG] handleClose called - closing overlay');
}
clearTimerRef(listeningTimerRef);
setVoiceInputMode(null);
@@ -638,7 +795,7 @@ const VoiceInputOverlay = ({
(e) => {
if (DEBUG_MODE) {
console.log(
'🎤 [VoiceInputOverlay.v2] handleWebSpeechMicClick called, currentMode:',
'🎤 [DEBUG] handleWebSpeechMicClick called, currentMode:',
currentMode
);
}
@@ -651,12 +808,17 @@ const VoiceInputOverlay = ({
e.nativeEvent.stopImmediatePropagation();
}
if (currentMode === VOICE_MODES.PROMPT || currentMode === VOICE_MODES.RESPONSE) {
// prompt 모드 또는 response 모드에서 클릭 시:
if (currentMode === VOICE_MODES.PROMPT) {
// ✨ PROMPT 모드에서만 LISTENING으로 전환 가능
// 1. listening 모드로 전환 (15초 타이머)
// 2. WebSpeech API 시작 (독립 동작)
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] Starting LISTENING mode (15s) + WebSpeech API');
console.log('🎤 [DEBUG] Starting LISTENING mode (15s) + WebSpeech API');
}
// ✅ TInput 초기화 (새로운 음성 입력 시작)
if (onSearchChange) {
onSearchChange({ value: '' });
}
// 🔊 Beep 소리 재생
@@ -665,11 +827,6 @@ const VoiceInputOverlay = ({
// 기존 타이머 정리
clearTimerRef(listeningTimerRef);
// STT 텍스트 초기화 (RESPONSE 모드에서 올 경우)
if (currentMode === VOICE_MODES.RESPONSE) {
setSttResponseText('');
}
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
@@ -690,12 +847,12 @@ const VoiceInputOverlay = ({
} else {
// listening 모드 또는 기타 모드에서 클릭 시 -> overlay 닫기
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] Closing overlay');
console.log('🎤 [DEBUG] Closing overlay (not in PROMPT mode)');
}
handleClose();
}
},
[currentMode, handleClose, playBeep, startListening, stopListening]
[currentMode, handleClose, playBeep, startListening, stopListening, onSearchChange]
);
// 마이크 버튼 키다운 핸들러
@@ -826,6 +983,7 @@ const VoiceInputOverlay = ({
onKeyDown={handleInputKeyDown}
onIconClick={handleSearchSubmit}
spotlightId={INPUT_SPOTLIGHT_ID}
onFocus={handleInputFocus}
/>
{/* voiceVersion에 따라 하나의 마이크만 표시 */}

View File

@@ -17,7 +17,7 @@ const PromptContainer = SpotlightContainerDecorator(
const VoicePromptScreen = ({ title = 'Try saying', suggestions = [], onSuggestionClick }) => {
const handleBubbleClick = (suggestion) => {
console.log(`[VoicePromptScreen] Bubble clicked: ${suggestion}`);
console.log('💡 [DEBUG][VoicePromptScreen] Bubble clicked:', suggestion);
// 부모 컴포넌트로 suggestion 텍스트 전달 (API 호출은 부모에서 처리)
if (onSuggestionClick) {

View File

@@ -6,7 +6,6 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco
import css from './VoiceResponse.module.less';
const SpottableBubble = Spottable('div');
const SpottableButton = Spottable('button');
const ResponseContainer = SpotlightContainerDecorator(
{
@@ -39,28 +38,9 @@ const VoiceResponse = ({ responseText = '', onTalkAgain }) => {
const displayText = capitalizeSentences(responseText);
const handleTalkAgainClick = () => {
console.log('[VoiceResponse] TALK AGAIN clicked');
if (onTalkAgain) {
onTalkAgain();
}
};
const handleButtonClick = () => {
handleTalkAgainClick();
};
return (
<ResponseContainer className={css.container} spotlightId="voice-response-container">
<div className={css.responseContainer}>
<SpottableButton
className={css.talkAgainButton}
onClick={handleButtonClick}
spotlightId="voice-talk-again-button"
>
TALK AGAIN
</SpottableButton>
<SpottableBubble className={css.bubbleMessage} spotlightId="voice-response-text">
<div className={css.bubbleText}>{displayText}</div>
</SpottableBubble>