🕐 커밋 시간: 2025. 10. 16. 10:26:16 📊 변경 통계: • 총 파일: 7개 • 추가: +65줄 • 삭제: -36줄 📁 추가된 파일: + com.twin.app.shoptime/vui-implement.md + com.twin.app.shoptime/vui-react.md 📝 수정된 파일: ~ com.twin.app.shoptime/src/actions/actionTypes.js ~ com.twin.app.shoptime/src/actions/searchActions.js ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/actions/searchActions.js (javascript): 🔄 Modified: updateSearchTimestamp() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less (unknown): ✅ Added: scaleY() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx (javascript): ✅ Added: VoicePromptScreen() ❌ Deleted: SpotlightContainerDecorator() 📄 com.twin.app.shoptime/vui-implement.md (md파일): ✅ Added: dispatch(), Date(), useDispatch(), useSelector(), useEffect(), onSTTText(), SearchPanel(), useState(), useCallback(), setSearchQuery(), getSearch(), setIsVoiceOverlayVisible(), useSearchVoice(), setVoiceMode(), handleSearchSubmit(), onSearchChange(), setCurrentMode(), setTimeout(), onClose(), stopPropagation(), Search(), App(), getLaunchParams(), clearLaunchParams(), pushPanel(), Input(), registerVoiceFramework(), performAction(), handleSTTText() 📄 com.twin.app.shoptime/vui-react.md (md파일): ✅ Added: Interface(), Search(), Input(), function(), register(), App(), useDispatch(), useCallback(), getLaunchParams(), clearLaunchParams(), dispatch(), pushPanel(), useEffect(), SearchPanel(), getSearch(), onSuccess(), cancel(), LS2Request(), onCommand(), onFailure(), instance(), deleteInstance(), useRef(), onVoiceInput(), reportActionResult(), registerVoiceConductor(), setVoiceContext(), unregisterVoiceConductor(), setSearchQuery(), useVoiceConductor(), handleSearchSubmit(), setContext() 🔧 주요 변경 내용: • 타입 시스템 안정성 강화 • 핵심 비즈니스 로직 개선 • 개발 문서 및 가이드 개선
26 KiB
26 KiB
[251015] React 프로젝트 VUI 구현 완벽 가이드
📚 목차
- 개요
- 1. VUI 핵심 개념
- 2. voiceTicket과 STT 텍스트 수신 원리
- 3. handleRelaunch 구현
- 4. Luna Service 래퍼 구현
- 5. Custom Hook 구현
- 6. React 컴포넌트 통합
- 7. 전체 구현 체크리스트
- 8. 트러블슈팅
개요
이 문서는 React 기반 webOS TV 앱에서 Voice User Interface (VUI) 기능을 구현하는 완벽한 가이드입니다.
문서 목적
- ✅ voiceTicket의 정확한 역할 이해
- ✅ STT(Speech-to-Text) 텍스트를 받는 방법 명확히 설명
- ✅ handleRelaunch 구현 예시 제공
- ✅ 전체 React 프로젝트 구조에서의 구현 방법 안내
지원 환경
- webOS 버전: 5.0 MR2 이상 (2020년형 TV 이후)
- React 버전: 16.8+ (Hooks 지원)
- Luna Service:
com.webos.service.voiceconductor
1. VUI 핵심 개념
1.1 VUI 기능 분류
webOS VUI는 크게 2가지 유형으로 나뉩니다:
A. Global Voice Search (전역 음성 검색)
- 앱이 백그라운드/종료 상태에서도 동작
- 사용자: "Search for iPhone on Shop Time"
- 시스템이 앱을 실행하고
intent,intentParam전달 - handleRelaunch를 통해 파라미터 수신
B. Foreground Voice Input (포그라운드 음성 입력)
- 앱이 포그라운드일 때만 동작
- 사용자가 🎤 버튼을 누르고 발화
- VoiceConductor Service와 직접 통신
- STT 텍스트를 실시간으로 수신
2. voiceTicket과 STT 텍스트 수신 원리
2.1 voiceTicket이란?
voiceTicket은 Voice Framework와 통신하기 위한 인증 토큰입니다.
[앱] ---register---> [Voice Framework]
<--- voiceTicket 발급 ---
이후 모든 API 호출 시 voiceTicket 필요:
- setContext (Intent 등록)
- reportActionResult (결과 보고)
2.2 STT 텍스트 수신 메커니즘 ⭐
핵심 원리: /interactor/register를 subscribe: true로 호출하면, 음성 명령이 있을 때마다 같은 콜백이 계속 호출됩니다!
// ❌ 잘못된 이해: voiceTicket으로 텍스트를 조회한다?
// ⭐ 올바른 이해: subscribe 모드의 onSuccess가 계속 호출되며 텍스트가 전달된다!
webOS.service.request('luna://com.webos.service.voiceconductor', {
method: '/interactor/register',
parameters: {
type: 'foreground',
subscribe: true // ⚠️ 이것이 핵심!
},
onSuccess: function(response) {
// 최초 1회: voiceTicket 발급
if (response.voiceTicket) {
voiceTicket = response.voiceTicket;
}
// 사용자가 발화할 때마다 이 콜백이 다시 호출됨!
if (response.command === 'performAction') {
// ✅ 여기서 STT 텍스트 수신!
console.log('STT 텍스트:', response.action.value);
console.log('Intent:', response.action.intent);
}
}
});
2.3 전체 흐름도
┌─────────────────────────────────────────────────────────────┐
│ 1. /interactor/register (subscribe: true) │
│ → onSuccess에서 voiceTicket 저장 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. /interactor/setContext │
│ → voiceTicket + UseIME intent 등록 │
│ → Voice Framework에 "음성 입력 받을 준비 됨" 알림 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. 사용자가 리모컨 🎤 버튼 누르고 발화 │
│ 예: "iPhone" │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. /interactor/register의 onSuccess가 다시 호출됨! ✅ │
│ response.command === "performAction" │
│ response.action.intent === "UseIME" │
│ response.action.value === "iPhone" (STT 텍스트!) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 5. /interactor/reportActionResult │
│ → Voice Framework에 처리 완료 보고 │
└─────────────────────────────────────────────────────────────┘
3. handleRelaunch 구현
3.1 Global Voice Search 파라미터 수신
appinfo.json 설정:
{
"id": "com.lgshop.app",
"version": "2.0.0",
"inAppVoiceIntent": {
"contentTarget": {
"intent": "$INTENT",
"intentParam": "$INTENT_PARAM",
"languageCode": "$LANG_CODE"
},
"voiceConfig": {
"supportedIntent": ["SearchContent", "PlayContent"],
"supportedVoiceLanguage": ["en-US", "ko-KR"]
}
}
}
3.2 App.js에서 handleRelaunch 구현
// src/App/App.js
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { pushPanel } from '../actions/panelActions';
import { panel_names } from '../utils/Config';
function App() {
const dispatch = useDispatch();
// ✅ handleRelaunchEvent 구현
const handleRelaunchEvent = useCallback(() => {
console.log("handleRelaunchEvent started");
const launchParams = getLaunchParams();
clearLaunchParams();
// ========================================
// ✅ Voice Intent 처리 (최우선)
// ========================================
if (launchParams?.intent) {
const { intent, intentParam, languageCode } = launchParams;
console.log("[Voice Intent]", { intent, intentParam, languageCode });
// SearchContent 또는 PlayContent intent 처리
if (intent === "SearchContent" || intent === "PlayContent") {
dispatch(
pushPanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
voiceSearch: true, // 음성 검색 플래그
searchVal: intentParam, // 검색어
languageCode: languageCode,
},
})
);
// 로깅 (선택사항)
console.log(`[VUI] Opening SearchPanel with query: ${intentParam}`);
return;
}
}
// ========================================
// 기존 deeplink 처리
// ========================================
if (launchParams?.contentTarget) {
console.log("[DeepLink]", launchParams.contentTarget);
dispatch(handleDeepLink(launchParams.contentTarget));
}
}, [dispatch]);
// ✅ webOSRelaunch 이벤트 리스너 등록
useEffect(() => {
document.addEventListener("webOSRelaunch", handleRelaunchEvent);
// Cleanup
return () => {
document.removeEventListener("webOSRelaunch", handleRelaunchEvent);
};
}, [handleRelaunchEvent]);
return (
<div className="app">
{/* 앱 컴포넌트 */}
</div>
);
}
// ✅ Launch 파라미터 헬퍼 함수
function getLaunchParams() {
// PalmSystem에서 launch params 추출
if (window.PalmSystem) {
const params = JSON.parse(window.PalmSystem.launchParams || '{}');
return params;
}
return null;
}
function clearLaunchParams() {
if (window.PalmSystem) {
window.PalmSystem.launchParams = '{}';
}
}
export default App;
3.3 SearchPanel에서 voiceSearch 플래그 처리
// src/views/SearchPanel/SearchPanel.jsx
import React, { useEffect } from 'react';
function SearchPanel({ panelInfo, isOnTop }) {
// ✅ 음성 검색으로 패널이 열렸을 때 자동 검색
useEffect(() => {
if (panelInfo?.voiceSearch && panelInfo?.searchVal) {
console.log("[SearchPanel] Voice search triggered:", panelInfo.searchVal);
// 자동으로 검색 실행
dispatch(
getSearch({
service: "com.lgshop.app",
query: panelInfo.searchVal,
domain: "theme,show,item",
})
);
}
}, [panelInfo?.voiceSearch, panelInfo?.searchVal]);
return (
<div>
{/* SearchPanel UI */}
</div>
);
}
export default SearchPanel;
4. Luna Service 래퍼 구현
4.1 voiceconductor.js 생성
// src/lunaSend/voiceconductor.js
import LS2Request from "./LS2Request";
/**
* VoiceConductor 서비스: Foreground 앱의 음성 명령 처리
*/
// 현재 활성화된 voiceTicket
let currentVoiceTicket = null;
let voiceHandlerRef = null;
/**
* Voice Framework에 Foreground 앱으로 등록
*
* @param {Function} onCommand - performAction 수신 시 호출되는 콜백
* @param {Function} onSuccess - 등록 성공 시 콜백
* @param {Function} onFailure - 등록 실패 시 콜백
* @returns {Object} LS2Request 인스턴스
*/
export const registerVoiceConductor = ({ onCommand, onSuccess, onFailure }) => {
// ========================================
// Mock 처리 (브라우저 환경)
// ========================================
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK registerVoiceConductor");
const mockTicket = "mock-voice-ticket-" + Date.now();
currentVoiceTicket = mockTicket;
onSuccess && onSuccess({ returnValue: true, voiceTicket: mockTicket });
return null;
}
// ========================================
// 기존 구독 취소
// ========================================
if (voiceHandlerRef) {
voiceHandlerRef.cancel();
voiceHandlerRef = null;
}
// ========================================
// Voice Framework 등록 (subscribe: true!)
// ========================================
voiceHandlerRef = new LS2Request().send({
service: "luna://com.webos.service.voiceconductor",
method: "/interactor/register",
subscribe: true, // ⚠️ 핵심!
parameters: {
type: "foreground",
},
onSuccess: (res) => {
console.log("[VoiceConductor] register response:", res);
// ✅ 최초 등록 성공: voiceTicket 저장
if (res.voiceTicket) {
currentVoiceTicket = res.voiceTicket;
console.log("[VoiceConductor] voiceTicket issued:", currentVoiceTicket);
}
// ✅ performAction 수신 처리 (STT 텍스트!)
if (res.command === "performAction") {
console.log("[VoiceConductor] performAction received:", res.action);
onCommand && onCommand(res.action, res.voiceTicket);
}
// 최초 등록 성공 콜백
if (res.returnValue && res.voiceTicket && !res.command) {
onSuccess && onSuccess(res);
}
},
onFailure: (err) => {
console.error("[VoiceConductor] register failed:", err);
currentVoiceTicket = null;
onFailure && onFailure(err);
},
});
return voiceHandlerRef;
};
/**
* Voice Framework에 현재 화면에서 처리 가능한 Intent 등록
*
* @param {Array} inAppIntents - Intent 배열
* @param {Object} callbacks - onSuccess, onFailure 콜백
* @returns {Object} LS2Request 인스턴스
*/
export const setVoiceContext = (inAppIntents, { onSuccess, onFailure }) => {
// Mock 처리
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK setVoiceContext:", inAppIntents);
onSuccess && onSuccess({ returnValue: true });
return null;
}
// voiceTicket 검증
if (!currentVoiceTicket) {
console.warn("[VoiceConductor] No voiceTicket. Call registerVoiceConductor first.");
onFailure && onFailure({ returnValue: false, errorText: "No voiceTicket" });
return null;
}
return new LS2Request().send({
service: "luna://com.webos.service.voiceconductor",
method: "/interactor/setContext",
parameters: {
voiceTicket: currentVoiceTicket,
inAppIntents: inAppIntents,
},
onSuccess: (res) => {
console.log("[VoiceConductor] setContext success:", res);
onSuccess && onSuccess(res);
},
onFailure: (err) => {
console.error("[VoiceConductor] setContext failed:", err);
onFailure && onFailure(err);
},
});
};
/**
* Voice 명령 처리 결과를 Voice Framework에 보고
*
* @param {Boolean} result - 성공 여부
* @param {String} utterance - TTS로 읽을 피드백 메시지
* @param {String} exception - 에러 타입
* @returns {Object} LS2Request 인스턴스
*/
export const reportActionResult = ({ result, utterance, exception, onSuccess, onFailure }) => {
// Mock 처리
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK reportActionResult:", { result, utterance, exception });
onSuccess && onSuccess({ returnValue: true });
return null;
}
if (!currentVoiceTicket) {
console.warn("[VoiceConductor] No voiceTicket for reportActionResult.");
return null;
}
const feedback = {};
if (utterance || exception) {
feedback.voiceUi = {};
if (utterance) feedback.voiceUi.systemUtterance = utterance;
if (exception) feedback.voiceUi.exception = exception;
}
return new LS2Request().send({
service: "luna://com.webos.service.voiceconductor",
method: "/interactor/reportActionResult",
parameters: {
voiceTicket: currentVoiceTicket,
result: result,
...(Object.keys(feedback).length > 0 && { feedback }),
},
onSuccess: (res) => {
console.log("[VoiceConductor] reportActionResult success:", res);
onSuccess && onSuccess(res);
},
onFailure: (err) => {
console.error("[VoiceConductor] reportActionResult failed:", err);
onFailure && onFailure(err);
},
});
};
/**
* Voice Framework 등록 해제
*/
export const unregisterVoiceConductor = () => {
if (voiceHandlerRef) {
console.log("[VoiceConductor] unregister");
voiceHandlerRef.cancel();
voiceHandlerRef = null;
}
currentVoiceTicket = null;
};
/**
* 현재 voiceTicket 가져오기
*/
export const getVoiceTicket = () => currentVoiceTicket;
4.2 lunaSend/index.js에 export 추가
// src/lunaSend/index.js
import { LS2RequestSingleton } from './LS2RequestSingleton';
export * from './account';
export * from './common';
export * from './voiceconductor'; // ✅ 추가
export const cancelReq = (instanceName) => {
let r = LS2RequestSingleton.instance(instanceName);
if (r) {
r.cancel();
r.cancelled = false;
LS2RequestSingleton.deleteInstance(instanceName);
}
};
5. Custom Hook 구현
5.1 useVoiceConductor.js 생성
// src/hooks/useVoiceConductor.js
import { useEffect, useCallback, useRef } from "react";
import {
registerVoiceConductor,
setVoiceContext,
reportActionResult,
unregisterVoiceConductor,
} from "../lunaSend/voiceconductor";
/**
* VoiceConductor Hook: 음성 명령 처리를 위한 React Hook
*
* @param {Boolean} isActive - 패널이 활성화(foreground)되었는지 여부
* @param {Function} onVoiceInput - STT 텍스트 수신 시 호출되는 콜백
* @param {Array} inAppIntents - 등록할 intent 목록 (선택, 기본값: UseIME)
*
* @example
* // SearchPanel에서 사용
* useVoiceConductor(isOnTop, (text) => {
* console.log('음성 텍스트:', text);
* setSearchQuery(text);
* handleSearchSubmit(text);
* });
*/
export const useVoiceConductor = (isActive, onVoiceInput, inAppIntents = null) => {
const isActiveRef = useRef(isActive);
// isActive 상태를 ref로 관리 (콜백에서 최신 값 참조)
useEffect(() => {
isActiveRef.current = isActive;
}, [isActive]);
// ========================================
// performAction 수신 처리
// ========================================
const handleCommand = useCallback(
(action, voiceTicket) => {
// 패널이 활성화되지 않았으면 무시
if (!isActiveRef.current) {
console.log("[useVoiceConductor] Not active, ignoring command");
return;
}
console.log("[useVoiceConductor] handleCommand:", action);
const { intent, value } = action;
// ✅ UseIME: 음성 입력 텍스트 전달
if (intent === "UseIME" && value) {
console.log("[useVoiceConductor] STT 텍스트:", value);
onVoiceInput && onVoiceInput(value);
// 성공 피드백 보고
reportActionResult({
result: true,
utterance: `Searching for ${value}`,
});
}
// 다른 intent 처리 가능 (Select, Scroll 등)
// if (intent === "Select" && action.itemId) { ... }
},
[onVoiceInput]
);
// ========================================
// Voice Framework 등록 및 Intent 설정
// ========================================
useEffect(() => {
if (!isActive) return;
console.log("[useVoiceConductor] Registering VoiceConductor...");
// 1. Voice Framework 등록
const handler = registerVoiceConductor({
onCommand: handleCommand,
onSuccess: (res) => {
console.log("[useVoiceConductor] Registered, voiceTicket:", res.voiceTicket);
// 2. Intent 등록 (UseIME)
const defaultIntents = [
{
intent: "UseIME",
supportAsrOnly: true, // STT만 사용 (NLP 불필요)
},
];
const intentsToRegister = inAppIntents || defaultIntents;
setVoiceContext(intentsToRegister, {
onSuccess: (contextRes) => {
console.log("[useVoiceConductor] Context set successfully");
},
onFailure: (err) => {
console.error("[useVoiceConductor] Failed to set context:", err);
},
});
},
onFailure: (err) => {
console.error("[useVoiceConductor] Registration failed:", err);
},
});
// 3. Cleanup: 패널이 닫히거나 비활성화될 때
return () => {
console.log("[useVoiceConductor] Unregistering...");
unregisterVoiceConductor();
};
}, [isActive, handleCommand, inAppIntents]);
};
6. React 컴포넌트 통합
6.1 SearchPanel.jsx에서 사용
// src/views/SearchPanel/SearchPanel.jsx
import React, { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getSearch, resetSearch } from "../../actions/searchActions";
import { useVoiceConductor } from "../../hooks/useVoiceConductor";
import TInput from "../../components/TInput/TInput";
function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState(panelInfo?.searchVal || "");
// ========================================
// ✅ Voice Input 처리 콜백
// ========================================
const handleVoiceInput = useCallback(
(voiceText) => {
console.log("[SearchPanel] Voice input received:", voiceText);
// 검색어 설정
setSearchQuery(voiceText);
// 즉시 검색 수행
if (voiceText && voiceText.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
query: voiceText.trim(),
domain: "theme,show,item",
})
);
}
},
[dispatch]
);
// ========================================
// ✅ VoiceConductor Hook 활성화
// ========================================
useVoiceConductor(isOnTop, handleVoiceInput);
// ========================================
// ✅ Global Voice Search 처리
// ========================================
useEffect(() => {
if (panelInfo?.voiceSearch && panelInfo?.searchVal) {
console.log("[SearchPanel] Global voice search:", panelInfo.searchVal);
setSearchQuery(panelInfo.searchVal);
dispatch(
getSearch({
service: "com.lgshop.app",
query: panelInfo.searchVal,
domain: "theme,show,item",
})
);
}
}, [panelInfo?.voiceSearch, panelInfo?.searchVal]);
// ========================================
// 수동 검색 처리
// ========================================
const handleSearchSubmit = useCallback(
(query) => {
if (query && query.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
query: query.trim(),
domain: "theme,show,item",
})
);
}
},
[dispatch]
);
return (
<div className="search-panel">
<TInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.value)}
onIconClick={() => handleSearchSubmit(searchQuery)}
placeholder="Say or type to search..." // 음성 입력 안내
/>
{/* 검색 결과 표시 */}
</div>
);
}
export default SearchPanel;
7. 전체 구현 체크리스트
Phase 1: Global Voice Search
-
appinfo.json 수정
inAppVoiceIntent섹션 추가supportedIntent에SearchContent,PlayContent추가supportedVoiceLanguage설정
-
App.js 수정
handleRelaunchEvent함수 구현webOSRelaunch이벤트 리스너 등록- Voice Intent 파라미터 파싱
- SearchPanel 열기 및 검색 실행
-
SearchPanel 수정
panelInfo.voiceSearch플래그 확인- 자동 검색 로직 추가
Phase 2: Foreground Voice Input
-
Luna Service 래퍼 생성
src/lunaSend/voiceconductor.js생성registerVoiceConductor함수 구현setVoiceContext함수 구현reportActionResult함수 구현unregisterVoiceConductor함수 구현
-
lunaSend/index.js 수정
- voiceconductor export 추가
-
Custom Hook 생성
src/hooks/useVoiceConductor.js생성- subscribe 모드 콜백 처리
- Intent 등록 로직
- Cleanup 로직
-
SearchPanel 통합
useVoiceConductorHook 추가handleVoiceInput콜백 구현- STT 텍스트로 자동 검색
Phase 3: 테스트
-
브라우저 Mock 테스트
- Console에 MOCK 로그 확인
- 수동 트리거로 동작 확인
-
Emulator 테스트
- 앱 빌드 및 설치 (
npm run pack) - Global Voice Search 테스트
- Foreground Voice Input 테스트
- 앱 빌드 및 설치 (
-
실제 TV 테스트
- Voice Remote로 명령 테스트
- 다국어 테스트 (en-US, ko-KR)
- Luna Service 로그 확인
8. 트러블슈팅
문제 1: voiceTicket이 null로 나옴
원인:
subscribe: true를 설정하지 않음- Luna Service 호출 실패
해결:
// ✅ subscribe: true 확인
registerVoiceConductor({
subscribe: true, // 필수!
...
});
문제 2: STT 텍스트를 받지 못함
원인:
setContext에서 UseIME intent를 등록하지 않음performAction이벤트 처리 누락
해결:
// 1. Intent 등록 확인
setVoiceContext([{
intent: "UseIME",
supportAsrOnly: true,
}]);
// 2. performAction 처리 확인
if (response.command === "performAction") {
console.log(response.action.value); // STT 텍스트
}
문제 3: 패널이 닫혀도 음성 입력이 계속 처리됨
원인:
unregisterVoiceConductor호출 누락
해결:
// useEffect cleanup에서 반드시 호출
useEffect(() => {
// ... 등록 로직
return () => {
unregisterVoiceConductor(); // ✅ 필수!
};
}, [isActive]);
문제 4: 브라우저에서 에러 발생
원인:
- PalmSystem이 없는 환경에서 Luna Service 호출
해결:
// Mock 처리 추가
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK mode");
// Mock 응답 반환
return;
}
9. 핵심 요약
voiceTicket의 역할
- Voice Framework와 통신하기 위한 인증 토큰
/interactor/register최초 호출 시 발급- 이후
setContext,reportActionResult에 필수
STT 텍스트 수신 방법
/interactor/register를 subscribe: true로 호출- 최초 onSuccess에서 voiceTicket 저장
- 사용자 발화 시 같은 onSuccess가 다시 호출됨
response.command === "performAction"확인response.action.value에 STT 텍스트 포함!
전체 흐름
register (subscribe: true)
→ voiceTicket 발급
→ setContext (UseIME 등록)
→ 사용자 발화
→ onSuccess 다시 호출 (performAction)
→ action.value = STT 텍스트!
→ reportActionResult
10. 참고 문서
- VUI 기본 가이드:
docs/vui/VoiceUserInterface_ko.md - SearchPanel VUI 구현:
docs/vui/searchpanel-vui-implementation-guide.md - 테스트 시나리오:
docs/vui/vui-test-scenarios.md - Luna Service 가이드: webOS Developer Portal
작성일: 2025-10-15 작성자: AI Assistant 버전: 1.0.0 관련 이슈: React VUI Implementation Guide