본문으로 건너뛰기

React 사용 가이드

Klever One SDK는 React 애플리케이션에 디지털 휴먼을 쉽게 통합할 수 있도록 강력하고 유연한 컴포넌트와 Hook을 제공합니다.

빠른 시작: UnifiedConversation 컴포넌트

가장 간단하게 시작하는 방법은 <UnifiedConversation /> 컴포넌트를 사용하는 것입니다. 이 컴포넌트는 완전한 대화형 인터페이스를 제공합니다.

기본 사용법
"use client";

import { UnifiedConversation } from "@klever-one/web-sdk/react";

function App() {
return <UnifiedConversation apiKey="your-api-key" />;
}

Props

Prop타입기본값설명
apiKeystring(필수)Klever One API 키

권장 접근 방식: useKleverOneClient Hook

더 세밀한 제어가 필요하거나 커스텀 UI를 구축하려는 경우, useKleverOneClient Hook 사용을 적극 권장합니다. 이 Hook은 모든 SDK 기능을 통합된 인터페이스로 제공하여 개발 경험을 크게 향상시킵니다.

장점

  • 단일 진입점: 하나의 import와 설정으로 모든 기능에 접근할 수 있습니다.
  • 통합 상태 관리: 연결, 녹음, 스트리밍 등 모든 상태를 하나의 객체에서 관리합니다.
  • 간소화된 API: 복잡한 상호작용 없이 직관적인 메서드를 사용할 수 있습니다.
  • 일관된 아키텍처: 핵심 KleverOneClient 클래스와 동일한 사용 패턴을 따릅니다.

useKleverOneClient 사용 예제

다음은 useKleverOneClient Hook을 사용하여 디지털 휴먼과 상호작용하는 전체 React 컴포넌트 예제입니다.

"use client";

import { useRef, useEffect, useState, useMemo } from "react";
import { useKleverOneClient, type Message } from "@klever-one/web-sdk/react";
import { type LipMotionData } from "@klever-one/web-sdk/core";

interface MyDigitalHumanAppProps {
apiKey: string;
}

const MyDigitalHumanApp: React.FC<MyDigitalHumanAppProps> = ({ apiKey }) => {
const containerRef = useRef<HTMLDivElement>(null);
const messageContainerRef = useRef<HTMLDivElement>(null);
const [inputMessage, setInputMessage] = useState("");
const [volume, setVolume] = useState<number>(1);

// 임시 컨테이너 생성
const tempContainer = useMemo(() => {
if (typeof document !== "undefined") {
return document.createElement("div");
}
return {} as HTMLDivElement;
}, []);

// 콜백 최적화
const callbacks = useMemo(
() => ({
onReady: () => console.log("디지털 휴먼 준비 완료!"),
onMessageReceived: (message: Message) =>
console.log("받은 메시지:", message),
onError: (error: Error) => alert(`오류가 발생했습니다: ${error.message}`),
onLipMotionStart: (data: LipMotionData) =>
console.log(
`아바타 발화 시작! [${data.index + 1}/${data.totalCount}]: ${data.text}`,
),
onConversationEnd: () => console.log("모든 발화가 끝났습니다!"),
}),
[],
);

// useKleverOneClient Hook을 사용하여 클라이언트 인스턴스 및 상태 관리
const client = useKleverOneClient({
apiKey,
container: containerRef.current || tempContainer,
callbacks,
// ShortForm 모드 활성화 (숏폼 미리보기 기능 사용 시 필요)
// streaming: {
// initialMode: "ShortForm",
// },
});

// 메시지를 ID별로 중복 제거하여 처리 (스트리밍 효과를 위해)
const processedMessages = useMemo(() => {
const messageMap = new Map();
client.messages.forEach((msg) => {
if (msg.id) {
messageMap.set(msg.id, msg);
} else {
messageMap.set(Symbol("unique-msg"), msg);
}
});
return Array.from(messageMap.values());
}, [client.messages]);

// 컴포넌트 언마운트 시 클라이언트 연결 해제
useEffect(() => {
return () => {
if (client.client) {
client.disconnect();
}
};
}, [client.client]);

// 메시지 변경 시 자동 스크롤
useEffect(() => {
if (messageContainerRef.current && processedMessages.length > 0) {
const container = messageContainerRef.current;
// 스크롤을 맨 아래로 이동
container.scrollTop = container.scrollHeight;
}
}, [processedMessages]);

// 연결 시작 핸들러
const handleConnect = async () => {
if (containerRef.current && client.client) {
try {
await client.connect();
} catch (error) {
alert(
`연결 실패: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
};

// 연결 종료 핸들러
const handleDisconnect = () => {
if (client.client) {
client.disconnect();
}
};

// 메시지 전송 핸들러
const handleSendMessage = async () => {
if (inputMessage.trim() && client.isReady()) {
try {
await client.sendText(inputMessage.trim());
setInputMessage("");
} catch (error) {
alert(
`메시지 전송 실패: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
};

// 녹음 토글 핸들러
const handleToggleRecording = async () => {
try {
if (client.state.recording === "recording") {
await client.stopRecording();
} else {
await client.startRecording();
}
} catch (error) {
alert(
`녹음 상태 변경 실패: ${error instanceof Error ? error.message : String(error)}`,
);
}
};

// 볼륨 조절 핸들러
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
client.setVolume(newVolume);
};

// 줌 인 핸들러
const handleZoomIn = () => {
if (client.client) {
client.sendZoomIn();
}
};

// 줌 아웃 핸들러
const handleZoomOut = () => {
if (client.client) {
client.sendZoomOut();
}
};

// 대화 초기화 핸들러
const handleResetConversation = () => {
if (client.client) {
client.resetConversation();
}
};

// 행동 실행 핸들러
const handleExecuteAction = (actionId: string) => {
if (client.client) {
client.executeAction(actionId);
}
};

// 말하기 중지 핸들러
const handleStopSpeaking = () => {
if (client.client) {
client.stopSpeaking();
}
};

return (
<div className="flex gap-4 font-sans">
{/* 디지털 휴먼 스트리밍 영역 */}
<div className="relative h-[400px] w-[800px] rounded-lg bg-black">
<div
ref={containerRef}
className="h-full w-full"
/>

{/* 상태 표시 */}
<div className="absolute left-4 top-4 text-white">
연결 상태: {client.state.connection}
</div>

{/* 컨트롤 버튼 */}
<div className="absolute right-4 top-4 flex flex-col gap-2">
<div className="flex gap-2">
<button
onClick={handleConnect}
className="cursor-pointer rounded border-none bg-blue-500 px-4 py-2 text-white"
disabled={client.state.connection === "connected"}
>
연결 시작
</button>
<button
onClick={handleDisconnect}
className="cursor-pointer rounded border-none bg-red-500 px-4 py-2 text-white"
disabled={client.state.connection === "disconnected"}
>
연결 종료
</button>
</div>

{/* 연결됨 상태일 때만 추가 컨트롤 표시 */}
{client.state.connection === "connected" && (
<>
{/* 대화 초기화 버튼 */}
<button
onClick={handleResetConversation}
className="rounded bg-gray-600 px-4 py-2 text-sm text-white"
>
대화 초기화
</button>

{/* 볼륨 조절 */}
<div className="flex items-center gap-2 rounded bg-white bg-opacity-90 px-3 py-2">
<span className="text-xs text-gray-800">볼륨</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="w-24"
/>
<span className="text-xs text-gray-800">
{Math.round(volume * 100)}%
</span>
</div>

{/* 줌 컨트롤 */}
<div className="flex gap-2">
<button
onClick={handleZoomIn}
className="flex-1 rounded bg-green-600 px-3 py-2 text-sm text-white"
>
줌 인
</button>
<button
onClick={handleZoomOut}
className="flex-1 rounded bg-green-600 px-3 py-2 text-sm text-white"
>
줌 아웃
</button>
</div>

{/* 행동 실행 버튼 */}
<button
onClick={() => handleExecuteAction("JOB_001")}
className="rounded bg-purple-600 px-4 py-2 text-sm text-white"
>
행동 실행
</button>

{/* 말하기 중지 버튼 */}
<button
onClick={handleStopSpeaking}
className="rounded bg-red-600 px-4 py-2 text-sm text-white"
disabled={!client.state.isStreaming}
>
말하기 중지
</button>
</>
)}
</div>
</div>

{/* 메시지 목록 영역 */}
<div className="flex h-[400px] w-80 flex-col rounded-lg bg-gray-100 p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-bold text-gray-900">대화 내역</h3>
<span className="text-xs text-gray-600">
{processedMessages.length > 0
? `${processedMessages.length}개 메시지`
: "대화 없음"}
</span>
</div>
<div
className="mb-4 flex-1 space-y-2 overflow-y-auto"
ref={messageContainerRef}
>
{processedMessages.length === 0 ? (
<div className="py-8 text-center text-gray-500">
아직 대화 내역이 없습니다
</div>
) : (
// 최근 10개 메시지만 표시 (성능 최적화)
processedMessages.slice(-10).map((message, index) => (
<div
key={message.id || index}
className={`max-w-[85%] rounded-lg p-3 ${
message.role === "user"
? "ml-auto bg-blue-500 text-white"
: "mr-auto border border-gray-200 bg-white text-gray-900"
} ${
message.isDelta
? "animate-pulse border-l-4 border-blue-400"
: ""
}`}
>
<div className="text-sm">
<strong className="text-xs opacity-70">
{message.role === "user" ? "나" : "AI"}
{message.isDelta && (
<span
className={`ml-1 ${message.role === "user" ? "text-blue-200" : "text-blue-600"}`}
>
입력 중...
</span>
)}
</strong>
</div>
<div className="mt-1 leading-relaxed">
{/* Delta 메시지의 경우 fullContent 우선 표시 */}
{message.isDelta && message.fullContent
? message.fullContent
: message.content}
{/* 스트리밍 중일 때 커서 표시 */}
{message.isDelta && (
<span
className={`ml-1 inline-block h-4 w-2 animate-pulse ${message.role === "user" ? "bg-white" : "bg-gray-600"}`}
/>
)}
</div>
<div className="mt-1 text-xs opacity-60">
{message.createdAt
? new Date(message.createdAt).toLocaleTimeString()
: new Date().toLocaleTimeString()}
</div>
</div>
))
)}
</div>

{/* 메시지 입력 */}
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="메시지 입력..."
className="flex-1 rounded border border-gray-300 bg-white px-3 py-2 text-gray-900 placeholder-gray-400"
disabled={!client.isReady()}
onKeyPress={(e) => {
if (e.key === "Enter" && inputMessage.trim()) {
handleSendMessage();
}
}}
/>
<button
onClick={handleSendMessage}
className="rounded bg-blue-500 px-4 py-2 text-white"
disabled={!client.isReady() || !inputMessage.trim()}
>
전송
</button>
</div>

<button
onClick={handleToggleRecording}
className={`w-full rounded px-4 py-2 text-white ${
client.state.recording === "recording"
? "bg-red-500"
: "bg-green-500"
}`}
disabled={!client.isReady()}
>
{client.state.recording === "recording" ? "녹음 중지" : "음성 녹음"}
</button>
</div>
</div>
</div>
);
};

export default MyDigitalHumanApp;