[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:
41
com.twin.app.shoptime/shopperHouseResponse.txt
Normal file
41
com.twin.app.shoptime/shopperHouseResponse.txt
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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에 따라 하나의 마이크만 표시 */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user