polymorph-sdk 0.2.0 → 0.2.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polymorph-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "src/index.ts",
@@ -8,7 +8,11 @@
8
8
  "exports": {
9
9
  ".": "./src/index.ts"
10
10
  },
11
- "files": ["dist", "src", "README.md"],
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "README.md"
15
+ ],
12
16
  "publishConfig": {
13
17
  "access": "public",
14
18
  "main": "dist/index.js",
@@ -1,26 +1,90 @@
1
- import { useEffect, useRef } from "react";
2
- import type { ChatMessage } from "./types";
1
+ import { useCallback, useRef } from "react";
3
2
  import styles from "./styles.module.css";
3
+ import type { ChatMessage, SessionStatus } from "./types";
4
4
 
5
- export function ChatThread({ messages, primaryColor }: { messages: ChatMessage[]; primaryColor: string }) {
5
+ export function ChatThread({
6
+ messages,
7
+ primaryColor,
8
+ status,
9
+ }: {
10
+ messages: ChatMessage[];
11
+ primaryColor: string;
12
+ status: SessionStatus;
13
+ }) {
6
14
  const scrollRef = useRef<HTMLDivElement>(null);
15
+ const isNearBottom = useRef(true);
16
+ const prevItemCount = useRef(0);
17
+ const agentCountAtConnect = useRef<number | null>(null);
18
+ const agentMessageCount = messages.filter((m) => m.role === "agent").length;
19
+ const lastMessageIsUser =
20
+ messages.length > 0 && messages[messages.length - 1].role === "user";
7
21
 
8
- useEffect(() => {
9
- scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
10
- }, [messages]);
22
+ const handleScroll = useCallback(() => {
23
+ const el = scrollRef.current;
24
+ if (!el) return;
25
+ isNearBottom.current =
26
+ el.scrollHeight - el.scrollTop - el.clientHeight < 100;
27
+ }, []);
28
+
29
+ // Snapshot agent message count when connecting starts
30
+ if (status === "connecting" && agentCountAtConnect.current === null) {
31
+ agentCountAtConnect.current = agentMessageCount;
32
+ }
33
+ if (status === "idle" || status === "error") {
34
+ agentCountAtConnect.current = null;
35
+ }
36
+
37
+ const initialConnectThinking =
38
+ (status === "connecting" || status === "connected") &&
39
+ agentCountAtConnect.current !== null &&
40
+ agentMessageCount <= agentCountAtConnect.current;
41
+ const waitingForAgentReply =
42
+ status === "connected" &&
43
+ messages.length > 0 &&
44
+ messages[messages.length - 1].role === "user";
45
+ const showThinking = initialConnectThinking || waitingForAgentReply;
46
+
47
+ // Auto-scroll when new messages arrive or thinking indicator appears.
48
+ // Uses render-time comparison instead of useEffect to avoid lint issues
49
+ // with intentional "trigger" dependencies.
50
+ const itemCount = messages.length + (showThinking ? 1 : 0);
51
+ if (itemCount !== prevItemCount.current) {
52
+ prevItemCount.current = itemCount;
53
+ if (isNearBottom.current || lastMessageIsUser) {
54
+ queueMicrotask(() => {
55
+ scrollRef.current?.scrollTo({
56
+ top: scrollRef.current.scrollHeight,
57
+ behavior: "smooth",
58
+ });
59
+ });
60
+ }
61
+ }
11
62
 
12
63
  return (
13
- <div ref={scrollRef} className={styles.chatThread}>
64
+ <div ref={scrollRef} className={styles.chatThread} onScroll={handleScroll}>
14
65
  {messages.map((msg) => (
15
66
  <div
16
67
  key={msg.id}
17
68
  className={`${styles.messageBubble} ${msg.role === "user" ? styles.userMessage : styles.agentMessage}`}
18
- style={msg.role === "user" ? { backgroundColor: primaryColor } : undefined}
69
+ style={
70
+ msg.role === "user" ? { backgroundColor: primaryColor } : undefined
71
+ }
19
72
  >
20
73
  {msg.text}
21
- {msg.source === "voice" && <div className={styles.voiceLabel}>voice</div>}
74
+ {msg.source === "voice" && (
75
+ <div className={styles.voiceLabel}>voice</div>
76
+ )}
22
77
  </div>
23
78
  ))}
79
+ {showThinking && (
80
+ <div className={`${styles.messageBubble} ${styles.agentMessage}`}>
81
+ <div className={styles.thinkingDots}>
82
+ <span />
83
+ <span />
84
+ <span />
85
+ </div>
86
+ </div>
87
+ )}
24
88
  </div>
25
89
  );
26
90
  }
@@ -1,14 +1,52 @@
1
+ import { LiveKitRoom } from "@livekit/components-react";
1
2
  import { MantineProvider } from "@mantine/core";
2
3
  import "@mantine/core/styles.css";
3
- import { useState } from "react";
4
+ import { useRef, useState } from "react";
5
+ import { RoomHandler } from "./RoomHandler";
4
6
  import styles from "./styles.module.css";
5
7
  import { buildWidgetTheme } from "./theme";
6
8
  import type { WidgetConfig } from "./types";
7
9
  import { usePolymorphSession } from "./usePolymorphSession";
8
10
  import { WidgetPanel } from "./WidgetPanel";
9
11
 
10
- // Chat bubble icon for FAB
11
- function ChatIcon() { return <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>; }
12
+ function ChatIcon() {
13
+ return (
14
+ <svg
15
+ xmlns="http://www.w3.org/2000/svg"
16
+ width="24"
17
+ height="24"
18
+ viewBox="0 0 24 24"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ strokeWidth="2"
22
+ strokeLinecap="round"
23
+ strokeLinejoin="round"
24
+ >
25
+ <title>Chat</title>
26
+ <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
27
+ </svg>
28
+ );
29
+ }
30
+
31
+ function FabCloseIcon() {
32
+ return (
33
+ <svg
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ width="24"
36
+ height="24"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ strokeWidth="2"
41
+ strokeLinecap="round"
42
+ strokeLinejoin="round"
43
+ >
44
+ <title>Close</title>
45
+ <path d="M18 6 6 18" />
46
+ <path d="m6 6 12 12" />
47
+ </svg>
48
+ );
49
+ }
12
50
 
13
51
  export function PolymorphWidget(props: WidgetConfig) {
14
52
  const { position = "bottom-right", branding } = props;
@@ -16,26 +54,60 @@ export function PolymorphWidget(props: WidgetConfig) {
16
54
  const session = usePolymorphSession(props);
17
55
  const primaryColor = branding?.primaryColor || "#171717";
18
56
  const theme = buildWidgetTheme(primaryColor);
57
+ const rootRef = useRef<HTMLDivElement>(null);
19
58
 
20
59
  return (
21
- <MantineProvider theme={theme} defaultColorScheme="light">
22
- <div className={`${styles.widgetRoot} ${position === "bottom-left" ? styles.bottomLeft : styles.bottomRight}`}>
23
- {open && (
24
- <WidgetPanel
25
- config={props}
26
- session={session}
27
- onClose={() => setOpen(false)}
28
- />
29
- )}
60
+ <div
61
+ ref={rootRef}
62
+ className={`polymorph-widget ${styles.widgetRoot} ${position === "bottom-left" ? styles.bottomLeft : styles.bottomRight}`}
63
+ style={{ colorScheme: props.darkMode ? "dark" : "light" }}
64
+ >
65
+ <MantineProvider
66
+ theme={theme}
67
+ forceColorScheme={props.darkMode ? "dark" : "light"}
68
+ cssVariablesSelector=".polymorph-widget"
69
+ getRootElement={() => rootRef.current ?? undefined}
70
+ >
71
+ <WidgetPanel
72
+ config={props}
73
+ session={session}
74
+ onClose={() => setOpen(false)}
75
+ hidden={!open}
76
+ />
30
77
  <button
31
78
  type="button"
32
- className={styles.fab}
79
+ className={`${styles.fab} ${open ? styles.fabOpen : ""}`}
33
80
  style={{ backgroundColor: primaryColor }}
34
- onClick={() => setOpen(prev => !prev)}
81
+ onClick={() => setOpen((prev) => !prev)}
35
82
  >
36
- <ChatIcon />
83
+ {open ? <FabCloseIcon /> : <ChatIcon />}
37
84
  </button>
38
- </div>
39
- </MantineProvider>
85
+ {session.roomConnection && (
86
+ <LiveKitRoom
87
+ token={session.roomConnection.token}
88
+ serverUrl={session.roomConnection.livekitUrl}
89
+ connect={true}
90
+ audio={
91
+ session.isVoiceEnabled
92
+ ? {
93
+ echoCancellation: true,
94
+ noiseSuppression: true,
95
+ autoGainControl: true,
96
+ }
97
+ : false
98
+ }
99
+ video={false}
100
+ style={{ display: "none" }}
101
+ onDisconnected={session.disconnect}
102
+ >
103
+ <RoomHandler
104
+ setRoom={session.setRoom}
105
+ addMessage={session.addMessage}
106
+ isVoiceEnabled={session.isVoiceEnabled}
107
+ />
108
+ </LiveKitRoom>
109
+ )}
110
+ </MantineProvider>
111
+ </div>
40
112
  );
41
113
  }
@@ -0,0 +1,99 @@
1
+ import { RoomAudioRenderer, useRoomContext } from "@livekit/components-react";
2
+ import type { Room } from "livekit-client";
3
+ import {
4
+ type DataPacket_Kind,
5
+ type Participant,
6
+ ParticipantKind,
7
+ RoomEvent,
8
+ type TranscriptionSegment,
9
+ } from "livekit-client";
10
+ import { useEffect } from "react";
11
+
12
+ export function RoomHandler({
13
+ setRoom,
14
+ addMessage,
15
+ isVoiceEnabled,
16
+ }: {
17
+ setRoom: (room: Room | null) => void;
18
+ addMessage: (
19
+ role: "user" | "agent",
20
+ text: string,
21
+ source: "chat" | "voice",
22
+ ) => void;
23
+ isVoiceEnabled: boolean;
24
+ }) {
25
+ const room = useRoomContext();
26
+
27
+ useEffect(() => {
28
+ setRoom(room);
29
+ return () => {
30
+ setRoom(null);
31
+ };
32
+ }, [room, setRoom]);
33
+
34
+ // Send initial voice state to agent once connected
35
+ useEffect(() => {
36
+ const sendVoiceState = () => {
37
+ const payload = new TextEncoder().encode(
38
+ JSON.stringify({ voice_enabled: isVoiceEnabled }),
39
+ );
40
+ room.localParticipant.publishData(payload, {
41
+ reliable: true,
42
+ topic: "voice_mode",
43
+ });
44
+ };
45
+
46
+ if (room.state === "connected") {
47
+ sendVoiceState();
48
+ } else {
49
+ const onConnected = () => sendVoiceState();
50
+ room.on(RoomEvent.Connected, onConnected);
51
+ return () => {
52
+ room.off(RoomEvent.Connected, onConnected);
53
+ };
54
+ }
55
+ }, [room, isVoiceEnabled]);
56
+
57
+ useEffect(() => {
58
+ const handleDataReceived = (
59
+ payload: Uint8Array,
60
+ _participant?: Participant,
61
+ _kind?: DataPacket_Kind,
62
+ topic?: string,
63
+ ) => {
64
+ if (topic === "chat_message") {
65
+ try {
66
+ const data = JSON.parse(new TextDecoder().decode(payload));
67
+ if (data.type === "chat_response") {
68
+ addMessage("agent", data.text, "chat");
69
+ }
70
+ } catch {
71
+ /* ignore malformed */
72
+ }
73
+ }
74
+ };
75
+
76
+ const handleTranscription = (
77
+ segments: TranscriptionSegment[],
78
+ participant?: Participant,
79
+ ) => {
80
+ const finalSegments = segments.filter((s) => s.final);
81
+ if (finalSegments.length > 0) {
82
+ const text = finalSegments.map((s) => s.text).join(" ");
83
+ const isAgent =
84
+ participant?.kind === ParticipantKind.AGENT ||
85
+ participant?.identity?.includes("agent");
86
+ addMessage(isAgent ? "agent" : "user", text, "voice");
87
+ }
88
+ };
89
+
90
+ room.on(RoomEvent.DataReceived, handleDataReceived);
91
+ room.on(RoomEvent.TranscriptionReceived, handleTranscription);
92
+ return () => {
93
+ room.off(RoomEvent.DataReceived, handleDataReceived);
94
+ room.off(RoomEvent.TranscriptionReceived, handleTranscription);
95
+ };
96
+ }, [room, addMessage]);
97
+
98
+ return <RoomAudioRenderer />;
99
+ }
@@ -3,7 +3,17 @@ import type { SessionStatus } from "./types";
3
3
 
4
4
  function VolumeIcon() {
5
5
  return (
6
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
6
+ <svg
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ width="14"
9
+ height="14"
10
+ viewBox="0 0 24 24"
11
+ fill="none"
12
+ stroke="currentColor"
13
+ strokeWidth="2"
14
+ strokeLinecap="round"
15
+ strokeLinejoin="round"
16
+ >
7
17
  <path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
8
18
  <path d="M16 9a5 5 0 0 1 0 6" />
9
19
  <path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
@@ -13,7 +23,17 @@ function VolumeIcon() {
13
23
 
14
24
  function VolumeOffIcon() {
15
25
  return (
16
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
26
+ <svg
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ width="14"
29
+ height="14"
30
+ viewBox="0 0 24 24"
31
+ fill="none"
32
+ stroke="currentColor"
33
+ strokeWidth="2"
34
+ strokeLinecap="round"
35
+ strokeLinejoin="round"
36
+ >
17
37
  <path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
18
38
  <line x1="22" x2="16" y1="9" y2="15" />
19
39
  <line x1="16" x2="22" y1="9" y2="15" />