[251022] feat: VoiceDebugDashboard

🕐 커밋 시간: 2025. 10. 22. 15:14:24

📊 변경 통계:
  • 총 파일: 3개
  • 추가: +440줄
  • 삭제: -1줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceDebugDashboard.jsx

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

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
    🔄 Modified: clearAllTimers()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less (unknown):
     Added: scale(), rotate(), media(), translateY(), repeat()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceDebugDashboard.jsx (javascript):
     Added: getEventIcon()
This commit is contained in:
2025-10-22 15:14:25 +09:00
parent b74f7abf83
commit 8a2704265f
3 changed files with 644 additions and 1 deletions

View File

@@ -0,0 +1,204 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceDebugDashboard.jsx
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import css from './VoiceInputOverlay.module.less';
/**
* VoiceDebugDashboard - 음성 인식 전체 디버깅 대시보드
* 풀화면 오버레이로 표시되며, 다음 정보를 제공:
* 1. 현재 상태 (Mode, isListening, Countdown, SearchId)
* 2. 이벤트 타임라인 (최근 20개 이벤트)
* 3. API 요청 정보
* 4. 에러 정보 (있는 경우)
*/
const VoiceDebugDashboard = ({
isVisible,
onClose,
currentMode,
isListening,
countdown,
shopperHouseSearchId,
webSpeechEventLogs,
lastSTTText,
sttResponseText,
interimText,
errorMessage,
voiceInputMode,
isBubbleClickSearch,
}) => {
// 이벤트 로그를 시간순으로 정렬 (최신이 위)
// Note: Hooks must be called at the top level, before any early returns
const sortedLogs = useMemo(() => {
return [...webSpeechEventLogs].reverse();
}, [webSpeechEventLogs]);
if (!isVisible) return null;
// 이벤트 타입별 아이콘
const getEventIcon = (event) => {
const icons = {
INIT: '🔧',
START: '▶️',
RESULT: '🎤',
RESULT_FINAL: '✅',
ERROR: '❌',
END: '⏹️',
STOP: '🛑',
ABORT: '💥',
RESTART: '🔄',
TIMEOUT_15S: '⏰',
SILENCE_3S: '🔇',
MODE_CHANGE: '🔀',
API_REQUEST: '📤',
API_RESPONSE: '📥',
BUBBLE_CLICK: '💡',
OVERLAY_OPEN: '👁️',
OVERLAY_CLOSE: '🙈',
};
return icons[event] || '•';
};
return (
<div className={css.voiceDebugDashboardOverlay} onClick={onClose}>
<div className={css.voiceDebugDashboard} onClick={(e) => e.stopPropagation()}>
{/* 헤더 */}
<div className={css.dashboardHeader}>
<div className={css.dashboardTitle}>🎯 Voice Input Debug Dashboard</div>
<button className={css.dashboardCloseBtn} onClick={onClose}>
</button>
</div>
{/* 메인 컨텐츠 */}
<div className={css.dashboardContent}>
{/* 1⃣ 현재 상태 섹션 */}
<section className={css.dashboardSection}>
<h3 className={css.sectionTitle}>📍 Current State</h3>
<div className={css.stateGrid}>
<div className={css.stateItem}>
<span className={css.stateLabel}>Mode:</span>
<span className={css.stateValue}>{currentMode}</span>
</div>
<div className={css.stateItem}>
<span className={css.stateLabel}>isListening:</span>
<span className={`${css.stateValue} ${isListening ? css.active : ''}`}>
{isListening ? '🎤 YES' : '❌ NO'}
</span>
</div>
<div className={css.stateItem}>
<span className={css.stateLabel}>VoiceInputMode:</span>
<span className={css.stateValue}>{voiceInputMode || 'None'}</span>
</div>
<div className={css.stateItem}>
<span className={css.stateLabel}>Countdown:</span>
<span className={css.stateValue}>{countdown}s</span>
</div>
<div className={css.stateItem}>
<span className={css.stateLabel}>SearchId:</span>
<span className={css.stateValue} title={shopperHouseSearchId || 'N/A'}>
{shopperHouseSearchId
? shopperHouseSearchId.substring(0, 12) + '...'
: 'None (첫 번째 발화)'}
</span>
</div>
<div className={css.stateItem}>
<span className={css.stateLabel}>BubbleSearch:</span>
<span className={css.stateValue}>{isBubbleClickSearch ? '✅ YES' : '❌ NO'}</span>
</div>
</div>
</section>
{/* 2⃣ Interim/Final Text 섹션 */}
<section className={css.dashboardSection}>
<h3 className={css.sectionTitle}>📝 Text Input</h3>
<div className={css.textBlock}>
<div className={css.textItem}>
<span className={css.textLabel}>Interim Text:</span>
<div className={css.textContent}>{interimText || '(empty)'}</div>
</div>
<div className={css.textItem}>
<span className={css.textLabel}>STT Response Text:</span>
<div className={css.textContent}>{sttResponseText || '(empty)'}</div>
</div>
<div className={css.textItem}>
<span className={css.textLabel}>Last STT Text (Redux):</span>
<div className={css.textContent}>{lastSTTText || '(empty)'}</div>
</div>
</div>
</section>
{/* 3⃣ 에러 정보 */}
{errorMessage && (
<section className={css.dashboardSection}>
<h3 className={css.sectionTitle}>🔴 Error Message</h3>
<div className={css.errorBlock}>{errorMessage}</div>
</section>
)}
{/* 4⃣ 이벤트 타임라인 섹션 */}
<section className={css.dashboardSection}>
<h3 className={css.sectionTitle}>📋 Event Timeline ({sortedLogs.length} events)</h3>
<div className={css.timelineContainer}>
{sortedLogs.length === 0 ? (
<div className={css.noEvents}>No events yet...</div>
) : (
<div className={css.timelineList}>
{sortedLogs.map((log, idx) => (
<div key={log.id} className={`${css.timelineItem} ${css[log.type]}`}>
<div className={css.timelineIndex}>{sortedLogs.length - idx}</div>
<div className={css.timelineIcon}>{getEventIcon(log.event)}</div>
<div className={css.timelineContent}>
<div className={css.timelineEvent}>
<span className={css.eventName}>{log.event}</span>
<span className={css.eventTime}>[{log.timestamp}]</span>
</div>
{log.details && <div className={css.timelineDetails}>{log.details}</div>}
</div>
</div>
))}
</div>
)}
</div>
</section>
</div>
</div>
</div>
);
};
VoiceDebugDashboard.propTypes = {
isVisible: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
currentMode: PropTypes.string.isRequired,
isListening: PropTypes.bool.isRequired,
countdown: PropTypes.number.isRequired,
shopperHouseSearchId: PropTypes.string,
webSpeechEventLogs: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
timestamp: PropTypes.string,
event: PropTypes.string,
details: PropTypes.string,
type: PropTypes.string,
})
),
lastSTTText: PropTypes.string,
sttResponseText: PropTypes.string,
interimText: PropTypes.string,
errorMessage: PropTypes.string,
voiceInputMode: PropTypes.string,
isBubbleClickSearch: PropTypes.bool,
};
VoiceDebugDashboard.defaultProps = {
shopperHouseSearchId: null,
webSpeechEventLogs: [],
lastSTTText: '',
sttResponseText: '',
interimText: '',
errorMessage: '',
voiceInputMode: null,
isBubbleClickSearch: false,
};
export default VoiceDebugDashboard;

View File

@@ -23,6 +23,7 @@ import VoicePromptScreen from './modes/VoicePromptScreen';
import VoiceResponse from './modes/VoiceResponse';
import VoiceApiError from './modes/VoiceApiError';
import WebSpeechEventDebug from './WebSpeechEventDebug';
import VoiceDebugDashboard from './VoiceDebugDashboard';
import css from './VoiceInputOverlay.module.less';
const OverlayContainer = SpotlightContainerDecorator(
@@ -165,6 +166,8 @@ const VoiceInputOverlay = ({
const [webSpeechEventLogs, setWebSpeechEventLogs] = useState([]);
// Bubble 클릭으로 검색이 시작되었는지 추적하는 상태
const [isBubbleClickSearch, setIsBubbleClickSearch] = useState(false);
// 디버그 대시보드 표시 여부
const [showDashboard, setShowDashboard] = useState(false);
// 검색 기록 관리 (localStorage 기반, 최근 5개)
const [searchHistory, setSearchHistory] = useState(() => {
const history = readLocalStorage(SEARCH_HISTORY_KEY, DEFAULT_SUGGESTIONS);
@@ -193,8 +196,10 @@ const VoiceInputOverlay = ({
console.log(
`📍 [DEBUG][VoiceInputOverlay] Current state: isVisible=true, mode=${currentMode}`
);
// 모드 변경 이벤트 로깅
addWebSpeechEventLog('MODE_CHANGE', `Mode switched to: ${currentMode}`);
}
}, [currentMode, isVisible]);
}, [currentMode, isVisible, addWebSpeechEventLog]);
// 🔍 검색 기록 저장 함수 (성능 최적화: stable reference)
const addToSearchHistory = useCallback((searchText) => {
@@ -667,6 +672,7 @@ const VoiceInputOverlay = ({
clearTimerRef(silenceDetectionTimerRef);
silenceDetectionTimerRef.current = setTimeout(() => {
console.log('[VoiceInput] 🔇 3초 동안 입력 없음 - 자동 종료');
addWebSpeechEventLog('SILENCE_3S', 'No input detected for 3 seconds - auto finish');
processFinalVoiceInput('3초 silence detection');
}, 3000); // 3초
@@ -977,6 +983,12 @@ const VoiceInputOverlay = ({
console.log('[VoiceInput] ├─ currentSearchId:', currentSearchId);
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
// API 호출 이벤트 로깅
const searchIdInfo = currentSearchId
? `(2nd query, searchId: ${currentSearchId.substring(0, 8)}...)`
: '(1st query, no searchId)';
addWebSpeechEventLog('API_REQUEST', `Query: "${query}" ${searchIdInfo}`);
dispatch(getShopperHouseSearch(query, currentSearchId));
} else {
// 입력이 없거나 너무 짧으면 PROMPT 모드로 복귀
@@ -1058,6 +1070,13 @@ const VoiceInputOverlay = ({
console.log('[VoiceInput] ├─ query:', query);
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
}
// API 호출 이벤트 로깅 (Bubble 클릭)
const searchIdInfo = currentSearchId
? `(2nd query from bubble, searchId: ${currentSearchId.substring(0, 8)}...)`
: '(1st query from bubble, no searchId)';
addWebSpeechEventLog('BUBBLE_CLICK', `Query: "${query}" ${searchIdInfo}`);
try {
dispatch(getShopperHouseSearch(query, currentSearchId));
} catch (error) {
@@ -1129,6 +1148,14 @@ const VoiceInputOverlay = ({
[handleClose]
);
// 디버그 대시보드 토글 핸들러
const handleToggleDashboard = useCallback(() => {
setShowDashboard((prev) => !prev);
if (DEBUG_MODE) {
console.log('🔧 [DEBUG] Dashboard toggled:', !showDashboard);
}
}, [showDashboard]);
// ⛔ TALK AGAIN 버튼 제거됨 - 더 이상 사용하지 않음
// const handleTalkAgain = useCallback(() => { ... }, []);
@@ -1310,6 +1337,7 @@ const VoiceInputOverlay = ({
// 15초 타이머 설정: 최대 입력 시간 제한
listeningTimerRef.current = setTimeout(() => {
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
processFinalVoiceInput('15초 타임아웃');
}, 15000); // 15초
} else {
@@ -1393,6 +1421,19 @@ const VoiceInputOverlay = ({
handleMicBlur,
]);
// Memoize debug toggle button
const debugToggleButton = useMemo(() => {
return (
<button
className={classNames(css.debugToggleButton, showDashboard && css.active)}
onClick={handleToggleDashboard}
title={showDashboard ? 'Close Debug Dashboard' : 'Open Debug Dashboard'}
>
<span className={css.debugToggleIcon}></span>
</button>
);
}, [showDashboard, handleToggleDashboard]);
// Memoize debug UI (always render for TV debugging)
const debugUI = useMemo(() => {
return (
@@ -1557,6 +1598,9 @@ const VoiceInputOverlay = ({
{/* voiceVersion에 따라 하나의 마이크만 표시 */}
{microphoneButton}
{/* 디버그 토글 버튼 (DEBUG_MODE에서만 표시) */}
{DEBUG_MODE && debugToggleButton}
{/* VUI_DISABLE_START - VUI 마이크 버튼 비활성화 */}
{/* {voiceVersion === VOICE_VERSION.VUI && (
<SpottableMicButton
@@ -1609,6 +1653,23 @@ const VoiceInputOverlay = ({
{/* WebSpeech 이벤트 전용 디버그 - 모드와 상관없이 항상 우측 하단에 표시 */}
<WebSpeechEventDebug logs={webSpeechEventLogs} />
{/* 풀 대시보드 - 토글 버튼으로 열기/닫기 */}
<VoiceDebugDashboard
isVisible={showDashboard}
onClose={() => setShowDashboard(false)}
currentMode={currentMode}
isListening={isListening}
countdown={countdown}
shopperHouseSearchId={shopperHouseSearchId}
webSpeechEventLogs={webSpeechEventLogs}
lastSTTText={lastSTTText}
sttResponseText={sttResponseText}
interimText={interimText}
errorMessage={errorMessage}
voiceInputMode={voiceInputMode}
isBubbleClickSearch={isBubbleClickSearch}
/>
</div>
</TFullPopup>
);

View File

@@ -425,3 +425,381 @@
background: rgba(255, 255, 255, 0.4);
}
}
// ============================================================
// 디버그 토글 버튼 (마이크 버튼 옆)
// ============================================================
.debugToggleButton {
width: 100px;
height: 100px;
padding: 0;
border: none;
background: rgba(255, 255, 255, 0.9);
border-radius: 1000px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
border: 5px solid #ccc;
transition: all 0.3s ease;
z-index: 1003;
position: relative;
font-size: 48px;
&:hover {
background: white;
border-color: #999;
transform: scale(1.05);
}
&.active {
background: #FFB81C; // 활성화 상태 (노란색)
border-color: #FFB81C;
box-shadow: 0 0 22px 0 rgba(255, 184, 28, 0.5);
.debugToggleIcon {
animation: spin 0.6s linear;
}
}
.debugToggleIcon {
display: flex;
justify-content: center;
align-items: center;
line-height: 1;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// ============================================================
// 풀 대시보드 - 오버레이
// ============================================================
.voiceDebugDashboardOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000; // WebSpeechEventDebug (10006)보다 낮음
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
// ============================================================
// 풀 대시보드 - 메인 컨테이너
// ============================================================
.voiceDebugDashboard {
position: relative;
width: 90%;
max-width: 1400px;
height: 85%;
background: rgba(0, 0, 0, 0.95);
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
cursor: auto;
pointer-events: auto;
font-family: "LG Smart UI", monospace;
color: white;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.8);
animation: slideUp 0.3s ease;
@media (max-width: 1920px) {
width: 95%;
height: 90%;
}
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// 대시보드 헤더
.dashboardHeader {
padding: 24px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
min-height: 70px;
}
.dashboardTitle {
font-size: 24px;
font-weight: bold;
color: #FFB81C; // Shoptime 브랜드 색상
letter-spacing: 0.5px;
}
.dashboardCloseBtn {
width: 50px;
height: 50px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
color: white;
font-size: 28px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
// 대시보드 콘텐츠 (스크롤 가능)
.dashboardContent {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
// 대시보드 섹션
.dashboardSection {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 20px;
flex-shrink: 0;
h3 {
margin: 0 0 16px 0;
font-size: 20px;
font-weight: bold;
color: #4CAF50;
border-bottom: 2px solid rgba(76, 175, 80, 0.3);
padding-bottom: 12px;
}
}
// 상태 그리드 (현재 상태 섹션)
.stateGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
font-size: 16px;
@media (max-width: 1400px) {
grid-template-columns: 1fr;
}
}
.stateItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
border-left: 3px solid rgba(255, 255, 255, 0.1);
.stateLabel {
color: rgba(255, 255, 255, 0.7);
font-weight: 600;
min-width: 120px;
}
.stateValue {
color: #2196F3;
font-weight: 500;
word-break: break-all;
max-width: 60%;
&.active {
color: #4CAF50;
}
}
}
// 텍스트 블록 (Text Input 섹션)
.textBlock {
display: flex;
flex-direction: column;
gap: 12px;
}
.textItem {
display: flex;
flex-direction: column;
gap: 8px;
.textLabel {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.textContent {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 12px;
color: #4CAF50;
font-family: monospace;
font-size: 14px;
min-height: 40px;
max-height: 100px;
overflow-y: auto;
word-break: break-word;
line-height: 1.4;
}
}
// 에러 블록
.errorBlock {
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-left: 4px solid #F44336;
padding: 12px;
border-radius: 4px;
color: #FF6B6B;
font-size: 15px;
line-height: 1.5;
}
// 타임라인 컨테이너
.timelineContainer {
max-height: 500px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
background: rgba(0, 0, 0, 0.3);
}
.timelineList {
display: flex;
flex-direction: column;
gap: 0;
}
.timelineItem {
display: flex;
gap: 12px;
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
align-items: flex-start;
font-size: 14px;
transition: background 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: rgba(255, 255, 255, 0.03);
}
&.success { border-left: 3px solid #4CAF50; }
&.error { border-left: 3px solid #F44336; }
&.warning { border-left: 3px solid #FF9800; }
&.info { border-left: 3px solid #2196F3; }
}
.timelineIndex {
min-width: 30px;
text-align: right;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
font-family: monospace;
}
.timelineIcon {
min-width: 24px;
font-size: 18px;
text-align: center;
}
.timelineContent {
flex: 1;
}
.timelineEvent {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 4px;
.eventName {
font-weight: 600;
color: white;
min-width: 120px;
}
.eventTime {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
font-family: monospace;
}
}
.timelineDetails {
color: rgba(255, 255, 255, 0.7);
margin-left: 0;
word-break: break-word;
padding: 4px 0;
font-family: monospace;
font-size: 13px;
}
.noEvents {
text-align: center;
padding: 40px;
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
// 스크롤바 스타일링 (대시보드용)
.dashboardContent::-webkit-scrollbar,
.timelineContainer::-webkit-scrollbar {
width: 8px;
}
.dashboardContent::-webkit-scrollbar-track,
.timelineContainer::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.dashboardContent::-webkit-scrollbar-thumb,
.timelineContainer::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}