openclaw-liveavatar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +42 -0
  3. package/.next/app-path-routes-manifest.json +6 -0
  4. package/.next/build-manifest.json +33 -0
  5. package/.next/cache/.previewinfo +1 -0
  6. package/.next/cache/.rscinfo +1 -0
  7. package/.next/cache/.tsbuildinfo +1 -0
  8. package/.next/cache/chrome-devtools-workspace-uuid +1 -0
  9. package/.next/cache/next-devtools-config.json +1 -0
  10. package/.next/cache/webpack/client-production/0.pack +0 -0
  11. package/.next/cache/webpack/client-production/1.pack +0 -0
  12. package/.next/cache/webpack/client-production/2.pack +0 -0
  13. package/.next/cache/webpack/client-production/3.pack +0 -0
  14. package/.next/cache/webpack/client-production/4.pack +0 -0
  15. package/.next/cache/webpack/client-production/index.pack +0 -0
  16. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  17. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  18. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  19. package/.next/cache/webpack/server-production/0.pack +0 -0
  20. package/.next/cache/webpack/server-production/index.pack +0 -0
  21. package/.next/diagnostics/build-diagnostics.json +6 -0
  22. package/.next/diagnostics/framework.json +1 -0
  23. package/.next/export-marker.json +6 -0
  24. package/.next/images-manifest.json +58 -0
  25. package/.next/next-minimal-server.js.nft.json +1 -0
  26. package/.next/next-server.js.nft.json +1 -0
  27. package/.next/package.json +1 -0
  28. package/.next/prerender-manifest.json +61 -0
  29. package/.next/react-loadable-manifest.json +1 -0
  30. package/.next/required-server-files.json +320 -0
  31. package/.next/routes-manifest.json +53 -0
  32. package/.next/server/app/_not-found/page.js +5 -0
  33. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  34. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  35. package/.next/server/app/_not-found.html +4 -0
  36. package/.next/server/app/_not-found.meta +8 -0
  37. package/.next/server/app/_not-found.rsc +15 -0
  38. package/.next/server/app/api/get-avatars/route.js +1 -0
  39. package/.next/server/app/api/get-avatars/route.js.nft.json +1 -0
  40. package/.next/server/app/api/get-avatars/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/api/start-session/route.js +1 -0
  42. package/.next/server/app/api/start-session/route.js.nft.json +1 -0
  43. package/.next/server/app/api/start-session/route_client-reference-manifest.js +1 -0
  44. package/.next/server/app/index.html +4 -0
  45. package/.next/server/app/index.meta +7 -0
  46. package/.next/server/app/index.rsc +16 -0
  47. package/.next/server/app/page.js +9 -0
  48. package/.next/server/app/page.js.nft.json +1 -0
  49. package/.next/server/app/page_client-reference-manifest.js +1 -0
  50. package/.next/server/app-paths-manifest.json +6 -0
  51. package/.next/server/chunks/361.js +9 -0
  52. package/.next/server/chunks/611.js +6 -0
  53. package/.next/server/chunks/873.js +22 -0
  54. package/.next/server/functions-config-manifest.json +4 -0
  55. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  56. package/.next/server/middleware-build-manifest.js +1 -0
  57. package/.next/server/middleware-manifest.json +6 -0
  58. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  59. package/.next/server/next-font-manifest.js +1 -0
  60. package/.next/server/next-font-manifest.json +1 -0
  61. package/.next/server/pages/404.html +4 -0
  62. package/.next/server/pages/500.html +1 -0
  63. package/.next/server/pages/_app.js +1 -0
  64. package/.next/server/pages/_app.js.nft.json +1 -0
  65. package/.next/server/pages/_document.js +1 -0
  66. package/.next/server/pages/_document.js.nft.json +1 -0
  67. package/.next/server/pages/_error.js +19 -0
  68. package/.next/server/pages/_error.js.nft.json +1 -0
  69. package/.next/server/pages-manifest.json +6 -0
  70. package/.next/server/server-reference-manifest.js +1 -0
  71. package/.next/server/server-reference-manifest.json +1 -0
  72. package/.next/server/webpack-runtime.js +1 -0
  73. package/.next/static/chunks/144d3bae-37bcc55d23f188ee.js +1 -0
  74. package/.next/static/chunks/255-35bf8c00c5dde345.js +1 -0
  75. package/.next/static/chunks/336-a66237a0a1db954a.js +1 -0
  76. package/.next/static/chunks/4bd1b696-c023c6e3521b1417.js +1 -0
  77. package/.next/static/chunks/app/_not-found/page-dfc6e5d8e6c6203c.js +1 -0
  78. package/.next/static/chunks/app/api/get-avatars/route-8017e1cff542d5d0.js +1 -0
  79. package/.next/static/chunks/app/api/start-session/route-8017e1cff542d5d0.js +1 -0
  80. package/.next/static/chunks/app/layout-ff675313cc8f8fcf.js +1 -0
  81. package/.next/static/chunks/app/page-9e4b703722bef650.js +1 -0
  82. package/.next/static/chunks/framework-de98b93a850cfc71.js +1 -0
  83. package/.next/static/chunks/main-1a0dcce460eb61ce.js +1 -0
  84. package/.next/static/chunks/main-app-e7f1007edc7ad7e1.js +1 -0
  85. package/.next/static/chunks/pages/_app-7d307437aca18ad4.js +1 -0
  86. package/.next/static/chunks/pages/_error-cb2a52f75f2162e2.js +1 -0
  87. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  88. package/.next/static/chunks/webpack-4a462cecab786e93.js +1 -0
  89. package/.next/static/css/bfd73afa11897439.css +3 -0
  90. package/.next/static/v_GdCj8lVweDVhmIhhEcM/_buildManifest.js +1 -0
  91. package/.next/static/v_GdCj8lVweDVhmIhhEcM/_ssgManifest.js +1 -0
  92. package/.next/trace +2 -0
  93. package/.next/types/app/api/get-avatars/route.ts +347 -0
  94. package/.next/types/app/api/start-session/route.ts +347 -0
  95. package/.next/types/app/layout.ts +84 -0
  96. package/.next/types/app/page.ts +84 -0
  97. package/.next/types/cache-life.d.ts +141 -0
  98. package/.next/types/package.json +1 -0
  99. package/.next/types/routes.d.ts +74 -0
  100. package/.next/types/validator.ts +88 -0
  101. package/README.md +241 -0
  102. package/app/api/config.ts +18 -0
  103. package/app/api/get-avatars/route.ts +117 -0
  104. package/app/api/start-session/route.ts +95 -0
  105. package/app/globals.css +3 -0
  106. package/app/layout.tsx +37 -0
  107. package/app/page.tsx +9 -0
  108. package/bin/cli.js +100 -0
  109. package/package.json +66 -0
  110. package/src/components/LiveAvatarSession.tsx +825 -0
  111. package/src/components/OpenClawDemo.tsx +399 -0
  112. package/src/gateway/client.ts +522 -0
  113. package/src/gateway/types.ts +83 -0
  114. package/src/liveavatar/context.tsx +750 -0
  115. package/src/liveavatar/index.ts +6 -0
  116. package/src/liveavatar/types.ts +10 -0
  117. package/src/liveavatar/useAvatarActions.ts +41 -0
  118. package/src/liveavatar/useChatHistory.ts +7 -0
  119. package/src/liveavatar/useSession.ts +37 -0
  120. package/src/liveavatar/useTextChat.ts +32 -0
  121. package/src/liveavatar/useVoiceChat.ts +70 -0
  122. package/tsconfig.json +40 -0
@@ -0,0 +1,750 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import {
12
+ ConnectionQuality,
13
+ LiveAvatarSession,
14
+ SessionState,
15
+ SessionEvent,
16
+ VoiceChatEvent,
17
+ VoiceChatState,
18
+ AgentEventsEnum,
19
+ } from "@heygen/liveavatar-web-sdk";
20
+ import { LiveAvatarSessionMessage, MessageSender } from "./types";
21
+ import { LIVEAVATAR_API_URL } from "../../app/api/config";
22
+ import {
23
+ OpenClawGatewayClient,
24
+ getGatewayClient,
25
+ } from "../gateway/client";
26
+ import { GatewayConnectionState } from "../gateway/types";
27
+
28
+ /**
29
+ * Truncate text for TTS to avoid overwhelming the avatar with long responses.
30
+ * Keeps approximately 2-3 sentences (around 200 characters max).
31
+ */
32
+ const truncateForTTS = (text: string, maxLength: number = 200): string => {
33
+ if (text.length <= maxLength) {
34
+ return text;
35
+ }
36
+
37
+ // Try to cut at a sentence boundary
38
+ const truncated = text.substring(0, maxLength);
39
+
40
+ // Find the last sentence ending (., !, ?)
41
+ const lastSentenceEnd = Math.max(
42
+ truncated.lastIndexOf(". "),
43
+ truncated.lastIndexOf("! "),
44
+ truncated.lastIndexOf("? "),
45
+ truncated.lastIndexOf(".\n"),
46
+ truncated.lastIndexOf("!\n"),
47
+ truncated.lastIndexOf("?\n")
48
+ );
49
+
50
+ if (lastSentenceEnd > maxLength * 0.5) {
51
+ // Cut at sentence boundary if it's past halfway
52
+ return truncated.substring(0, lastSentenceEnd + 1).trim();
53
+ }
54
+
55
+ // Otherwise cut at last space and add ellipsis
56
+ const lastSpace = truncated.lastIndexOf(" ");
57
+ if (lastSpace > maxLength * 0.7) {
58
+ return truncated.substring(0, lastSpace).trim() + "...";
59
+ }
60
+
61
+ return truncated.trim() + "...";
62
+ };
63
+
64
+ /**
65
+ * Placeholder phrases to speak when OpenClaw takes longer than 2 seconds to respond
66
+ */
67
+ const PROCESSING_PHRASES = [
68
+ "Let me think about that...",
69
+ "One moment please...",
70
+ "Let me check on that...",
71
+ "Give me a second...",
72
+ "Looking into that...",
73
+ "Hmm, let me see...",
74
+ ];
75
+
76
+ const getRandomProcessingPhrase = (): string => {
77
+ return PROCESSING_PHRASES[Math.floor(Math.random() * PROCESSING_PHRASES.length)];
78
+ };
79
+
80
+ /**
81
+ * Intro phrases for when the avatar session starts
82
+ */
83
+ const INTRO_PHRASES = [
84
+ "Hey there! I'm ready to help. What can I do for you today?",
85
+ "Hi! Good to see you. What would you like to work on?",
86
+ "Hello! I'm all set. What's on your mind?",
87
+ "Hey! Ready when you are. What can I help you with?",
88
+ "Hi there! Let's get started. What do you need?",
89
+ "Hello! I'm here to assist. What are you working on today?",
90
+ ];
91
+
92
+ const DEMO_INTRO_PHRASES = [
93
+ "Hi there! Welcome to the OpenClaw demo. Feel free to ask me anything, or say 'help' to learn more!",
94
+ "Hello! This is the OpenClaw LiveAvatar demo. Try asking me what I can do!",
95
+ "Hey! Welcome to the demo. I'm here to show you how this works. What would you like to know?",
96
+ ];
97
+
98
+ const getRandomIntroPhrase = (isDemoMode: boolean): string => {
99
+ const phrases = isDemoMode ? DEMO_INTRO_PHRASES : INTRO_PHRASES;
100
+ return phrases[Math.floor(Math.random() * phrases.length)];
101
+ };
102
+
103
+ type LiveAvatarContextProps = {
104
+ sessionRef: React.RefObject<LiveAvatarSession>;
105
+
106
+ isMuted: boolean;
107
+ voiceChatState: VoiceChatState;
108
+
109
+ sessionState: SessionState;
110
+ isStreamReady: boolean;
111
+ connectionQuality: ConnectionQuality;
112
+
113
+ isUserTalking: boolean;
114
+ isAvatarTalking: boolean;
115
+
116
+ messages: LiveAvatarSessionMessage[];
117
+ addMessage: (message: LiveAvatarSessionMessage) => void;
118
+ addTypedMessage: (text: string) => void;
119
+
120
+ // OpenClaw Gateway state
121
+ gatewayState: GatewayConnectionState;
122
+ isProcessingAgent: boolean;
123
+ isDemoMode: boolean;
124
+ };
125
+
126
+ export const LiveAvatarContext = createContext<LiveAvatarContextProps>({
127
+ sessionRef: {
128
+ current: null,
129
+ } as unknown as React.RefObject<LiveAvatarSession>,
130
+ connectionQuality: ConnectionQuality.UNKNOWN,
131
+ isMuted: true,
132
+ voiceChatState: VoiceChatState.INACTIVE,
133
+ sessionState: SessionState.DISCONNECTED,
134
+ isStreamReady: false,
135
+ isUserTalking: false,
136
+ isAvatarTalking: false,
137
+ messages: [],
138
+ addMessage: () => {},
139
+ addTypedMessage: () => {},
140
+ gatewayState: "disconnected",
141
+ isProcessingAgent: false,
142
+ isDemoMode: true,
143
+ });
144
+
145
+ type LiveAvatarContextProviderProps = {
146
+ children: React.ReactNode;
147
+ sessionAccessToken: string;
148
+ };
149
+
150
+ const useSessionState = (sessionRef: React.RefObject<LiveAvatarSession>) => {
151
+ const [sessionState, setSessionState] = useState<SessionState>(
152
+ sessionRef.current?.state || SessionState.INACTIVE
153
+ );
154
+ const [connectionQuality, setConnectionQuality] = useState<ConnectionQuality>(
155
+ sessionRef.current?.connectionQuality || ConnectionQuality.UNKNOWN
156
+ );
157
+ const [isStreamReady, setIsStreamReady] = useState<boolean>(false);
158
+
159
+ useEffect(() => {
160
+ if (sessionRef.current) {
161
+ sessionRef.current.on(SessionEvent.SESSION_STATE_CHANGED, (state) => {
162
+ setSessionState(state);
163
+ if (state === SessionState.DISCONNECTED) {
164
+ sessionRef.current.removeAllListeners();
165
+ sessionRef.current.voiceChat.removeAllListeners();
166
+ setIsStreamReady(false);
167
+ }
168
+ });
169
+ sessionRef.current.on(SessionEvent.SESSION_STREAM_READY, () => {
170
+ setIsStreamReady(true);
171
+ });
172
+ sessionRef.current.on(
173
+ SessionEvent.SESSION_CONNECTION_QUALITY_CHANGED,
174
+ setConnectionQuality
175
+ );
176
+ }
177
+ }, [sessionRef]);
178
+
179
+ return { sessionState, isStreamReady, connectionQuality };
180
+ };
181
+
182
+ const useVoiceChatState = (sessionRef: React.RefObject<LiveAvatarSession>) => {
183
+ const [isMuted, setIsMuted] = useState(true);
184
+ const [voiceChatState, setVoiceChatState] = useState<VoiceChatState>(
185
+ sessionRef.current?.voiceChat.state || VoiceChatState.INACTIVE
186
+ );
187
+
188
+ useEffect(() => {
189
+ if (sessionRef.current) {
190
+ sessionRef.current.voiceChat.on(VoiceChatEvent.MUTED, () => {
191
+ setIsMuted(true);
192
+ });
193
+ sessionRef.current.voiceChat.on(VoiceChatEvent.UNMUTED, () => {
194
+ setIsMuted(false);
195
+ });
196
+ sessionRef.current.voiceChat.on(
197
+ VoiceChatEvent.STATE_CHANGED,
198
+ setVoiceChatState
199
+ );
200
+ }
201
+ }, [sessionRef]);
202
+
203
+ return { isMuted, voiceChatState };
204
+ };
205
+
206
+ const useTalkingState = (sessionRef: React.RefObject<LiveAvatarSession>) => {
207
+ const [isUserTalking, setIsUserTalking] = useState(false);
208
+ const [isAvatarTalking, setIsAvatarTalking] = useState(false);
209
+
210
+ useEffect(() => {
211
+ if (sessionRef.current) {
212
+ sessionRef.current.on(AgentEventsEnum.USER_SPEAK_STARTED, () => {
213
+ setIsUserTalking(true);
214
+ });
215
+ sessionRef.current.on(AgentEventsEnum.USER_SPEAK_ENDED, () => {
216
+ setIsUserTalking(false);
217
+ });
218
+ sessionRef.current.on(AgentEventsEnum.AVATAR_SPEAK_STARTED, () => {
219
+ setIsAvatarTalking(true);
220
+ });
221
+ sessionRef.current.on(AgentEventsEnum.AVATAR_SPEAK_ENDED, () => {
222
+ setIsAvatarTalking(false);
223
+ });
224
+ }
225
+ }, [sessionRef]);
226
+
227
+ return { isUserTalking, isAvatarTalking };
228
+ };
229
+
230
+ /**
231
+ * Demo mode FAQ responses - comprehensive guide to the OpenClaw LiveAvatar integration
232
+ */
233
+ const getDemoResponse = (text: string): string => {
234
+ const lowerText = text.toLowerCase().trim();
235
+
236
+ // Greetings
237
+ if (lowerText.match(/^(hi|hello|hey|greetings|good morning|good afternoon|good evening)\.?$/)) {
238
+ return "Hello! Welcome to the OpenClaw LiveAvatar integration demo. I'm here to show you how this works. Try asking me 'what is this?' or 'how does it work?' to learn more!";
239
+ }
240
+
241
+ // What is this / Introduction
242
+ if (lowerText.includes("what is this") || lowerText.includes("what are you") || lowerText.includes("who are you")) {
243
+ return "I'm a LiveAvatar - a real-time AI video avatar that serves as your voice and video interface to OpenClaw agents. Think of me as a friendly face for your AI assistant. When connected to OpenClaw, I'll speak your agent's responses and listen to your voice commands!";
244
+ }
245
+
246
+ // How does it work
247
+ if (lowerText.includes("how does it work") || lowerText.includes("how do you work") || lowerText.includes("explain")) {
248
+ return "Here's how it works: You speak to me or type a message. Your input goes to your OpenClaw agent, which processes it and generates a response. Then I speak that response back to you with natural lip-sync and expressions. It's like having a video call with your AI agent!";
249
+ }
250
+
251
+ // What is OpenClaw
252
+ if (lowerText.includes("openclaw") && (lowerText.includes("what") || lowerText.includes("tell me about"))) {
253
+ return "OpenClaw is an AI agent platform that lets you build and deploy intelligent assistants. These agents can handle tasks, answer questions, and integrate with your tools. This LiveAvatar integration adds a human-like video interface to make interactions more engaging and natural.";
254
+ }
255
+
256
+ // What is LiveAvatar
257
+ if (lowerText.includes("liveavatar") && (lowerText.includes("what") || lowerText.includes("tell me about"))) {
258
+ return "LiveAvatar is powered by HeyGen's streaming avatar technology. It creates real-time, photorealistic AI avatars that can speak any text with natural expressions and lip-sync. Combined with OpenClaw, it transforms text-based AI interactions into face-to-face conversations.";
259
+ }
260
+
261
+ // Help / Commands
262
+ if (lowerText === "help" || lowerText.includes("what can i ask") || lowerText.includes("what can you do") || lowerText.includes("commands")) {
263
+ return "In demo mode, you can ask me about: 'What is this?', 'How does it work?', 'What is OpenClaw?', 'What is LiveAvatar?', 'How do I connect?', 'Features', 'Requirements', 'Pricing', or 'Get started'. Once connected to OpenClaw, I'll respond with your actual agent's intelligence!";
264
+ }
265
+
266
+ // How to connect / Setup
267
+ if (lowerText.includes("connect") || lowerText.includes("setup") || lowerText.includes("get started") || lowerText.includes("install")) {
268
+ return "To connect to your OpenClaw agent: First, make sure OpenClaw is running on your computer with the Gateway enabled on port 18789. Then refresh this page - I'll automatically detect the connection and switch from demo mode to live mode. You'll see the status change from 'Demo Mode' to 'OpenClaw Connected'.";
269
+ }
270
+
271
+ // Features
272
+ if (lowerText.includes("feature")) {
273
+ return "Key features include: Voice-to-voice conversations with your AI agent, real-time video avatar with natural expressions, text chat as an alternative to voice, multiple avatar options to choose from, and seamless integration with your OpenClaw workflows. It's like giving your AI a face!";
274
+ }
275
+
276
+ // Requirements
277
+ if (lowerText.includes("requirement") || lowerText.includes("need") || lowerText.includes("prerequisite")) {
278
+ return "To use this integration you'll need: An OpenClaw account with an active agent, the OpenClaw Gateway running locally, a LiveAvatar API key from HeyGen, a modern browser with microphone access, and a stable internet connection for the video stream.";
279
+ }
280
+
281
+ // Pricing / Cost
282
+ if (lowerText.includes("price") || lowerText.includes("cost") || lowerText.includes("free") || lowerText.includes("pricing")) {
283
+ return "LiveAvatar sessions consume HeyGen credits based on session duration. OpenClaw has its own pricing for agent usage. Check openclaw.ai and heygen.com for current pricing. This demo mode is free to try and shows you exactly how the integration works!";
284
+ }
285
+
286
+ // Demo mode explanation
287
+ if (lowerText.includes("demo mode") || lowerText.includes("demo")) {
288
+ return "You're currently in demo mode because no OpenClaw Gateway connection was detected. In this mode, I respond with pre-set information about the integration. Once you connect to OpenClaw, I'll relay your messages to your actual AI agent and speak its responses!";
289
+ }
290
+
291
+ // Voice / Microphone
292
+ if (lowerText.includes("voice") || lowerText.includes("microphone") || lowerText.includes("speak") || lowerText.includes("talk")) {
293
+ return "You can talk to me using your microphone! Click the green microphone button to unmute, then just speak naturally. I'll transcribe what you say, process it, and respond verbally. You can also type in the chat box if you prefer text input.";
294
+ }
295
+
296
+ // Avatar / Change avatar
297
+ if (lowerText.includes("avatar") || lowerText.includes("change") || lowerText.includes("appearance")) {
298
+ return "You can change my appearance by clicking the person icon in the bottom right of the video. This opens the avatar selector where you can choose from different available avatars. Each avatar has its own look and voice!";
299
+ }
300
+
301
+ // Goodbye
302
+ if (lowerText.includes("bye") || lowerText.includes("goodbye") || lowerText.includes("see you") || lowerText.includes("thanks")) {
303
+ return "Thank you for trying the OpenClaw LiveAvatar demo! When you're ready to use it with your actual OpenClaw agent, just start the Gateway and refresh this page. Have a great day!";
304
+ }
305
+
306
+ // Default response for unrecognized input
307
+ return `I heard: "${text}". I'm currently in demo mode, showing you how this integration works. Try asking me about 'what is this?', 'how does it work?', or type 'help' for more options. Once connected to OpenClaw, your agent will provide intelligent responses to any question!`;
308
+ };
309
+
310
+ /**
311
+ * Hook to bridge LiveAvatar transcriptions to OpenClaw Gateway (or demo mode)
312
+ * When user speaks, send to OpenClaw agent and make avatar speak the response
313
+ */
314
+ const useOpenClawBridge = (
315
+ sessionRef: React.RefObject<LiveAvatarSession>,
316
+ addMessage: (message: LiveAvatarSessionMessage) => void,
317
+ recentTypedMessages: React.RefObject<Set<string>>,
318
+ recentMessagesRef: React.RefObject<Set<string>>,
319
+ isAvatarTalkingRef: React.RefObject<boolean>
320
+ ) => {
321
+ const [gatewayState, setGatewayState] = useState<GatewayConnectionState>("disconnected");
322
+ const [isProcessingAgent, setIsProcessingAgent] = useState(false);
323
+ const [isDemoMode, setIsDemoMode] = useState(true); // Start in demo mode, switch if Gateway connects
324
+ const gatewayRef = useRef<OpenClawGatewayClient | null>(null);
325
+
326
+ // Try to connect to OpenClaw Gateway on mount
327
+ // If connection fails, stay in demo mode
328
+ useEffect(() => {
329
+ const gateway = getGatewayClient();
330
+ gatewayRef.current = gateway;
331
+
332
+ gateway.onConnectionState((state) => {
333
+ setGatewayState(state);
334
+ // If we successfully connect, disable demo mode
335
+ if (state === "connected") {
336
+ console.log("[OpenClaw] Gateway connected - switching to live mode");
337
+ setIsDemoMode(false);
338
+ }
339
+ // If we disconnect/error after being connected, fall back to demo mode
340
+ if (state === "disconnected" || state === "error") {
341
+ console.log("[OpenClaw] Gateway disconnected - falling back to demo mode");
342
+ setIsDemoMode(true);
343
+ }
344
+ });
345
+
346
+ // Try to connect to gateway
347
+ gateway.connect().catch((err) => {
348
+ console.log("[OpenClaw] Gateway not available, staying in demo mode:", err.message);
349
+ setIsDemoMode(true);
350
+ });
351
+
352
+ return () => {
353
+ gateway.disconnect();
354
+ };
355
+ }, []);
356
+
357
+ // Listen to user transcriptions and respond
358
+ useEffect(() => {
359
+ const session = sessionRef.current;
360
+ if (!session) return;
361
+
362
+ // Handler for user transcriptions
363
+ const handleUserTranscription = async (data: {
364
+ text?: string;
365
+ transcript?: string;
366
+ }) => {
367
+ const text = data.text || data.transcript || "";
368
+ if (!text.trim()) return;
369
+
370
+ // Skip if avatar is currently speaking (avoid echo/feedback loop)
371
+ if (isAvatarTalkingRef.current) {
372
+ console.log("[Voice] Ignoring transcription while avatar is speaking:", text.substring(0, 30));
373
+ return;
374
+ }
375
+
376
+ // Skip if this message was recently typed (to avoid duplicates)
377
+ if (recentTypedMessages.current?.has(text.trim())) {
378
+ recentTypedMessages.current.delete(text.trim());
379
+ return;
380
+ }
381
+
382
+ // Skip if we've already seen this exact message recently (dedupe)
383
+ const messageKey = `user:${text.trim()}`;
384
+ if (recentMessagesRef.current?.has(messageKey)) {
385
+ return;
386
+ }
387
+ recentMessagesRef.current?.add(messageKey);
388
+ setTimeout(() => recentMessagesRef.current?.delete(messageKey), 3000);
389
+
390
+ // Add user message to chat
391
+ addMessage({
392
+ sender: MessageSender.USER,
393
+ message: text,
394
+ timestamp: Date.now(),
395
+ });
396
+
397
+ try {
398
+ setIsProcessingAgent(true);
399
+
400
+ let responseText: string;
401
+ let placeholderSpoken = false;
402
+
403
+ if (isDemoMode) {
404
+ // Demo mode: use comprehensive FAQ responses
405
+ console.log("[Demo] Processing voice message locally:", text);
406
+ responseText = getDemoResponse(text);
407
+ } else {
408
+ // Production mode: send to OpenClaw Gateway
409
+ const gateway = gatewayRef.current;
410
+ if (!gateway || gateway.state !== "connected") {
411
+ console.warn("[OpenClaw] Gateway not connected, cannot send message");
412
+ responseText = "I'm not connected to the agent. Please check the Gateway connection.";
413
+ } else {
414
+ console.log("[OpenClaw] Sending to agent:", text);
415
+
416
+ // Set up placeholder timeout - speak a filler if response takes > 2s
417
+ let placeholderTimeout: NodeJS.Timeout | null = null;
418
+ if (session && session.state === SessionState.CONNECTED) {
419
+ placeholderTimeout = setTimeout(async () => {
420
+ const placeholder = getRandomProcessingPhrase();
421
+ console.log("[Avatar] Speaking placeholder (slow response):", placeholder);
422
+ placeholderSpoken = true;
423
+ try {
424
+ await session.repeat(placeholder);
425
+ } catch (err) {
426
+ console.error("[Avatar] Failed to speak placeholder:", err);
427
+ }
428
+ }, 2000);
429
+ }
430
+
431
+ const response = await gateway.sendToAgent(text);
432
+
433
+ // Clear placeholder timeout if response came back in time
434
+ if (placeholderTimeout) {
435
+ clearTimeout(placeholderTimeout);
436
+ }
437
+
438
+ console.log("[OpenClaw] Agent response:", response);
439
+
440
+ if (response.status === "completed" && response.text) {
441
+ responseText = response.text;
442
+ } else {
443
+ responseText = "Sorry, I didn't get a response from the agent.";
444
+ }
445
+ }
446
+ }
447
+
448
+ // Parse response to extract TTS summary and full message
449
+ const gateway = gatewayRef.current;
450
+ let ttsText = responseText;
451
+ let displayText = responseText;
452
+
453
+ if (!isDemoMode && gateway) {
454
+ const parsed = gateway.parseResponse(responseText);
455
+ ttsText = parsed.tts;
456
+ displayText = parsed.full;
457
+ } else {
458
+ // Demo mode: just truncate for TTS
459
+ ttsText = truncateForTTS(responseText);
460
+ }
461
+
462
+ // Add full response to chat (without the TTS block)
463
+ addMessage({
464
+ sender: MessageSender.AVATAR,
465
+ message: displayText,
466
+ timestamp: Date.now(),
467
+ });
468
+
469
+ // Make avatar speak - only the TTS summary
470
+ // If placeholder was spoken, wait a moment before speaking the actual response
471
+ if (session && session.state === SessionState.CONNECTED) {
472
+ try {
473
+ if (placeholderSpoken) {
474
+ // Small delay to let placeholder finish
475
+ await new Promise(resolve => setTimeout(resolve, 500));
476
+ }
477
+ console.log("[Avatar] Speaking TTS summary:", ttsText);
478
+ await session.repeat(ttsText);
479
+ } catch (speakErr) {
480
+ console.error("[Avatar] Failed to make avatar speak:", speakErr);
481
+ }
482
+ }
483
+ } catch (err) {
484
+ console.error("[Chat] Failed to process voice message:", err);
485
+ addMessage({
486
+ sender: MessageSender.AVATAR,
487
+ message: "Sorry, I couldn't process that. Please try again.",
488
+ timestamp: Date.now(),
489
+ });
490
+ } finally {
491
+ setIsProcessingAgent(false);
492
+ }
493
+ };
494
+
495
+ // Register listener for user transcription
496
+ session.on(AgentEventsEnum.USER_TRANSCRIPTION, handleUserTranscription);
497
+
498
+ return () => {
499
+ session.off(AgentEventsEnum.USER_TRANSCRIPTION, handleUserTranscription);
500
+ };
501
+ }, [sessionRef, addMessage, recentTypedMessages, recentMessagesRef, isDemoMode]);
502
+
503
+ return { gatewayState, isProcessingAgent, isDemoMode };
504
+ };
505
+
506
+ export const LiveAvatarContextProvider = ({
507
+ children,
508
+ sessionAccessToken,
509
+ }: LiveAvatarContextProviderProps) => {
510
+ // Voice chat config - start unmuted so user can speak immediately
511
+ const config = {
512
+ voiceChat: {
513
+ defaultMuted: false,
514
+ },
515
+ apiUrl: LIVEAVATAR_API_URL,
516
+ };
517
+ const sessionRef = useRef<LiveAvatarSession>(
518
+ new LiveAvatarSession(sessionAccessToken, config)
519
+ );
520
+
521
+ const [messages, setMessages] = useState<LiveAvatarSessionMessage[]>([]);
522
+
523
+ // Track recently typed messages to avoid duplicates from transcription events
524
+ const recentTypedMessagesRef = useRef<Set<string>>(new Set());
525
+ // Track all recent messages to dedupe events that fire multiple times
526
+ const recentMessagesRef = useRef<Set<string>>(new Set());
527
+
528
+ const addMessage = useCallback((message: LiveAvatarSessionMessage) => {
529
+ setMessages((prev) => [...prev, message]);
530
+ }, []);
531
+
532
+ const { sessionState, isStreamReady, connectionQuality } =
533
+ useSessionState(sessionRef);
534
+
535
+ const { isMuted, voiceChatState } = useVoiceChatState(sessionRef);
536
+ const { isUserTalking, isAvatarTalking } = useTalkingState(sessionRef);
537
+
538
+ // Ref to track avatar talking state for use in async handlers (avoids stale closure)
539
+ const isAvatarTalkingRef = useRef(false);
540
+ useEffect(() => {
541
+ isAvatarTalkingRef.current = isAvatarTalking;
542
+ }, [isAvatarTalking]);
543
+
544
+ // Bridge to OpenClaw Gateway - this determines demo mode
545
+ const { gatewayState, isProcessingAgent: isProcessingVoiceAgent, isDemoMode } = useOpenClawBridge(
546
+ sessionRef,
547
+ addMessage,
548
+ recentTypedMessagesRef,
549
+ recentMessagesRef,
550
+ isAvatarTalkingRef
551
+ );
552
+
553
+ // State for tracking if we're processing a typed message
554
+ const [isProcessingTypedMessage, setIsProcessingTypedMessage] = useState(false);
555
+ const gatewayClientRef = useRef<OpenClawGatewayClient | null>(null);
556
+ const hasPlayedIntroRef = useRef(false);
557
+
558
+ // Store gateway client reference
559
+ useEffect(() => {
560
+ gatewayClientRef.current = getGatewayClient();
561
+ }, []);
562
+
563
+ // Play intro message when stream is ready
564
+ useEffect(() => {
565
+ console.log("[Intro] Effect triggered - isStreamReady:", isStreamReady, "hasPlayed:", hasPlayedIntroRef.current);
566
+
567
+ if (!isStreamReady) return;
568
+ if (hasPlayedIntroRef.current) return;
569
+
570
+ const session = sessionRef.current;
571
+ if (!session) {
572
+ console.log("[Intro] No session ref");
573
+ return;
574
+ }
575
+
576
+ console.log("[Intro] Session state:", session.state);
577
+
578
+ // Mark as played immediately to prevent double-play
579
+ hasPlayedIntroRef.current = true;
580
+
581
+ const playIntro = async () => {
582
+ const introPhrase = getRandomIntroPhrase(isDemoMode);
583
+
584
+ addMessage({
585
+ sender: MessageSender.AVATAR,
586
+ message: introPhrase,
587
+ timestamp: Date.now(),
588
+ });
589
+
590
+ try {
591
+ console.log("[Intro] Playing intro:", introPhrase);
592
+ await session.repeat(introPhrase);
593
+ console.log("[Intro] Intro played successfully");
594
+ } catch (err) {
595
+ console.error("[Intro] Failed to play intro:", err);
596
+ }
597
+ };
598
+
599
+ // Small delay to ensure avatar is fully ready to speak
600
+ setTimeout(playIntro, 1000);
601
+ }, [isStreamReady, isDemoMode, addMessage, sessionRef]);
602
+
603
+ // Add a typed message (from text input) - adds to messages and gets response
604
+ const addTypedMessage = useCallback(
605
+ async (text: string) => {
606
+ // Track this message so we can skip it if it appears in transcription
607
+ recentTypedMessagesRef.current.add(text);
608
+ // Clear from tracking after a short delay
609
+ setTimeout(() => {
610
+ recentTypedMessagesRef.current.delete(text);
611
+ }, 2000);
612
+
613
+ // Add to messages
614
+ addMessage({
615
+ sender: MessageSender.USER,
616
+ message: text,
617
+ timestamp: Date.now(),
618
+ });
619
+
620
+ try {
621
+ setIsProcessingTypedMessage(true);
622
+
623
+ let responseText: string;
624
+ let placeholderSpoken = false;
625
+ const session = sessionRef.current;
626
+
627
+ if (isDemoMode) {
628
+ // Demo mode: use comprehensive FAQ responses
629
+ console.log("[Demo] Processing typed message locally:", text);
630
+ responseText = getDemoResponse(text);
631
+ } else {
632
+ // Production mode: send to OpenClaw Gateway
633
+ const gateway = gatewayClientRef.current;
634
+ if (!gateway || gateway.state !== "connected") {
635
+ console.warn("[OpenClaw] Gateway not connected, cannot send typed message");
636
+ responseText = "I'm not connected to the OpenClaw agent yet. Please make sure the Gateway is running.";
637
+ } else {
638
+ console.log("[OpenClaw] Sending typed message to agent:", text);
639
+
640
+ // Set up placeholder timeout - speak a filler if response takes > 2s
641
+ let placeholderTimeout: NodeJS.Timeout | null = null;
642
+ if (session && session.state === SessionState.CONNECTED) {
643
+ placeholderTimeout = setTimeout(async () => {
644
+ const placeholder = getRandomProcessingPhrase();
645
+ console.log("[Avatar] Speaking placeholder (slow response):", placeholder);
646
+ placeholderSpoken = true;
647
+ try {
648
+ await session.repeat(placeholder);
649
+ } catch (err) {
650
+ console.error("[Avatar] Failed to speak placeholder:", err);
651
+ }
652
+ }, 2000);
653
+ }
654
+
655
+ const response = await gateway.sendToAgent(text);
656
+
657
+ // Clear placeholder timeout if response came back in time
658
+ if (placeholderTimeout) {
659
+ clearTimeout(placeholderTimeout);
660
+ }
661
+
662
+ console.log("[OpenClaw] Agent response:", response);
663
+
664
+ if (response.status === "completed" && response.text) {
665
+ responseText = response.text;
666
+ } else {
667
+ responseText = "Sorry, I didn't get a response from the agent.";
668
+ }
669
+ }
670
+ }
671
+
672
+ // Parse response to extract TTS summary and full message
673
+ const gateway = gatewayClientRef.current;
674
+ let ttsText = responseText;
675
+ let displayText = responseText;
676
+
677
+ if (!isDemoMode && gateway) {
678
+ const parsed = gateway.parseResponse(responseText);
679
+ ttsText = parsed.tts;
680
+ displayText = parsed.full;
681
+ } else {
682
+ // Demo mode: just truncate for TTS
683
+ ttsText = truncateForTTS(responseText);
684
+ }
685
+
686
+ // Add full response to chat (without the TTS block)
687
+ addMessage({
688
+ sender: MessageSender.AVATAR,
689
+ message: displayText,
690
+ timestamp: Date.now(),
691
+ });
692
+
693
+ // Make avatar speak - only the TTS summary
694
+ // If placeholder was spoken, wait a moment before speaking the actual response
695
+ if (session && session.state === SessionState.CONNECTED) {
696
+ try {
697
+ if (placeholderSpoken) {
698
+ // Small delay to let placeholder finish
699
+ await new Promise(resolve => setTimeout(resolve, 500));
700
+ }
701
+ console.log("[Avatar] Speaking TTS summary:", ttsText);
702
+ await session.repeat(ttsText);
703
+ } catch (speakErr) {
704
+ console.error("[Avatar] Failed to make avatar speak:", speakErr);
705
+ }
706
+ }
707
+ } catch (err) {
708
+ console.error("[Chat] Failed to process message:", err);
709
+ addMessage({
710
+ sender: MessageSender.AVATAR,
711
+ message: "Sorry, I couldn't process that. Please try again.",
712
+ timestamp: Date.now(),
713
+ });
714
+ } finally {
715
+ setIsProcessingTypedMessage(false);
716
+ }
717
+ },
718
+ [addMessage, sessionRef, isDemoMode]
719
+ );
720
+
721
+ // Combine processing states from voice and typed messages
722
+ const isProcessingAgent = isProcessingVoiceAgent || isProcessingTypedMessage;
723
+
724
+ return (
725
+ <LiveAvatarContext.Provider
726
+ value={{
727
+ sessionRef,
728
+ sessionState,
729
+ isStreamReady,
730
+ connectionQuality,
731
+ isMuted,
732
+ voiceChatState,
733
+ isUserTalking,
734
+ isAvatarTalking,
735
+ messages,
736
+ addMessage,
737
+ addTypedMessage,
738
+ gatewayState,
739
+ isProcessingAgent,
740
+ isDemoMode,
741
+ }}
742
+ >
743
+ {children}
744
+ </LiveAvatarContext.Provider>
745
+ );
746
+ };
747
+
748
+ export const useLiveAvatarContext = () => {
749
+ return useContext(LiveAvatarContext);
750
+ };